Spaces:
Paused
Paused
| # -*- 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. ' ' -> ' '), 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 "<div class='pasaje'><p>Aún no has subido libros a la carpeta <b>libros/</b>.</p></div>", "", "" | |
| 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"<div class='pasaje'><p>No hay texto para {html.escape(d.get('es',''))} {cap}.</p></div>", "", "" | |
| # 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"<div class='cab'><span class='h'>{html.escape(_limpiar_entidades(d.get('heb','')))}</span><br>" | |
| f"{html.escape(d.get('es',''))} {cap}</div>"] | |
| 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"<div class='verso' data-n='{n_verso}'><span class='num'>{n_verso}</span>" | |
| if he_raw: | |
| fila += f"<span class='he'>{html.escape(he_raw)}</span>" | |
| if es_sentido: | |
| fila += f"<span class='es'>{html.escape(es_sentido)}</span>" | |
| fila += "</div>" | |
| 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"<div class='pasaje'>{''.join(filas)}</div>", 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("<div id='cabecera'><div class='estrella'>✡</div>" | |
| "<h1>Centralita Torá</h1><p>hebreo real e impecable · español fluido y natural</p></div>") | |
| # Resaltado dorado, clarito y transparente, de la frase que se está locutando ahora mismo. | |
| gr.HTML(""" | |
| <style> | |
| .verso .es { | |
| transition: background-color .35s ease, box-shadow .35s ease, color .35s ease; | |
| border-radius: 6px; | |
| padding: 1px 5px; | |
| } | |
| .verso.activo .es { | |
| background-color: rgba(255, 213, 122, 0.25); | |
| box-shadow: 0 0 14px rgba(255, 213, 122, 0.35); | |
| color: #fff6dc; | |
| } | |
| </style> | |
| """) | |
| 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 "<p>Escribe algo para buscar.</p>" | |
| 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"<div class='resultado'><span class='ref'>{ref}</span> — {txt}</div>") | |
| if len(filas) >= 120: | |
| return "".join(filas) | |
| return "".join(filas) if filas else "<p>Sin resultados en los libros cargados.</p>" | |
| 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) | |