fix: optimizar caché de traducción con hashes SHA256 y normalización de texto para estabilidad en producción
This commit is contained in:
@@ -34,7 +34,7 @@ def load_config(config_path: str = None) -> dict:
|
||||
"port": 3306,
|
||||
"user": "",
|
||||
"password": "",
|
||||
"name": "mi_red"
|
||||
"name": ""
|
||||
},
|
||||
"languages": {
|
||||
"enabled": [
|
||||
|
||||
@@ -11,19 +11,42 @@ def get_connection():
|
||||
db_type = get_db_type()
|
||||
|
||||
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:
|
||||
_connection = mysql.connector.connect(
|
||||
host=db_config.get("host", "localhost"),
|
||||
port=db_config.get("port", 3306),
|
||||
user=db_config.get("user", "root"),
|
||||
password=db_config.get("password", ""),
|
||||
database=db_config.get("name", "mi_red")
|
||||
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"Error connecting to MySQL: {e}")
|
||||
print(f"CRITICAL: Could not establish initial MySQL connection: {e}")
|
||||
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
|
||||
else:
|
||||
import os
|
||||
@@ -107,16 +130,34 @@ def init_db():
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
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''')
|
||||
|
||||
# Tabla para traducciones de la interfaz web
|
||||
cursor.execute('''CREATE TABLE IF NOT EXISTS ui_translations
|
||||
(id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
text_hash VARCHAR(64) NOT NULL,
|
||||
original_text TEXT NOT NULL,
|
||||
target_lang VARCHAR(10) NOT NULL,
|
||||
translated_text TEXT NOT NULL,
|
||||
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
|
||||
cursor.execute('''CREATE TABLE IF NOT EXISTS admins
|
||||
(id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
@@ -412,37 +453,64 @@ def get_cached_translation(message_id: int, target_lang: str) -> str:
|
||||
conn.close()
|
||||
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:
|
||||
db_type = get_db_type()
|
||||
if db_type == "mysql":
|
||||
conn = get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT translated_text FROM ui_translations WHERE original_text = %s AND target_lang = %s", (text, target_lang))
|
||||
row = cursor.fetchone()
|
||||
cursor.close()
|
||||
return row[0] if row else None
|
||||
else:
|
||||
conn = get_connection()
|
||||
c = conn.cursor()
|
||||
c.execute("SELECT translated_text FROM ui_translations WHERE original_text = ? AND target_lang = ?", (text, target_lang))
|
||||
row = c.fetchone()
|
||||
conn.close()
|
||||
return row[0] if row else None
|
||||
text_hash = _get_text_hash(text)
|
||||
try:
|
||||
if db_type == "mysql":
|
||||
conn = get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT translated_text FROM ui_translations WHERE text_hash = %s AND target_lang = %s", (text_hash, target_lang))
|
||||
row = cursor.fetchone()
|
||||
cursor.close()
|
||||
return row[0] if row else None
|
||||
else:
|
||||
conn = get_connection()
|
||||
c = conn.cursor()
|
||||
c.execute("SELECT translated_text FROM ui_translations WHERE text_hash = ? AND target_lang = ?", (text_hash, target_lang))
|
||||
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):
|
||||
db_type = get_db_type()
|
||||
if db_type == "mysql":
|
||||
conn = get_connection()
|
||||
cursor = conn.cursor()
|
||||
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.commit()
|
||||
cursor.close()
|
||||
else:
|
||||
conn = get_connection()
|
||||
c = conn.cursor()
|
||||
c.execute("INSERT OR REPLACE INTO ui_translations (original_text, target_lang, translated_text) VALUES (?, ?, ?)", (text, target_lang, translated_text))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
text_hash = _get_text_hash(text)
|
||||
try:
|
||||
if db_type == "mysql":
|
||||
conn = get_connection()
|
||||
cursor = conn.cursor()
|
||||
query = """INSERT INTO ui_translations (text_hash, original_text, target_lang, translated_text)
|
||||
VALUES (%s, %s, %s, %s)
|
||||
ON DUPLICATE KEY UPDATE translated_text = %s"""
|
||||
cursor.execute(query, (text_hash, text, target_lang, translated_text, translated_text))
|
||||
conn.commit()
|
||||
cursor.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
|
||||
def get_admins():
|
||||
|
||||
@@ -94,24 +94,29 @@ async def translate_text(text: str, target_lang: str) -> str:
|
||||
return "".join(translated_segments)
|
||||
|
||||
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":
|
||||
return text
|
||||
|
||||
try:
|
||||
import nest_asyncio
|
||||
nest_asyncio.apply()
|
||||
except ImportError:
|
||||
# Si no está instalado, no podemos usar el bridge en FastAPI
|
||||
return text
|
||||
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
except RuntimeError:
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
|
||||
return loop.run_until_complete(translate_text(text, target_lang))
|
||||
import threading
|
||||
|
||||
result = []
|
||||
def target():
|
||||
# Creamos un nuevo bucle de eventos exclusivo para este hilo
|
||||
new_loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(new_loop)
|
||||
try:
|
||||
res = new_loop.run_until_complete(translate_text(text, target_lang))
|
||||
result.append(res)
|
||||
finally:
|
||||
new_loop.close()
|
||||
|
||||
# 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:
|
||||
load_lang_mappings(bot_type)
|
||||
|
||||
@@ -11,10 +11,14 @@ from pydantic import BaseModel
|
||||
|
||||
from passlib.hash import pbkdf2_sha256 as hasher
|
||||
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 (
|
||||
get_ui_translation, save_ui_translation,
|
||||
get_admins, get_admin_by_username, add_admin, delete_admin
|
||||
init_db, get_ui_translation, save_ui_translation,
|
||||
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
|
||||
|
||||
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"))
|
||||
|
||||
# Caché de memoria para traducciones UI (evita 50 queries SQL por página)
|
||||
_ui_memory_cache = {}
|
||||
|
||||
# Filtro de traducción para Jinja2
|
||||
def translate_filter(text, lang="es"):
|
||||
if lang == "es" or not text:
|
||||
return text
|
||||
|
||||
# Buscamos en caché de la base de datos (acceso rápido)
|
||||
from botdiscord.database import get_ui_translation, save_ui_translation
|
||||
# NORMALIZACIÓN inmediata
|
||||
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)
|
||||
if cached:
|
||||
_ui_memory_cache[cache_key] = 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
|
||||
translated = translate_text_sync(text, lang)
|
||||
|
||||
if translated and translated != text:
|
||||
save_ui_translation(text, lang, translated)
|
||||
_ui_memory_cache[cache_key] = translated
|
||||
|
||||
return translated
|
||||
|
||||
|
||||
Reference in New Issue
Block a user