fix: optimizar caché de traducción con hashes SHA256 y normalización de texto para estabilidad en producción

This commit is contained in:
2026-03-06 22:11:29 -06:00
parent ad0e80b15c
commit 6599dfcc23
4 changed files with 148 additions and 58 deletions

View File

@@ -34,7 +34,7 @@ def load_config(config_path: str = None) -> dict:
"port": 3306, "port": 3306,
"user": "", "user": "",
"password": "", "password": "",
"name": "mi_red" "name": ""
}, },
"languages": { "languages": {
"enabled": [ "enabled": [

View File

@@ -11,19 +11,42 @@ def get_connection():
db_type = get_db_type() db_type = get_db_type()
if db_type == "mysql": if db_type == "mysql":
if _connection is None or not _connection.is_connected(): db_config = get_db_config()
db_config = get_db_config()
# Si no hay conexión o no está activa, intentamos conectar/reconectar
if _connection is None:
try: try:
_connection = mysql.connector.connect( _connection = mysql.connector.connect(
host=db_config.get("host", "localhost"), host=db_config.get("host"),
port=db_config.get("port", 3306), port=db_config.get("port"),
user=db_config.get("user", "root"), user=db_config.get("user"),
password=db_config.get("password", ""), password=db_config.get("password"),
database=db_config.get("name", "mi_red") database=db_config.get("name"),
consume_results=True,
connection_timeout=5
) )
except MySQLError as e: except MySQLError as e:
print(f"Error connecting to MySQL: {e}") print(f"CRITICAL: Could not establish initial MySQL connection: {e}")
raise raise
# Verificar salud de la conexión existente
try:
_connection.ping(reconnect=True, attempts=3, delay=1)
except MySQLError:
try:
_connection = mysql.connector.connect(
host=db_config.get("host"),
port=db_config.get("port"),
user=db_config.get("user"),
password=db_config.get("password"),
database=db_config.get("name"),
consume_results=True,
connection_timeout=5
)
except MySQLError as e:
print(f"CRITICAL: MySQL reconnection failed: {e}")
raise
return _connection return _connection
else: else:
import os import os
@@ -107,16 +130,34 @@ def init_db():
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (message_id) REFERENCES messages(message_id) ON DELETE CASCADE, FOREIGN KEY (message_id) REFERENCES messages(message_id) ON DELETE CASCADE,
UNIQUE KEY idx_msg_lang (message_id, target_lang)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4''') UNIQUE KEY idx_msg_lang (message_id, target_lang)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4''')
# Tabla para traducciones de la interfaz web # Tabla para traducciones de la interfaz web
cursor.execute('''CREATE TABLE IF NOT EXISTS ui_translations cursor.execute('''CREATE TABLE IF NOT EXISTS ui_translations
(id INT AUTO_INCREMENT PRIMARY KEY, (id INT AUTO_INCREMENT PRIMARY KEY,
text_hash VARCHAR(64) NOT NULL,
original_text TEXT NOT NULL, original_text TEXT NOT NULL,
target_lang VARCHAR(10) NOT NULL, target_lang VARCHAR(10) NOT NULL,
translated_text TEXT NOT NULL, translated_text TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY idx_ui_lang (original_text(255), target_lang)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4''') UNIQUE KEY idx_ui_hash_lang (text_hash, target_lang)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4''')
# Migración automática: Añadir text_hash si no existe
try:
cursor.execute("SHOW COLUMNS FROM ui_translations LIKE 'text_hash'")
if not cursor.fetchone():
print("Migrating ui_translations: Adding text_hash column...")
# Primero borramos el índice viejo si existe (basado en el código anterior)
try:
cursor.execute("ALTER TABLE ui_translations DROP INDEX idx_ui_lang")
except: pass
# Añadimos la columna y el nuevo índice
cursor.execute("ALTER TABLE ui_translations ADD COLUMN text_hash VARCHAR(64) NOT NULL AFTER id")
cursor.execute("ALTER TABLE ui_translations ADD UNIQUE KEY idx_ui_hash_lang (text_hash, target_lang)")
# Limpiamos la tabla para evitar conflictos de hash con datos viejos
cursor.execute("DELETE FROM ui_translations")
except Exception as e:
print(f"Migration warning: {e}")
conn.commit()
# Tabla para administradores del panel web # Tabla para administradores del panel web
cursor.execute('''CREATE TABLE IF NOT EXISTS admins cursor.execute('''CREATE TABLE IF NOT EXISTS admins
(id INT AUTO_INCREMENT PRIMARY KEY, (id INT AUTO_INCREMENT PRIMARY KEY,
@@ -412,37 +453,64 @@ def get_cached_translation(message_id: int, target_lang: str) -> str:
conn.close() conn.close()
return row[0] if row else None return row[0] if row else None
import hashlib
def _normalize_text(text: str) -> str:
"""Normaliza el texto para que el caché sea consistente."""
if not text: return ""
# Quitar espacios en extremos, normalizar saltos de línea y quitar espacios dobles
text = text.strip().replace('\r\n', '\n')
return " ".join(text.split())
def _get_text_hash(text: str) -> str:
"""Genera un hash único para el texto normalizado."""
norm = _normalize_text(text)
return hashlib.sha256(norm.encode('utf-8')).hexdigest()
def get_ui_translation(text: str, target_lang: str) -> str: def get_ui_translation(text: str, target_lang: str) -> str:
db_type = get_db_type() db_type = get_db_type()
if db_type == "mysql": text_hash = _get_text_hash(text)
conn = get_connection() try:
cursor = conn.cursor() if db_type == "mysql":
cursor.execute("SELECT translated_text FROM ui_translations WHERE original_text = %s AND target_lang = %s", (text, target_lang)) conn = get_connection()
row = cursor.fetchone() cursor = conn.cursor()
cursor.close() cursor.execute("SELECT translated_text FROM ui_translations WHERE text_hash = %s AND target_lang = %s", (text_hash, target_lang))
return row[0] if row else None row = cursor.fetchone()
else: cursor.close()
conn = get_connection() return row[0] if row else None
c = conn.cursor() else:
c.execute("SELECT translated_text FROM ui_translations WHERE original_text = ? AND target_lang = ?", (text, target_lang)) conn = get_connection()
row = c.fetchone() c = conn.cursor()
conn.close() c.execute("SELECT translated_text FROM ui_translations WHERE text_hash = ? AND target_lang = ?", (text_hash, target_lang))
return row[0] if row else None row = c.fetchone()
conn.close()
return row[0] if row else None
except Exception as e:
print(f"Database error in get_ui_translation: {e}")
return None
def save_ui_translation(text: str, target_lang: str, translated_text: str): def save_ui_translation(text: str, target_lang: str, translated_text: str):
db_type = get_db_type() db_type = get_db_type()
if db_type == "mysql": text_hash = _get_text_hash(text)
conn = get_connection() try:
cursor = conn.cursor() if db_type == "mysql":
cursor.execute("INSERT INTO ui_translations (original_text, target_lang, translated_text) VALUES (%s, %s, %s) ON DUPLICATE KEY UPDATE translated_text = %s", (text, target_lang, translated_text, translated_text)) conn = get_connection()
conn.commit() cursor = conn.cursor()
cursor.close() query = """INSERT INTO ui_translations (text_hash, original_text, target_lang, translated_text)
else: VALUES (%s, %s, %s, %s)
conn = get_connection() ON DUPLICATE KEY UPDATE translated_text = %s"""
c = conn.cursor() cursor.execute(query, (text_hash, text, target_lang, translated_text, translated_text))
c.execute("INSERT OR REPLACE INTO ui_translations (original_text, target_lang, translated_text) VALUES (?, ?, ?)", (text, target_lang, translated_text)) conn.commit()
conn.commit() cursor.close()
conn.close() else:
conn = get_connection()
c = conn.cursor()
c.execute("INSERT OR REPLACE INTO ui_translations (text_hash, original_text, target_lang, translated_text) VALUES (?, ?, ?, ?)",
(text_hash, text, target_lang, translated_text))
conn.commit()
conn.close()
except Exception as e:
print(f"Database error in save_ui_translation: {e}")
# Funciones para administradores # Funciones para administradores
def get_admins(): def get_admins():

View File

@@ -94,24 +94,29 @@ async def translate_text(text: str, target_lang: str) -> str:
return "".join(translated_segments) return "".join(translated_segments)
def translate_text_sync(text: str, target_lang: str) -> str: def translate_text_sync(text: str, target_lang: str) -> str:
"""Versión síncrona de translate_text para usar en filtros de Jinja2.""" """Versión síncrona de translate_text utilizando un hilo separado."""
if not text or not target_lang or target_lang == "es": if not text or not target_lang or target_lang == "es":
return text return text
try: import threading
import nest_asyncio
nest_asyncio.apply() result = []
except ImportError: def target():
# Si no está instalado, no podemos usar el bridge en FastAPI # Creamos un nuevo bucle de eventos exclusivo para este hilo
return text new_loop = asyncio.new_event_loop()
asyncio.set_event_loop(new_loop)
try: try:
loop = asyncio.get_event_loop() res = new_loop.run_until_complete(translate_text(text, target_lang))
except RuntimeError: result.append(res)
loop = asyncio.new_event_loop() finally:
asyncio.set_event_loop(loop) new_loop.close()
return loop.run_until_complete(translate_text(text, target_lang)) # Ejecutamos la traducción en un hilo aparte para no interferir con el loop de FastAPI
thread = threading.Thread(target=target)
thread.start()
thread.join(timeout=15) # Esperamos máximo 15 segundos
return result[0] if result else text
def get_lang_mapping(bot_type: str = None) -> dict: def get_lang_mapping(bot_type: str = None) -> dict:
load_lang_mappings(bot_type) load_lang_mappings(bot_type)

View File

@@ -11,10 +11,14 @@ from pydantic import BaseModel
from passlib.hash import pbkdf2_sha256 as hasher from passlib.hash import pbkdf2_sha256 as hasher
from botdiscord.config import load_config, get_web_config, get_libretranslate_url, get_db_type from botdiscord.config import load_config, get_web_config, get_libretranslate_url, get_db_type
load_config() # Cargamos configuración inmediatamente
from botdiscord.database import ( from botdiscord.database import (
get_ui_translation, save_ui_translation, init_db, get_ui_translation, save_ui_translation,
get_admins, get_admin_by_username, add_admin, delete_admin get_admins, get_admin_by_username, add_admin, delete_admin,
_normalize_text
) )
init_db() # Aseguramos que las tablas existan antes de que FastAPI atienda peticiones
from botdiscord.translate import translate_text from botdiscord.translate import translate_text
app = FastAPI(title="Panel de Configuración - Bots de Traducción") app = FastAPI(title="Panel de Configuración - Bots de Traducción")
@@ -23,23 +27,36 @@ SCRIPT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
templates = Jinja2Templates(directory=os.path.join(SCRIPT_DIR, "panel", "templates")) templates = Jinja2Templates(directory=os.path.join(SCRIPT_DIR, "panel", "templates"))
# Caché de memoria para traducciones UI (evita 50 queries SQL por página)
_ui_memory_cache = {}
# Filtro de traducción para Jinja2 # Filtro de traducción para Jinja2
def translate_filter(text, lang="es"): def translate_filter(text, lang="es"):
if lang == "es" or not text: if lang == "es" or not text:
return text return text
# Buscamos en caché de la base de datos (acceso rápido) # NORMALIZACIÓN inmediata
from botdiscord.database import get_ui_translation, save_ui_translation text = _normalize_text(text)
if not text: return ""
# 1. Buscamos en caché de MEMORIA (RAM) - Ultra rápido
cache_key = f"{lang}:{text}"
if cache_key in _ui_memory_cache:
return _ui_memory_cache[cache_key]
# 2. Buscamos en caché de la base de datos (acceso rápido)
cached = get_ui_translation(text, lang) cached = get_ui_translation(text, lang)
if cached: if cached:
_ui_memory_cache[cache_key] = cached
return cached return cached
# Si no está en caché, traducimos de forma síncrona para la UI # 3. Si no está en ningún caché, traducimos de forma síncrona
from botdiscord.translate import translate_text_sync from botdiscord.translate import translate_text_sync
translated = translate_text_sync(text, lang) translated = translate_text_sync(text, lang)
if translated and translated != text: if translated and translated != text:
save_ui_translation(text, lang, translated) save_ui_translation(text, lang, translated)
_ui_memory_cache[cache_key] = translated
return translated return translated