# -*- coding: utf-8 -*- """ Centralita Torá — HuggingFace Space (Gradio) Leer hebreo real + español fluido con exégesis de Groq y audio optimizado """ import os, glob, json, tempfile, html, asyncio, subprocess, shutil, re, time import urllib.request import urllib.error import gradio as gr import edge_tts try: from gtts import gTTS except Exception: gTTS = None from gradio_theme_fenix import fenix_theme, FENIX_CSS # --- CONFIGURACIÓN DE VOZ Y AUDIO --- VOZ_DIVINA = "es-ES-AlvaroNeural" # voz de Dios (grave, con pitch -5Hz en generar_bloque) VOZ_NARRADOR = "es-MX-JorgeNeural" # voz del narrador (distinta a la de Dios) # Perillas de entonación para Narrador en Español VOZ_RATE = "-6%" VOZ_PITCH = "-22Hz" # Fondo musical FONDO = "fondo.mp3" FONDO_VOL = 0.18 # IA de Estudio y Refinamiento (Groq) MODELO_ESTUDIA = "openai/gpt-oss-120b" CARPETA = "libros" GROQ_URL = "https://api.groq.com/openai/v1/chat/completions" GROQ_KEY = os.environ.get("GROQ_API_KEY") # --- SUPABASE: LECTURA DEL TANAJ ENCUADRADO --- from supabase import create_client, Client SUPABASE_URL = os.environ.get("SUPABASE_URL") or "https://bvyzbgrokexdvrhqqrla.supabase.co" SUPABASE_KEY = os.environ.get("SUPABASE_KEY") # anon/public key basta para SOLO LEER TABLA_TANAJ = "traducciones" # una fila por versiculo: libro, capitulo, verso, modo, es MODO_A_CARGAR = "encuadrado" # el Tanaj revisado y guardado desde tanakh-translator _supabase: "Client | None" = None if SUPABASE_URL and SUPABASE_KEY: try: _supabase = create_client(SUPABASE_URL, SUPABASE_KEY) except Exception as e: print("No pude crear el cliente de Supabase:", e) def _limpiar_entidades(s: str) -> str: """Deshace entidades HTML aunque vengan DOBLEMENTE escapadas (p.ej. '&thinsp;' -> ' '), que es lo que rompía el hebreo en pantalla mostrando literalmente ' '.""" prev = None out = s or "" for _ in range(3): if out == prev: break prev = out out = html.unescape(out) return out # --- TRADUCCIÓN CON SENTIDO (GROQ), VERSÍCULO POR VERSÍCULO --- # Groq traduce DESDE EL HEBREO (fuente segura, siempre presente) a un español # fluido y con sentido tradicional, devolviendo UNA LÍNEA POR VERSÍCULO. Así el # mismo texto con sentido se muestra alineado y se usa EXACTAMENTE en el audio. def _traducir_lote_groq(pares): """ Traduce UN LOTE pequeño de versículos (para no chocar con el límite de tokens por minuto de Groq). pares: lista de (n, hebreo, es_literal). Devuelve (dict {n: español_con_sentido}, estado). """ fallback = {n: (es or he) for (n, he, es) in pares} if not pares: return fallback, "sin_pares" if not GROQ_KEY: return fallback, "sin_key" # Bloque de entrada numerado: el hebreo es la fuente; el literal español, # si existe, va solo como ayuda. lineas_in = [] for (n, he, es) in pares: ayuda = f" (ayuda literal: {es})" if es else "" lineas_in.append(f"[{n}] {he}{ayuda}") entrada = "\n".join(lineas_in) sistema = ( "Eres un experto en traducción bíblica del hebreo y en exégesis tradicional. " "Traduces cada versículo hebreo a un español fluido, claro y natural, " "con el sentido tradicional exacto, sin perder fidelidad ni profundidad. " "Reglas de salida ESTRICTAS: devuelve SOLO las traducciones, una por línea, " "cada línea empezando por el número entre corchetes tal cual: '[N] traducción'. " "No incluyas el texto hebreo, ni títulos, ni comentarios, ni notas. " "Conserva el mismo número de versículos que recibas." ) max_tok = min(2000, max(400, len(pares) * 90)) cuerpo = { "model": MODELO_ESTUDIA, "temperature": 0.3, "max_tokens": max_tok, "messages": [ {"role": "system", "content": sistema}, {"role": "user", "content": f"Traduce con sentido cada versículo:\n\n{entrada}"}, ], } try: data = json.dumps(cuerpo).encode("utf-8") req = urllib.request.Request(GROQ_URL, data=data, method="POST") req.add_header("Content-Type", "application/json") req.add_header("Authorization", f"Bearer {GROQ_KEY}") req.add_header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36") with urllib.request.urlopen(req, timeout=120) as r: d = json.load(r) salida = (d["choices"][0]["message"]["content"] or "").strip() except urllib.error.HTTPError as e: cuerpo_error = "" try: cuerpo_error = e.read().decode("utf-8", errors="replace")[:300] except Exception: pass motivo = f"error HTTP {e.code}: {cuerpo_error or e.reason}" print(f"Error traduciendo con Groq: {motivo}") return fallback, motivo except Exception as e: motivo = f"error: {type(e).__name__}: {e}" print(f"Error traduciendo con Groq: {motivo}") return fallback, motivo if not salida: return fallback, "respuesta vacía de Groq" # Parseamos las líneas '[N] texto' a un diccionario por versículo. res = {} for linea in salida.splitlines(): m = re.match(r"\s*\[?(\d+)\]?[\.\)\|:\-]?\s*(.+)$", linea.strip()) if m: res[int(m.group(1))] = m.group(2).strip() if not res: return fallback, "no se pudo interpretar la respuesta de Groq" # Si Groq se saltó algún versículo, se completa con el fallback. final = {n: res.get(n, fallback[n]) for (n, he, es) in pares} faltantes = sum(1 for (n, he, es) in pares if n not in res) estado = "ok" if faltantes == 0 else f"ok (Groq omitió {faltantes} versículo(s), se completó con literal)" return final, estado # Tamaño de lote: capítulos largos (p.ej. Génesis 1, 31 versículos) superan el # límite de tokens por minuto de Groq si se mandan de una vez. Se trocea en # bloques pequeños y se hacen varias llamadas, uniendo los resultados. TAMANO_LOTE_GROQ = 6 def _segundos_de_espera(motivo_error): """Si el error es un 429 con 'Please try again in X s', devuelve X (+1s de margen).""" m = re.search(r"try again in ([\d.]+)s", motivo_error) if m: try: return float(m.group(1)) + 1.0 except ValueError: pass return None def traducir_capitulo_con_groq(pares): """ pares: lista de (n, hebreo, es_literal) de TODO el capítulo. Trocea en lotes pequeños, traduce cada uno con Groq y une los resultados. Si Groq responde 429 (límite de tokens por minuto), espera el tiempo que Groq indica y reintenta ese lote (hasta 3 intentos) antes de rendirse. Devuelve (dict {n: español_con_sentido}, estado_general). """ if not pares: return {}, "sin_pares" resultado = {} lotes_ok = 0 lotes_total = 0 motivos_fallo = [] for i in range(0, len(pares), TAMANO_LOTE_GROQ): lote = pares[i:i + TAMANO_LOTE_GROQ] lotes_total += 1 parcial, estado_lote = _traducir_lote_groq(lote) intentos = 1 while "error HTTP 429" in estado_lote and intentos < 3: espera = _segundos_de_espera(estado_lote) or 8.0 time.sleep(espera) parcial, estado_lote = _traducir_lote_groq(lote) intentos += 1 resultado.update(parcial) if estado_lote == "ok" or estado_lote.startswith("ok ("): lotes_ok += 1 else: motivos_fallo.append(f"versículos {lote[0][0]}-{lote[-1][0]}: {estado_lote}") if lotes_ok == lotes_total: estado_general = "ok" elif lotes_ok == 0: estado_general = "; ".join(motivos_fallo[:3]) else: estado_general = ( f"parcial ({lotes_ok}/{lotes_total} lotes con Groq, el resto literal) — " + "; ".join(motivos_fallo[:3]) ) return resultado, estado_general # --- PROCESAMIENTO ACÚSTICO FFMPEG --- def procesar_audio_narrador(ruta): if not shutil.which("ffmpeg"): return ruta filtros = [ "atempo=0.92", "bass=g=5:f=120", "treble=g=2", "aecho=0.8:0.88:40:0.15" ] salida = ruta[:-4] + "_narrador.mp3" cmd = ["ffmpeg", "-y", "-i", ruta, "-af", ", ".join(filtros), "-ac", "2", salida] try: subprocess.run(cmd, check=True, capture_output=True, timeout=60) if os.path.getsize(salida) > 0: return salida except Exception: pass return ruta def procesar_audio_divino(ruta): if not shutil.which("ffmpeg"): return ruta filtros = [ "atempo=0.90", # Más pausado y solemne "asetrate=44100*0.55", # Bajar tono (más grave y imponente) "aresample=144000", # Reajustar sample rate "bass=g=15:f=200", # Graves profundos sub-bass "treble=g=-15", # Sonido cálido, sin aristas agudas "volume=2.0", # Voz divina al doble de volumen (por encima del narrador, que queda en 1.0) ] salida = ruta[:-4] + "_divino.mp3" cmd = ["ffmpeg", "-y", "-i", ruta, "-af", ", ".join(filtros), "-ac", "2", salida] try: subprocess.run(cmd, check=True, capture_output=True, timeout=60) if os.path.getsize(salida) > 0: return salida except Exception: pass return ruta def _duracion_audio(ruta): """Duración en segundos de un archivo de audio, vía ffprobe. Se usa para calcular en qué instante empieza y termina cada versículo dentro del audio final, y así poder iluminar la frase que se está locutando.""" if not (ruta and os.path.exists(ruta) and shutil.which("ffprobe")): return 0.0 try: cmd = ["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrapped=1:nokey=1", ruta] out = subprocess.run(cmd, capture_output=True, text=True, timeout=30).stdout.strip() return float(out) except Exception: return 0.0 def concatenar_audios(lista_rutas): if not lista_rutas: return None if len(lista_rutas) == 1: return lista_rutas[0] salida_final = tempfile.NamedTemporaryFile(suffix=".mp3", delete=False).name list_file = tempfile.NamedTemporaryFile(suffix=".txt", mode="w", delete=False, encoding="utf-8") for r in lista_rutas: list_file.write(f"file '{os.path.abspath(r)}'\n") list_file.close() cmd = ["ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", list_file.name, "-c", "copy", salida_final] try: subprocess.run(cmd, check=True, capture_output=True, timeout=120) os.remove(list_file.name) return salida_final except Exception: if os.path.exists(list_file.name): os.remove(list_file.name) return lista_rutas[0] def mezclar_fondo(voz_path): if not (os.path.exists(FONDO) and shutil.which("ffmpeg")): return voz_path salida = voz_path[:-4] + "_mix.mp3" filtro = (f"[1:a]volume={FONDO_VOL}[m];" f"[0:a][m]amix=inputs=2:duration=first:normalize=0[a]") cmd = ["ffmpeg", "-y", "-i", voz_path, "-stream_loop", "-1", "-i", FONDO, "-filter_complex", filtro, "-map", "[a]", salida] try: subprocess.run(cmd, check=True, capture_output=True, timeout=120) if os.path.getsize(salida) > 0: return salida except Exception: pass return voz_path # --- SEPARACIÓN DE BLOQUES (NARRADOR vs DIOS) PARA ESPAÑOL --- # Fórmulas de NARRACIÓN que, si aparecen dentro de lo que dice Dios, marcan # que la voz divina ya terminó y vuelve el narrador (ej. "y fue así"). _FIN_DIOS = re.compile( r'\s*(?:;?\s*y\s+(?:fue\s+as[ií]|as[ií]\s+fue|existir\s+poner\s+erguido|' r'fue\s+la\s+(?:luz|tarde|ma[nñ]ana)|vio\s+Dios|ver\s+Dios).*)$', flags=re.IGNORECASE) def segmentar_texto_divino(texto): # Verbos de "hablar" que introducen palabras de Dios (formas del literal # incluidas: decir/dijo, llamar/llamó, bendecir/bendijo, ordenar, mandar...). verbo = (r'(?:dijo|dice|decir|diciendo|para\s+decir|llam[óo]|llamar|' r'bendijo|bendecir|habl[óo]|hablar|orden[óo]|mand[óo]|respondi[óo])') patron = re.compile( r'((?:y\s+)?' + verbo + r'\s+Dios|Dios\s+' + verbo + r')' # intro (narrador) r'([:,.\-–—]?\s*)' # separador r'([^.\n]+)', # lo que dice Dios flags=re.IGNORECASE) bloques = [] ultimo = 0 for m in patron.finditer(texto): ini, fin = m.span() if ini > ultimo: bloques.append(("narrador", texto[ultimo:ini])) intro = m.group(1) + (m.group(2) or "") dicho = m.group(3) # Si dentro de lo dicho aparece una fórmula de narración, la separamos. corte = _FIN_DIOS.search(dicho) if corte: palabras_dios = dicho[:corte.start()] cola_narrador = dicho[corte.start():] else: palabras_dios, cola_narrador = dicho, "" bloques.append(("narrador", intro)) if palabras_dios.strip(): bloques.append(("dios", palabras_dios)) if cola_narrador.strip(): bloques.append(("narrador", cola_narrador)) ultimo = fin if ultimo < len(texto): bloques.append(("narrador", texto[ultimo:])) return bloques if bloques else [("narrador", texto)] async def generar_bloque_audio(texto, es_divino): ruta = tempfile.NamedTemporaryFile(suffix=".mp3", delete=False).name voz = VOZ_DIVINA if es_divino else VOZ_NARRADOR pitch = "-5Hz" if es_divino else VOZ_PITCH rate = "-9%" if es_divino else VOZ_RATE try: com = edge_tts.Communicate(texto[:4000], voz, rate=rate, pitch=pitch) await com.save(ruta) except Exception: return None if es_divino: return procesar_audio_divino(ruta) else: return procesar_audio_narrador(ruta) async def leer_es(pares_es): """pares_es: lista de (n_verso, texto_es) a locutar en orden. Genera el audio versículo por versículo (cada uno puede a su vez partirse en narrador/dios) y devuelve, además del audio final, un timeline JSON con el inicio y fin (en segundos) de cada versículo dentro del audio, para poder iluminar en pantalla la frase que se está reproduciendo.""" if not pares_es: return None, "[]" audios_segmentos = [] duracion_por_verso = {} # n_verso -> segundos acumulados de sus fragmentos for n_verso, texto in pares_es: texto = (texto or "").strip() if not texto: continue bloques = segmentar_texto_divino(texto) for tipo, contenido in bloques: if not contenido.strip(): continue es_divino = (tipo == "dios") audio_seg = await generar_bloque_audio(contenido, es_divino) if audio_seg: audios_segmentos.append(audio_seg) duracion_por_verso[n_verso] = duracion_por_verso.get(n_verso, 0.0) + _duracion_audio(audio_seg) audio_final = concatenar_audios(audios_segmentos) # El timeline se calcula ANTES de mezclar el fondo musical (mezclar_fondo # conserva la duración total, duration=first, así que los tiempos siguen # siendo válidos igual). timeline = [] t = 0.0 for n_verso, texto in pares_es: dur = duracion_por_verso.get(n_verso, 0.0) if dur <= 0: continue timeline.append({"verso": n_verso, "start": round(t, 3), "end": round(t + dur, 3)}) t += dur if audio_final: audio_final = mezclar_fondo(audio_final) return audio_final, json.dumps(timeline, ensure_ascii=False) # --- REPRODUCTOR DE HEBREO LITÚRGICO REAL (TROPE) --- async def leer_he(nombre_espanol, capitulo): libro_id = id_por_nombre(nombre_espanol) if not libro_id: return None MAPEO_TORAH = { "Genesis": "01", "Exodus": "02", "Leviticus": "03", "Numbers": "04", "Deuteronomy": "05" } carpeta_audios = "audios_torah" if not os.path.exists(carpeta_audios): os.makedirs(carpeta_audios) ruta_local = os.path.join(carpeta_audios, f"{libro_id}_{capitulo}.mp3") if os.path.exists(ruta_local): return mezclar_fondo(ruta_local) if libro_id in MAPEO_TORAH: id_mechon = MAPEO_TORAH[libro_id] cap_formateado = f"{int(capitulo):02d}" url_audio = f"https://mechon-mamre.org/mp3/t{id_mechon}{cap_formateado}.mp3" try: req = urllib.request.Request( url_audio, headers={"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"} ) with urllib.request.urlopen(req, timeout=30) as response, open(ruta_local, 'wb') as out_file: out_file.write(response.read()) return mezclar_fondo(ruta_local) except Exception as e: print(f"Error descargando el audio: {e}") return None return None # --- CARGA Y MANEJO DE LIBROS --- NOMBRES_ES = { "Genesis": "Génesis", "Exodus": "Éxodo", "Leviticus": "Levítico", "Numbers": "Números", "Deuteronomy": "Deuteronomio", "Joshua": "Josué", "Judges": "Jueces", "Ruth": "Rut", "1_Samuel": "1 Samuel", "2_Samuel": "2 Samuel", "1_Kings": "1 Reyes", "2_Kings": "2 Reyes", "1_Chronicles": "1 Crónicas", "2_Chronicles": "2 Crónicas", "Ezra": "Esdras", "Nehemiah": "Nehemías", "Esther": "Ester", "Job": "Job", "Psalms": "Salmos", "Proverbs": "Proverbios", "Ecclesiastes": "Eclesiastés", "Song_of_Songs": "Cantar de los Cantares", "Isaiah": "Isaías", "Jeremiah": "Jeremías", "Lamentations": "Lamentaciones", "Ezekiel": "Ezequiel", "Daniel": "Daniel", "Hosea": "Oseas", "Joel": "Joel", "Amos": "Amós", "Obadiah": "Abdías", "Jonah": "Jonás", "Micah": "Miqueas", "Nahum": "Nahúm", "Habakkuk": "Habacuc", "Zephaniah": "Sofonías", "Haggai": "Hageo", "Zechariah": "Zacarías", "Malachi": "Malaquías", } def _convertir_traduccion_plana(book_id: str, data: dict) -> dict: texto = data.get("translation", "") or "" lineas = [l.strip() for l in texto.split("\n") if l.strip()] versiculos = [{"n": i + 1, "he": "", "es": linea} for i, linea in enumerate(lineas)] return { "id": book_id, "es": NOMBRES_ES.get(book_id, book_id), "heb": "", "capitulos": {"1": versiculos}, } def cargar_libros(): """Carga los libros del Tanaj desde Supabase (tabla TABLA_TANAJ, modo=MODO_A_CARGAR, actualmente 'encuadrado' = el texto revisado y guardado desde tanakh-translator). Cada fila esperada = un versículo, con columnas: libro, capitulo, verso, es.""" libros = {} if not _supabase: print("Faltan los Secrets SUPABASE_URL / SUPABASE_KEY en el Space " "(Settings → Variables and secrets). Cargando 0 libros.") return _cargar_libros_locales_fallback() # La tabla 'traducciones' guarda UNA FILA POR VERSICULO. Traemos todas las # del modo pedido, paginando (Supabase corta a 1000 por defecto), y las # agrupamos por libro y capitulo. El hebreo se lee de la carpeta libros/. filas = [] desde = 0 PASO = 1000 try: while True: resp = ( _supabase.table(TABLA_TANAJ) .select("libro,capitulo,verso,es") .eq("modo", MODO_A_CARGAR) .range(desde, desde + PASO - 1) .execute() ) lote = resp.data or [] filas.extend(lote) if len(lote) < PASO: break desde += PASO except Exception as e: print(f"No pude leer la tabla '{TABLA_TANAJ}' en Supabase: {e}") return _cargar_libros_locales_fallback() # Hebreo por versiculo desde libros/ (para mostrarlo arriba). heb_por = {} for ruta in glob.glob(os.path.join(CARPETA, "*.json")): try: with open(ruta, encoding="utf-8") as f: d = json.load(f) lid = d.get("id") or os.path.splitext(os.path.basename(ruta))[0] for cap, versos in d.get("capitulos", {}).items(): for i, v in enumerate(versos): n = v.get("n", i + 1) heb_por[(lid, str(cap), n)] = v.get("he", "") except Exception: pass # Agrupar versiculos por libro -> capitulo -> lista ordenada. tmp = {} for fila in filas: lid = fila.get("libro") cap = str(fila.get("capitulo")) n = fila.get("verso") if lid is None or n is None: continue tmp.setdefault(lid, {}).setdefault(cap, []).append({ "n": n, "es": fila.get("es", ""), "he": heb_por.get((lid, cap, n), ""), }) for lid, caps in tmp.items(): capitulos = {} for cap, versos in caps.items(): capitulos[cap] = sorted(versos, key=lambda v: v["n"]) libros[lid] = { "id": lid, "es": NOMBRES_ES.get(lid, lid), "heb": "", "capitulos": capitulos, } if not libros: print(f"Supabase respondio pero no llego ningun versiculo con modo='{MODO_A_CARGAR}' " f"en la tabla '{TABLA_TANAJ}'. Revisa nombre de tabla/columnas.") return libros def _cargar_libros_locales_fallback(): """Si Supabase no está configurado o falla, intenta la carpeta local libros/ como respaldo.""" libros = {} for ruta in sorted(glob.glob(os.path.join(CARPETA, "*.json"))): try: with open(ruta, encoding="utf-8") as f: d = json.load(f) book_id = os.path.splitext(os.path.basename(ruta))[0] if "capitulos" in d: lid = d.get("id") or book_id libros[lid] = d elif "translation" in d: libros[book_id] = _convertir_traduccion_plana(book_id, d) except Exception as e: print("No pude leer", ruta, e) return libros LIBROS = cargar_libros() ORDEN = ["Genesis", "Exodus", "Leviticus", "Numbers", "Deuteronomy"] IDS = [i for i in ORDEN if i in LIBROS] + [i for i in LIBROS if i not in ORDEN] NOMBRES = [LIBROS[i].get("es", i) for i in IDS] or ["(sube tus libros)"] def id_por_nombre(nombre): for i in IDS: if LIBROS[i].get("es", i) == nombre: return i return IDS[0] if IDS else None def versiculos(lid, cap): return LIBROS.get(lid, {}).get("capitulos", {}).get(str(cap), []) def render(nombre, cap): lid = id_por_nombre(nombre) if not lid: return "

Aún no has subido libros a la carpeta libros/.

", "", "" try: cap = max(1, int(float(cap or 1))) except Exception: cap = 1 d = LIBROS[lid] vs = versiculos(lid, cap) if not vs: return f"

No hay texto para {html.escape(d.get('es',''))} {cap}.

", "", "" # Recopilamos (n, hebreo_limpio, español_literal) por versículo. pares = [] plano_he = [] for i, v in enumerate(vs): n_verso = v.get('n', i + 1) he_raw = _limpiar_entidades(v.get("he", "")).replace(" ", " ").replace("{פ}", "").replace("{ס}", "").strip() es_raw = _limpiar_entidades(v.get("es", "")).replace(" ", " ").strip() pares.append((n_verso, he_raw, es_raw)) if he_raw: plano_he.append(he_raw) # El español YA viene encuadrado desde Supabase (modo='encuadrado'). # No se vuelve a traducir: se muestra tal cual se guardó. # Interfaz: hebreo intacto arriba y, debajo, la traducción con sentido # (sin aviso visible; el estado queda solo en los logs del Space). filas = [f"
{html.escape(_limpiar_entidades(d.get('heb','')))}
" f"{html.escape(d.get('es',''))} {cap}
"] partes_es = [] pares_es = [] # [(n_verso, texto_es)] en orden, para locutar y sincronizar el resaltado for (n_verso, he_raw, es_raw) in pares: es_sentido = (es_raw or "").strip() if es_sentido: partes_es.append(es_sentido) pares_es.append((n_verso, es_sentido)) fila = f"
{n_verso}" if he_raw: fila += f"{html.escape(he_raw)}" if es_sentido: fila += f"{html.escape(es_sentido)}" fila += "
" filas.append(fila) # Texto que se locuta = exactamente la traducción con sentido de todo el capítulo. texto_es_final = " ".join(partes_es) return f"
{''.join(filas)}
", texto_es_final, " ".join(plano_he), pares_es def estudiar(mensaje, historial, contexto): if not GROQ_KEY: return "Para el estudio, añade el Secret GROQ_API_KEY en el Space." sistema = ( "Eres un compañero de estudio de la Torá que responde en español, cálido y honesto. " "Ofreces el sentido literal (peshat), contexto histórico y lingüístico, capas de la " "tradición (midrash, Rashi cuando venga al caso) y reflexión espiritual. Vas al grano.\n\n" + contexto ) mensajes = [{"role": "system", "content": sistema}] for m in (historial or []): if isinstance(m, dict) and m.get("role") in ("user", "assistant"): mensajes.append({"role": m["role"], "content": m["content"]}) mensajes.append({"role": "user", "content": mensaje}) cuerpo = {"model": MODELO_ESTUDIA, "temperature": 0.4, "max_tokens": 1200, "messages": mensajes} try: data = json.dumps(cuerpo).encode("utf-8") req = urllib.request.Request(GROQ_URL, data=data, method="POST") req.add_header("Content-Type", "application/json") req.add_header("Authorization", f"Bearer {GROQ_KEY}") req.add_header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36") with urllib.request.urlopen(req, timeout=90) as r: d = json.load(r) return (d["choices"][0]["message"]["content"] or "").strip() except Exception as e: return f"No pude consultar a la IA ahora mismo ({e})." # ============================ INTERFAZ GRADIO ============================ with gr.Blocks(theme=fenix_theme(), css=FENIX_CSS, title="Centralita Torá") as demo: st_es = gr.State("") st_he = gr.State("") st_pares_es = gr.State([]) st_nombre = gr.State(NOMBRES[0]) st_cap = gr.State(1) timing_box = gr.Textbox(value="[]", visible=False, elem_id="timing_data") gr.HTML("
" "

Centralita Torá

hebreo real e impecable · español fluido y natural

") # Resaltado dorado, clarito y transparente, de la frase que se está locutando ahora mismo. gr.HTML(""" """) with gr.Tab("Leer"): with gr.Row(): dd = gr.Dropdown(NOMBRES, value=NOMBRES[0], label="Libro") ncap = gr.Number(value=1, precision=0, label="Capítulo", minimum=1) ver_btn = gr.Button("Ver capítulo", variant="primary") salida = gr.HTML() with gr.Row(): b_es = gr.Button("🔊 Narrador + Voz Divina (Español)", variant="secondary") b_he = gr.Button("🔊 עברית (Hebreo con Trope Real)", variant="secondary") audio = gr.Audio(label="Audio Litúrgico", autoplay=True, elem_id="tts_audio") def ver(nombre, cap): md, es, he, pares_es = render(nombre, cap) return md, es, he, pares_es, nombre, cap ver_btn.click(ver, [dd, ncap], [salida, st_es, st_he, st_pares_es, st_nombre, st_cap]) b_es.click(leer_es, st_pares_es, [audio, timing_box]) b_he.click(leer_he, inputs=[st_nombre, st_cap], outputs=[audio]) with gr.Tab("Buscar"): q = gr.Textbox(label="Palabra o frase (hebreo o español)", placeholder="ej. luz / אור") q_btn = gr.Button("Buscar", variant="primary") q_out = gr.HTML() def buscar(texto): t = (texto or "").strip().lower() if not t: return "

Escribe algo para buscar.

" filas = [] for lid in IDS: d = LIBROS[lid] for c, vs in d.get("capitulos", {}).items(): for v in vs: campos = " ".join(filter(None, [v.get("es"), v.get("he")])).lower() if t in campos: ref = f"{d.get('es', lid)} {c}:{v.get('n')}" txt = html.escape(_limpiar_entidades(v.get("es") or v.get("he") or "")) filas.append(f"
{ref} — {txt}
") if len(filas) >= 120: return "".join(filas) return "".join(filas) if filas else "

Sin resultados en los libros cargados.

" q_btn.click(buscar, q, q_out) with gr.Tab("Estudiar"): gr.Markdown("La IA usa el capítulo abierto en **Leer** como contexto.") chat = gr.Chatbot(type="messages", height=360) pin = gr.Textbox(placeholder="Pregunta sobre el pasaje…", label="") with gr.Row(): enviar = gr.Button("Preguntar", variant="primary") comentar = gr.Button("Comentar este capítulo", variant="secondary") def responder(mensaje, historial, es, he, nombre, cap): mensaje = (mensaje.strip() if mensaje else "") if not mensaje: return historial, "" contexto = f"Pasaje en pantalla — {nombre} {cap}:\nHebreo: {he}\nEspañol: {es}" r = estudiar(mensaje, historial, contexto) historial = (historial or []) + [ {"role": "user", "content": mensaje}, {"role": "assistant", "content": r}, ] return historial, "" enviar.click(responder, [pin, chat, st_es, st_he, st_nombre, st_cap], [chat, pin]) comentar.click( lambda h, es, he, n, c: responder("Comenta y ayúdame a estudiar este capítulo.", h, es, he, n, c), [chat, st_es, st_he, st_nombre, st_cap], [chat, pin], ) with gr.Tab("Cómo subir"): gr.Markdown( "Sube un JSON por libro a la carpeta `libros/`. Para el estudio y traducción fluida, añade el Secret `GROQ_API_KEY`." ) demo.load(None, None, None, js=""" () => { if (window.__fenixHighlightInterval) return; window.__fenixHighlightInterval = setInterval(() => { const audioEl = document.querySelector('#tts_audio audio'); const timingBox = document.querySelector('#timing_data textarea'); if (!audioEl || !timingBox) return; let timeline; try { timeline = JSON.parse(timingBox.value || '[]'); } catch (e) { return; } if (!Array.isArray(timeline) || timeline.length === 0) { document.querySelectorAll('.verso.activo').forEach(el => el.classList.remove('activo')); return; } const t = audioEl.currentTime; let activo = null; for (const seg of timeline) { if (t >= seg.start && t < seg.end) { activo = seg.verso; break; } } document.querySelectorAll('.verso.activo').forEach(el => { if (String(el.dataset.n) !== String(activo)) el.classList.remove('activo'); }); if (activo !== null) { const el = document.querySelector(`.verso[data-n='${activo}']`); if (el && !el.classList.contains('activo')) el.classList.add('activo'); } }, 120); } """) if __name__ == "__main__": demo.launch(ssr_mode=False)