mejora de la semantica
This commit is contained in:
parent
d508dc2058
commit
cb8f69fb93
10 changed files with 191 additions and 227 deletions
28
.env
28
.env
|
|
@ -1,58 +1,34 @@
|
|||
# =========================
|
||||
# Base de datos
|
||||
# =========================
|
||||
DB_NAME=rss
|
||||
DB_USER=rss
|
||||
DB_PASS=lalalilo
|
||||
# DB_HOST y DB_PORT los inyecta docker-compose (DB_HOST=db).
|
||||
# Si ejecutas la app fuera de Docker, puedes descomentar:
|
||||
# DB_HOST=localhost
|
||||
# DB_PORT=5432
|
||||
DB_HOST=localhost
|
||||
DB_PORT=5432
|
||||
|
||||
# =========================
|
||||
# Flask / Web
|
||||
# =========================
|
||||
# ¡Pon aquí una clave larga y aleatoria!
|
||||
SECRET_KEY=CAMBIA_ESTA_CLAVE_POR_ALGO_LARGO_Y_ALEATORIO
|
||||
|
||||
# Idioma por defecto de la web y traducción activada por defecto
|
||||
DEFAULT_LANG=es
|
||||
DEFAULT_TRANSLATION_LANG=es
|
||||
WEB_TRANSLATED_DEFAULT=1
|
||||
|
||||
# Paginación por defecto (app.py limita entre 10 y 100)
|
||||
NEWS_PER_PAGE=20
|
||||
|
||||
# =========================
|
||||
# Ingesta / Scheduler
|
||||
# =========================
|
||||
RSS_MAX_WORKERS=20
|
||||
RSS_FEED_TIMEOUT=30
|
||||
RSS_MAX_FAILURES=5
|
||||
|
||||
# =========================
|
||||
# Worker de traducción (NLLB 1.3B)
|
||||
# =========================
|
||||
TARGET_LANGS=es
|
||||
TRANSLATOR_BATCH=4
|
||||
ENQUEUE=200
|
||||
TRANSLATOR_SLEEP_IDLE=5
|
||||
|
||||
# Límites de tokens (equilibrio calidad/VRAM para 12 GB)
|
||||
MAX_SRC_TOKENS=512
|
||||
MAX_NEW_TOKENS=256
|
||||
|
||||
# Beams (más calidad en títulos)
|
||||
NUM_BEAMS_TITLE=3
|
||||
NUM_BEAMS_BODY=2
|
||||
|
||||
# Modelo y dispositivo
|
||||
UNIVERSAL_MODEL=facebook/nllb-200-1.3B
|
||||
DEVICE=cuda
|
||||
|
||||
# =========================
|
||||
# Runtime (estabilidad/VRAM)
|
||||
# =========================
|
||||
PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True,max_split_size_mb:64,garbage_collection_threshold:0.9
|
||||
TOKENIZERS_PARALLELISM=false
|
||||
PYTHONUNBUFFERED=1
|
||||
|
|
|
|||
23
Dockerfile
23
Dockerfile
|
|
@ -1,59 +1,38 @@
|
|||
# Dockerfile
|
||||
# -----------
|
||||
|
||||
# Imagen base Python
|
||||
FROM python:3.11-slim
|
||||
|
||||
# Construcción para CUDA 12.1 por defecto (usa --build-arg TORCH_CUDA=cpu para CPU)
|
||||
ARG TORCH_CUDA=cu121
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Paquetes del sistema necesarios
|
||||
# - libpq-dev y gcc: para compilar dependencias que hablen con PostgreSQL (psycopg2)
|
||||
# - git: algunos modelos/liberías pueden tirar de git
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libpq-dev \
|
||||
gcc \
|
||||
git \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Ajustes de pip / runtime
|
||||
ENV PYTHONUNBUFFERED=1 \
|
||||
PIP_DISABLE_PIP_VERSION_CHECK=1 \
|
||||
TOKENIZERS_PARALLELISM=false \
|
||||
HF_HUB_DISABLE_SYMLINKS_WARNING=1
|
||||
|
||||
# Dependencias Python
|
||||
COPY requirements.txt ./
|
||||
RUN python -m pip install --no-cache-dir --upgrade pip setuptools wheel
|
||||
|
||||
# Instala PyTorch:
|
||||
# - Con CUDA 12.1 si TORCH_CUDA=cu121 (requiere runtime nvidia al ejecutar)
|
||||
# - Con ruedas CPU si TORCH_CUDA=cpu
|
||||
RUN if [ "$TORCH_CUDA" = "cu121" ]; then \
|
||||
pip install --no-cache-dir --index-url https://download.pytorch.org/whl/cu121 \
|
||||
torch==2.4.1 torchvision==0.19.1 torchaudio==2.4.1 ; \
|
||||
else \
|
||||
pip install --no-cache-dir \
|
||||
pip install --no-cache-dir --index-url https://download.pytorch.org/whl/cpu \
|
||||
torch==2.4.1 torchvision==0.19.1 torchaudio==2.4.1 ; \
|
||||
fi
|
||||
|
||||
# Instala el resto de dependencias de tu app
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Descarga el modelo de spaCy (español) para NER
|
||||
# Si el entorno de build no tiene red, no rompas la build: intenta en runtime.
|
||||
RUN python -m spacy download es_core_news_md || true
|
||||
|
||||
# Copia el código de la app
|
||||
COPY . .
|
||||
|
||||
# Descarga de recursos NLTK que usa newspaper3k (no crítico en build)
|
||||
RUN python download_models.py || true
|
||||
|
||||
# Puerto que usa gunicorn en el servicio web
|
||||
EXPOSE 8000
|
||||
|
||||
# El CMD/entrypoint se define en docker-compose.yml (web, scheduler, workers)
|
||||
|
||||
|
|
|
|||
21
app.py
21
app.py
|
|
@ -224,7 +224,6 @@ def _process_feed(feed_row):
|
|||
except psycopg2.Error as e:
|
||||
app.logger.warning(f"[ingesta] Error insertando noticia de {feed_url}: {e}")
|
||||
|
||||
# Si ha ido bien, reseteamos fallos
|
||||
with get_conn() as conn, conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"UPDATE feeds SET fallos = 0 WHERE id = %s;",
|
||||
|
|
@ -236,7 +235,6 @@ def _process_feed(feed_row):
|
|||
except Exception as e:
|
||||
app.logger.exception(f"[ingesta] Error procesando feed {feed_id} ({feed_url}): {e}")
|
||||
try:
|
||||
# Incrementamos fallos y marcamos inactivo si supera RSS_MAX_FAILURES
|
||||
with get_conn() as conn, conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
|
|
@ -818,11 +816,6 @@ def restore_feeds():
|
|||
return redirect(url_for("restore_feeds"))
|
||||
|
||||
def parse_int_field(row, key):
|
||||
"""
|
||||
Intenta convertir row[key] a int.
|
||||
- Si está vacío -> None
|
||||
- Si no es convertible (p.ej. 'categoria_id') -> None y log de aviso
|
||||
"""
|
||||
val = row.get(key)
|
||||
if val is None or str(val).strip() == "":
|
||||
return None
|
||||
|
|
@ -842,6 +835,18 @@ def restore_feeds():
|
|||
categoria_id = parse_int_field(row, "categoria_id")
|
||||
pais_id = parse_int_field(row, "pais_id")
|
||||
|
||||
raw_fallos = (row.get("fallos") or "").strip()
|
||||
if raw_fallos == "":
|
||||
fallos = 0
|
||||
else:
|
||||
try:
|
||||
fallos = int(raw_fallos)
|
||||
except (ValueError, TypeError):
|
||||
app.logger.warning(
|
||||
f"[restore_feeds] Valor no numérico '{raw_fallos}' en columna fallos, se usará 0."
|
||||
)
|
||||
fallos = 0
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO feeds (nombre, descripcion, url, categoria_id, pais_id, idioma, activo, fallos)
|
||||
|
|
@ -863,7 +868,7 @@ def restore_feeds():
|
|||
pais_id,
|
||||
(row.get("idioma") or "").strip().lower()[:2] or None,
|
||||
row.get("activo") in ("1", "True", "true", "t", "on"),
|
||||
int(row.get("fallos") or 0),
|
||||
fallos,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
|
|
|
|||
|
|
@ -80,32 +80,24 @@ services:
|
|||
- DB_NAME=${DB_NAME}
|
||||
- DB_USER=${DB_USER}
|
||||
- DB_PASS=${DB_PASS}
|
||||
|
||||
- TARGET_LANGS=es
|
||||
- TRANSLATOR_BATCH=8
|
||||
- TRANSLATOR_BATCH=32
|
||||
- ENQUEUE=200
|
||||
- TRANSLATOR_SLEEP_IDLE=5
|
||||
|
||||
- MAX_SRC_TOKENS=680
|
||||
- MAX_NEW_TOKENS=400
|
||||
|
||||
- NUM_BEAMS_TITLE=2
|
||||
- NUM_BEAMS_BODY=1
|
||||
|
||||
- UNIVERSAL_MODEL=facebook/nllb-200-1.3B
|
||||
|
||||
- CHUNK_BY_SENTENCES=True
|
||||
- CHUNK_MAX_TOKENS=700
|
||||
- CHUNK_MAX_TOKENS=400
|
||||
- CHUNK_OVERLAP_SENTS=1
|
||||
- CLEAN_ARTICLE=1
|
||||
|
||||
- DEVICE=cuda
|
||||
|
||||
- PYTHONUNBUFFERED=1
|
||||
- HF_HOME=/root/.cache/huggingface
|
||||
- TOKENIZERS_PARALLELISM=false
|
||||
- PYTORCH_CUDA_ALLOC_CONF=max_split_size_mb:64,garbage_collection_threshold:0.9
|
||||
|
||||
- NVIDIA_VISIBLE_DEVICES=all
|
||||
- NVIDIA_DRIVER_CAPABILITIES=compute,utility
|
||||
volumes:
|
||||
|
|
@ -149,11 +141,12 @@ services:
|
|||
- DB_NAME=${DB_NAME}
|
||||
- DB_USER=${DB_USER}
|
||||
- DB_PASS=${DB_PASS}
|
||||
|
||||
- EMB_MODEL=sentence-transformers/all-MiniLM-L6-v2
|
||||
- EMB_BATCH=64
|
||||
- EMB_SLEEP=5
|
||||
|
||||
- EMB_BATCH=256
|
||||
- EMB_SLEEP_IDLE=5
|
||||
- EMB_LANGS=es
|
||||
- EMB_LIMIT=5000
|
||||
- DEVICE=cuda
|
||||
- PYTHONUNBUFFERED=1
|
||||
- HF_HOME=/root/.cache/huggingface
|
||||
- TOKENIZERS_PARALLELISM=false
|
||||
|
|
@ -163,7 +156,7 @@ services:
|
|||
db:
|
||||
condition: service_healthy
|
||||
restart: always
|
||||
# gpus: all
|
||||
gpus: all
|
||||
|
||||
related:
|
||||
build:
|
||||
|
|
@ -178,7 +171,6 @@ services:
|
|||
- DB_NAME=${DB_NAME}
|
||||
- DB_USER=${DB_USER}
|
||||
- DB_PASS=${DB_PASS}
|
||||
|
||||
- RELATED_TOPK=10
|
||||
- RELATED_BATCH_IDS=200
|
||||
- RELATED_BATCH_SIM=2000
|
||||
|
|
|
|||
|
|
@ -1,17 +1,18 @@
|
|||
# embeddings_worker.py
|
||||
# Worker de embeddings para TRADUCCIONES:
|
||||
# - Lee traducciones con status='done' y sin embedding para un modelo concreto
|
||||
# - Calcula embedding (Sentence-Transformers) sobre título_trad + resumen_trad
|
||||
# - Calcula embedding (Sentence-Transformers) sobre titulo_trad + resumen_trad
|
||||
# - Guarda en traduccion_embeddings (traduccion_id, model, dim, embedding)
|
||||
|
||||
import os
|
||||
import time
|
||||
import logging
|
||||
from typing import List, Tuple
|
||||
from typing import List
|
||||
import numpy as np
|
||||
import psycopg2
|
||||
import psycopg2.extras
|
||||
from sentence_transformers import SentenceTransformer
|
||||
import torch
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format='[%(asctime)s] %(levelname)s: %(message)s')
|
||||
log = logging.getLogger(__name__)
|
||||
|
|
@ -26,25 +27,33 @@ DB = dict(
|
|||
)
|
||||
|
||||
# ---------- Parámetros de worker ----------
|
||||
# Modelo por defecto: MiniLM pequeño y rápido
|
||||
EMB_MODEL = os.environ.get("EMB_MODEL", "sentence-transformers/all-MiniLM-L6-v2")
|
||||
EMB_BATCH = int(os.environ.get("EMB_BATCH", "128"))
|
||||
SLEEP_IDLE = float(os.environ.get("EMB_SLEEP_IDLE", "5.0"))
|
||||
# Modelo por defecto: multilingüe, bueno para muchas lenguas
|
||||
EMB_MODEL = os.environ.get(
|
||||
"EMB_MODEL",
|
||||
"sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2",
|
||||
)
|
||||
EMB_BATCH = int(os.environ.get("EMB_BATCH", "128"))
|
||||
SLEEP_IDLE = float(os.environ.get("EMB_SLEEP_IDLE", "5.0"))
|
||||
# Filtrado por idiomas destino (coma-separado). Por defecto sólo 'es'
|
||||
EMB_LANGS = [s.strip() for s in os.environ.get("EMB_LANGS", "es").split(",") if s.strip()]
|
||||
DEVICE = os.environ.get("DEVICE", "auto").lower() # 'auto' | 'cpu' | 'cuda'
|
||||
EMB_LANGS = [s.strip() for s in os.environ.get("EMB_LANGS", "es").split(",") if s.strip()]
|
||||
|
||||
# DEVICE_ENV: 'auto' | 'cpu' | 'cuda'
|
||||
DEVICE_ENV = os.environ.get("DEVICE", "auto").lower()
|
||||
|
||||
# Límite por iteración (para no tragar toda la tabla de golpe)
|
||||
EMB_LIMIT = int(os.environ.get("EMB_LIMIT", "1000"))
|
||||
EMB_LIMIT = int(os.environ.get("EMB_LIMIT", "1000"))
|
||||
|
||||
|
||||
# ---------- Utilidades ----------
|
||||
def get_conn():
|
||||
return psycopg2.connect(**DB)
|
||||
|
||||
|
||||
def ensure_schema(conn):
|
||||
"""Crea la tabla de embeddings para traducciones si no existe."""
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("""
|
||||
cur.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS traduccion_embeddings (
|
||||
id SERIAL PRIMARY KEY,
|
||||
traduccion_id INT NOT NULL REFERENCES traducciones(id) ON DELETE CASCADE,
|
||||
|
|
@ -54,19 +63,21 @@ def ensure_schema(conn):
|
|||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
UNIQUE (traduccion_id, model)
|
||||
);
|
||||
""")
|
||||
"""
|
||||
)
|
||||
cur.execute("CREATE INDEX IF NOT EXISTS idx_tr_emb_model ON traduccion_embeddings(model);")
|
||||
cur.execute("CREATE INDEX IF NOT EXISTS idx_tr_emb_trid ON traduccion_embeddings(traduccion_id);")
|
||||
conn.commit()
|
||||
|
||||
|
||||
def fetch_batch_pending(conn) -> List[psycopg2.extras.DictRow]:
|
||||
"""
|
||||
Devuelve un lote de traducciones 'done' del/los idioma(s) objetivo
|
||||
que no tienen embedding aún para el EMB_MODEL indicado.
|
||||
"""
|
||||
with conn.cursor(cursor_factory=psycopg2.extras.DictCursor) as cur:
|
||||
# Usamos ANY(%s) para filtrar por múltiples idiomas destino
|
||||
cur.execute(f"""
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT t.id AS traduccion_id,
|
||||
t.lang_to AS lang_to,
|
||||
COALESCE(NULLIF(t.titulo_trad, ''), '') AS titulo_trad,
|
||||
|
|
@ -81,10 +92,13 @@ def fetch_batch_pending(conn) -> List[psycopg2.extras.DictRow]:
|
|||
AND e.traduccion_id IS NULL
|
||||
ORDER BY t.id
|
||||
LIMIT %s
|
||||
""", (EMB_MODEL, EMB_LANGS, EMB_LIMIT))
|
||||
""",
|
||||
(EMB_MODEL, EMB_LANGS, EMB_LIMIT),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
return rows
|
||||
|
||||
|
||||
def texts_from_rows(rows: List[psycopg2.extras.DictRow]) -> List[str]:
|
||||
"""
|
||||
Compone el texto a vectorizar por cada traducción:
|
||||
|
|
@ -93,13 +107,14 @@ def texts_from_rows(rows: List[psycopg2.extras.DictRow]) -> List[str]:
|
|||
texts = []
|
||||
for r in rows:
|
||||
title = (r["titulo_trad"] or "").strip()
|
||||
body = (r["resumen_trad"] or "").strip()
|
||||
body = (r["resumen_trad"] or "").strip()
|
||||
if title and body:
|
||||
texts.append(f"{title}\n{body}")
|
||||
else:
|
||||
texts.append(title or body or "")
|
||||
return texts
|
||||
|
||||
|
||||
def upsert_embeddings(conn, rows, embs: np.ndarray, model_name: str):
|
||||
"""
|
||||
Inserta/actualiza embeddings por traducción.
|
||||
|
|
@ -109,25 +124,39 @@ def upsert_embeddings(conn, rows, embs: np.ndarray, model_name: str):
|
|||
dim = int(embs.shape[1])
|
||||
with conn.cursor() as cur:
|
||||
for r, e in zip(rows, embs):
|
||||
cur.execute("""
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO traduccion_embeddings (traduccion_id, model, dim, embedding)
|
||||
VALUES (%s, %s, %s, %s)
|
||||
ON CONFLICT (traduccion_id, model) DO UPDATE
|
||||
SET embedding = EXCLUDED.embedding,
|
||||
dim = EXCLUDED.dim,
|
||||
created_at = NOW()
|
||||
""", (int(r["traduccion_id"]), model_name, dim, list(map(float, e))))
|
||||
""",
|
||||
(int(r["traduccion_id"]), model_name, dim, list(map(float, e))),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
# ---------- Main loop ----------
|
||||
def main():
|
||||
log.info("Arrancando embeddings_worker para TRADUCCIONES")
|
||||
log.info("Modelo: %s | Batch: %s | Idiomas: %s | Device: %s",
|
||||
EMB_MODEL, EMB_BATCH, ",".join(EMB_LANGS), DEVICE)
|
||||
log.info(
|
||||
"Modelo: %s | Batch: %s | Idiomas: %s | DEVICE env: %s",
|
||||
EMB_MODEL,
|
||||
EMB_BATCH,
|
||||
",".join(EMB_LANGS),
|
||||
DEVICE_ENV,
|
||||
)
|
||||
|
||||
# Carga modelo
|
||||
# DEVICE='auto' -> deja que S-B decida (usa CUDA si está disponible)
|
||||
model = SentenceTransformer(EMB_MODEL, device=None if DEVICE == "auto" else DEVICE)
|
||||
if DEVICE_ENV == "auto":
|
||||
device = "cuda" if torch.cuda.is_available() else "cpu"
|
||||
else:
|
||||
device = DEVICE_ENV
|
||||
|
||||
log.info("Usando dispositivo: %s", device)
|
||||
|
||||
model = SentenceTransformer(EMB_MODEL, device=device)
|
||||
|
||||
while True:
|
||||
try:
|
||||
|
|
@ -140,13 +169,12 @@ def main():
|
|||
continue
|
||||
|
||||
texts = texts_from_rows(rows)
|
||||
# Normalizamos embeddings (unit-length) para facilitar similitudes posteriores
|
||||
embs = model.encode(
|
||||
texts,
|
||||
batch_size=EMB_BATCH,
|
||||
convert_to_numpy=True,
|
||||
show_progress_bar=False,
|
||||
normalize_embeddings=True
|
||||
normalize_embeddings=True,
|
||||
)
|
||||
|
||||
upsert_embeddings(conn, rows, embs, EMB_MODEL)
|
||||
|
|
@ -156,6 +184,7 @@ def main():
|
|||
log.exception("Error en embeddings_worker: %s", e)
|
||||
time.sleep(SLEEP_IDLE)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,3 @@
|
|||
-- init-db/08-embeddings.sql
|
||||
-- ============================================================
|
||||
-- Esquema para embeddings y relaciones semánticas entre noticias
|
||||
-- Compatible con embeddings_worker.py (usa traduccion_embeddings)
|
||||
-- y mantiene una vista "embeddings" para compatibilidad previa.
|
||||
-- ============================================================
|
||||
|
||||
-- Tabla principal de embeddings por traducción (con modelo)
|
||||
CREATE TABLE IF NOT EXISTS traduccion_embeddings (
|
||||
id SERIAL PRIMARY KEY,
|
||||
traduccion_id INT NOT NULL REFERENCES traducciones(id) ON DELETE CASCADE,
|
||||
|
|
@ -16,18 +8,11 @@ CREATE TABLE IF NOT EXISTS traduccion_embeddings (
|
|||
UNIQUE (traduccion_id, model)
|
||||
);
|
||||
|
||||
-- Índices recomendados
|
||||
CREATE INDEX IF NOT EXISTS idx_tr_emb_traduccion_id ON traduccion_embeddings(traduccion_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_tr_emb_model ON traduccion_embeddings(model);
|
||||
|
||||
-- -----------------------------------------------------------------
|
||||
-- Vista de compatibilidad "embeddings"
|
||||
-- (emula tu antigua tabla con columnas: traduccion_id, dim, vec)
|
||||
-- Ajusta el valor del WHERE model = '...' si usas otro modelo.
|
||||
-- -----------------------------------------------------------------
|
||||
DO $$
|
||||
BEGIN
|
||||
-- Si ya existe una tabla llamada embeddings, la renombramos a embeddings_legacy para evitar conflicto
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.tables
|
||||
WHERE table_schema = 'public' AND table_name = 'embeddings'
|
||||
|
|
@ -35,11 +20,9 @@ BEGIN
|
|||
EXECUTE 'ALTER TABLE embeddings RENAME TO embeddings_legacy';
|
||||
END IF;
|
||||
EXCEPTION WHEN others THEN
|
||||
-- No bloqueamos la migración por esto
|
||||
NULL;
|
||||
END$$;
|
||||
|
||||
-- Crea/actualiza la vista
|
||||
CREATE OR REPLACE VIEW embeddings AS
|
||||
SELECT
|
||||
te.traduccion_id,
|
||||
|
|
@ -48,20 +31,6 @@ SELECT
|
|||
FROM traduccion_embeddings te
|
||||
WHERE te.model = 'sentence-transformers/all-MiniLM-L6-v2';
|
||||
|
||||
-- Nota:
|
||||
-- Si quieres que la vista siempre coja el embedding más reciente de CUALQUIER modelo:
|
||||
-- REEMPLAZA el WHERE anterior por:
|
||||
-- WHERE te.id IN (
|
||||
-- SELECT DISTINCT ON (traduccion_id) id
|
||||
-- FROM traduccion_embeddings
|
||||
-- ORDER BY traduccion_id, created_at DESC
|
||||
-- );
|
||||
|
||||
-- -----------------------------------------------------------------
|
||||
-- Relaciones semánticas entre traducciones (opcional)
|
||||
-- Esta tabla no la usa el worker directamente, pero permite cachear
|
||||
-- "noticias relacionadas" precalculadas por otro proceso/batch.
|
||||
-- -----------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS related_noticias (
|
||||
traduccion_id INT NOT NULL REFERENCES traducciones(id) ON DELETE CASCADE,
|
||||
related_traduccion_id INT NOT NULL REFERENCES traducciones(id) ON DELETE CASCADE,
|
||||
|
|
@ -71,11 +40,6 @@ CREATE TABLE IF NOT EXISTS related_noticias (
|
|||
CHECK (traduccion_id <> related_traduccion_id)
|
||||
);
|
||||
|
||||
-- Índices para acelerar consultas en ambos sentidos
|
||||
CREATE INDEX IF NOT EXISTS idx_related_by_tr ON related_noticias (traduccion_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_related_by_relatedtr ON related_noticias (related_traduccion_id);
|
||||
|
||||
-- Sugerencias:
|
||||
-- - Si pretendes recalcular periódicamente, podrías limpiar por ventana temporal:
|
||||
-- DELETE FROM related_noticias WHERE created_at < NOW() - INTERVAL '7 days';
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
# related_worker.py
|
||||
import os
|
||||
import time
|
||||
import math
|
||||
|
|
@ -21,33 +20,30 @@ DB = dict(
|
|||
password=os.environ.get("DB_PASS", "x"),
|
||||
)
|
||||
|
||||
# Config
|
||||
TOPK = int(os.environ.get("RELATED_TOPK", 10)) # vecinos por traducción
|
||||
BATCH_IDS = int(os.environ.get("RELATED_BATCH_IDS", 200)) # cuántas traducciones objetivo por pasada
|
||||
BATCH_SIM = int(os.environ.get("RELATED_BATCH_SIM", 2000)) # tamaño de bloque al comparar contra el resto
|
||||
SLEEP_IDLE = float(os.environ.get("RELATED_SLEEP", 10)) # pausa cuando no hay trabajo
|
||||
MIN_SCORE = float(os.environ.get("RELATED_MIN_SCORE", 0.0)) # descarta relaciones por debajo de este coseno
|
||||
WINDOW_HOURS = int(os.environ.get("RELATED_WINDOW_H", 0)) # 0 = sin filtro temporal; >0 = últimas X horas
|
||||
TOPK = int(os.environ.get("RELATED_TOPK", 10))
|
||||
BATCH_IDS = int(os.environ.get("RELATED_BATCH_IDS", 200))
|
||||
BATCH_SIM = int(os.environ.get("RELATED_BATCH_SIM", 2000))
|
||||
SLEEP_IDLE = float(os.environ.get("RELATED_SLEEP", 10))
|
||||
MIN_SCORE = float(os.environ.get("RELATED_MIN_SCORE", 0.0))
|
||||
WINDOW_HOURS = int(os.environ.get("RELATED_WINDOW_H", 0))
|
||||
|
||||
|
||||
def get_conn():
|
||||
return psycopg2.connect(**DB)
|
||||
|
||||
|
||||
def _fetch_all_embeddings(cur):
|
||||
"""
|
||||
Devuelve:
|
||||
ids: List[int] con traduccion_id
|
||||
vecs: List[List[float]] con el embedding (puede venir como list de DOUBLE PRECISION[])
|
||||
norms: List[float] con la norma L2 de cada vector (precalculada para acelerar el coseno)
|
||||
Si WINDOW_HOURS > 0, limitamos a noticias recientes.
|
||||
"""
|
||||
if WINDOW_HOURS > 0:
|
||||
cur.execute("""
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT e.traduccion_id, e.vec
|
||||
FROM embeddings e
|
||||
JOIN traducciones t ON t.id = e.traduccion_id
|
||||
JOIN noticias n ON n.id = t.noticia_id
|
||||
WHERE n.fecha >= NOW() - INTERVAL %s
|
||||
""", (f"{WINDOW_HOURS} hours",))
|
||||
""",
|
||||
(f"{WINDOW_HOURS} hours",),
|
||||
)
|
||||
else:
|
||||
cur.execute("SELECT traduccion_id, vec FROM embeddings")
|
||||
|
||||
|
|
@ -59,23 +55,18 @@ def _fetch_all_embeddings(cur):
|
|||
vecs = []
|
||||
norms = []
|
||||
for tr_id, v in rows:
|
||||
# v llega como lista de floats (DOUBLE PRECISION[]); protegemos None
|
||||
if v is None:
|
||||
v = []
|
||||
# calcular norma
|
||||
nrm = math.sqrt(sum(((x or 0.0) * (x or 0.0)) for x in v)) or 1e-8
|
||||
ids.append(tr_id)
|
||||
vecs.append(v)
|
||||
norms.append(nrm)
|
||||
return ids, vecs, norms
|
||||
|
||||
|
||||
def _fetch_pending_ids(cur, limit) -> List[int]:
|
||||
"""
|
||||
Traducciones con embedding pero sin relaciones generadas aún.
|
||||
Si quieres regenerar periódicamente, puedes cambiar la condición
|
||||
para tener en cuenta antigüedad o un flag de 'stale'.
|
||||
"""
|
||||
cur.execute("""
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT e.traduccion_id
|
||||
FROM embeddings e
|
||||
LEFT JOIN related_noticias r ON r.traduccion_id = e.traduccion_id
|
||||
|
|
@ -83,13 +74,14 @@ def _fetch_pending_ids(cur, limit) -> List[int]:
|
|||
HAVING COUNT(r.related_traduccion_id) = 0
|
||||
ORDER BY e.traduccion_id DESC
|
||||
LIMIT %s;
|
||||
""", (limit,))
|
||||
""",
|
||||
(limit,),
|
||||
)
|
||||
return [r[0] for r in cur.fetchall()]
|
||||
|
||||
|
||||
def _cosine_with_norms(a, b, na, nb):
|
||||
# producto punto
|
||||
num = 0.0
|
||||
# zip se corta por el más corto; si longitudes difieren, usamos la intersección
|
||||
for x, y in zip(a, b):
|
||||
xv = x or 0.0
|
||||
yv = y or 0.0
|
||||
|
|
@ -99,15 +91,15 @@ def _cosine_with_norms(a, b, na, nb):
|
|||
return 0.0
|
||||
return num / denom
|
||||
|
||||
def _topk_for_one(idx: int,
|
||||
ids_all: List[int],
|
||||
vecs_all: List[List[float]],
|
||||
norms_all: List[float],
|
||||
pool_indices: List[int],
|
||||
K: int) -> List[Tuple[int, float]]:
|
||||
"""
|
||||
Devuelve los K mejores (related_id, score) para ids_all[idx] restringido al conjunto pool_indices.
|
||||
"""
|
||||
|
||||
def _topk_for_one(
|
||||
idx: int,
|
||||
ids_all: List[int],
|
||||
vecs_all: List[List[float]],
|
||||
norms_all: List[float],
|
||||
pool_indices: List[int],
|
||||
K: int,
|
||||
) -> List[Tuple[int, float]]:
|
||||
me_vec = vecs_all[idx]
|
||||
me_norm = norms_all[idx]
|
||||
|
||||
|
|
@ -118,12 +110,12 @@ def _topk_for_one(idx: int,
|
|||
s = _cosine_with_norms(me_vec, vecs_all[j], me_norm, norms_all[j])
|
||||
out.append((ids_all[j], s))
|
||||
|
||||
# top-K ordenado por score desc
|
||||
out.sort(key=lambda t: t[1], reverse=True)
|
||||
if MIN_SCORE > 0.0:
|
||||
out = [p for p in out if p[1] >= MIN_SCORE]
|
||||
return out[:K]
|
||||
|
||||
|
||||
def _insert_related(cur, tr_id: int, pairs: List[Tuple[int, float]]):
|
||||
if not pairs:
|
||||
return
|
||||
|
|
@ -135,22 +127,16 @@ def _insert_related(cur, tr_id: int, pairs: List[Tuple[int, float]]):
|
|||
ON CONFLICT (traduccion_id, related_traduccion_id)
|
||||
DO UPDATE SET score = EXCLUDED.score
|
||||
""",
|
||||
[(tr_id, rid, float(score)) for (rid, score) in pairs]
|
||||
[(tr_id, rid, float(score)) for (rid, score) in pairs],
|
||||
)
|
||||
|
||||
|
||||
def build_for_ids(conn, target_ids: List[int]) -> int:
|
||||
"""
|
||||
Para las traducciones de target_ids:
|
||||
- carga TODOS los embeddings (opcionalmente filtrados por ventana temporal),
|
||||
- para cada target calcula sus TOPK vecinos por coseno, por bloques,
|
||||
- upsert en related_noticias.
|
||||
"""
|
||||
with conn.cursor() as cur:
|
||||
ids_all, vecs_all, norms_all = _fetch_all_embeddings(cur)
|
||||
if not ids_all:
|
||||
return 0
|
||||
|
||||
# mapa traduccion_id -> índice en arrays
|
||||
pos = {tid: i for i, tid in enumerate(ids_all)}
|
||||
n = len(ids_all)
|
||||
processed = 0
|
||||
|
|
@ -161,13 +147,10 @@ def build_for_ids(conn, target_ids: List[int]) -> int:
|
|||
continue
|
||||
i = pos[tr_id]
|
||||
|
||||
# barrido por bloques para no disparar memoria
|
||||
top: List[Tuple[int, float]] = []
|
||||
for start in range(0, n, BATCH_SIM):
|
||||
block = list(range(start, min(start + BATCH_SIM, n)))
|
||||
candidates = _topk_for_one(i, ids_all, vecs_all, norms_all, block, TOPK)
|
||||
|
||||
# merge de top-K global
|
||||
top += candidates
|
||||
top.sort(key=lambda t: t[1], reverse=True)
|
||||
if len(top) > TOPK:
|
||||
|
|
@ -179,10 +162,15 @@ def build_for_ids(conn, target_ids: List[int]) -> int:
|
|||
conn.commit()
|
||||
return processed
|
||||
|
||||
|
||||
def main():
|
||||
logging.info(
|
||||
"Iniciando related_worker (TOPK=%s, BATCH_IDS=%s, BATCH_SIM=%s, MIN_SCORE=%.3f, WINDOW_H=%s)",
|
||||
TOPK, BATCH_IDS, BATCH_SIM, MIN_SCORE, WINDOW_HOURS
|
||||
TOPK,
|
||||
BATCH_IDS,
|
||||
BATCH_SIM,
|
||||
MIN_SCORE,
|
||||
WINDOW_HOURS,
|
||||
)
|
||||
while True:
|
||||
try:
|
||||
|
|
@ -201,6 +189,7 @@ def main():
|
|||
logging.exception("Error en related_worker")
|
||||
time.sleep(SLEEP_IDLE)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,14 @@
|
|||
<ul class="noticias-list">
|
||||
{% for n in noticias %}
|
||||
{% if n.traduccion_id %}
|
||||
{% set detalle_url = url_for('noticia', tr_id=n.traduccion_id) %}
|
||||
{% else %}
|
||||
{% set detalle_url = url_for('noticia', id=n.id) %}
|
||||
{% endif %}
|
||||
<li class="noticia-item" data-item>
|
||||
{% if n.imagen_url %}
|
||||
<div class="noticia-imagen">
|
||||
<a href="{{ n.url }}" target="_blank" rel="noopener noreferrer">
|
||||
<a href="{{ detalle_url }}">
|
||||
<img src="{{ n.imagen_url }}" alt="Imagen para {{ n.titulo }}" loading="lazy">
|
||||
</a>
|
||||
</div>
|
||||
|
|
@ -23,13 +28,13 @@
|
|||
<div class="tabs-body">
|
||||
<div class="tab-panel {% if use_tr and n.tiene_traduccion %}active{% endif %}" data-panel="trad">
|
||||
<h3 class="m0">
|
||||
<a href="{{ n.url }}" target="_blank" rel="noopener noreferrer">
|
||||
<a href="{{ detalle_url }}">
|
||||
{{ n.titulo_traducido or n.titulo }}
|
||||
</a>
|
||||
{% if n.tiene_traduccion %}
|
||||
<span class="badge" title="Mostrando traducción">ES</span>
|
||||
<span class="badge" title="Mostrando traducción">{{ (lang or 'es')|upper }}</span>
|
||||
{% if n.traduccion_id %}
|
||||
<a class="mini-link" href="{{ url_for('noticia', tr_id=n.traduccion_id) }}">ver detalle</a>
|
||||
<a class="mini-link" href="{{ detalle_url }}">ver detalle</a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</h3>
|
||||
|
|
@ -54,8 +59,7 @@
|
|||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# === Chips de tags para la TRADUCCIÓN (si existen) === #}
|
||||
{% set chips = (tags_por_trad.get(n.traduccion_id) if (n.traduccion_id and tags_por_trad) else None) %}
|
||||
{% set chips = (tags_por_tr.get(n.traduccion_id) if (n.traduccion_id and tags_por_tr) else None) %}
|
||||
{% if chips %}
|
||||
<div class="noticia-tags" style="margin-top:8px;" aria-label="Etiquetas">
|
||||
{% for valor, tipo in chips %}
|
||||
|
|
@ -63,11 +67,19 @@
|
|||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if n.url %}
|
||||
<div style="margin-top:8px;">
|
||||
<a href="{{ n.url }}" class="mini-link" target="_blank" rel="noopener noreferrer">
|
||||
Ver fuente original
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="tab-panel {% if not (use_tr and n.tiene_traduccion) %}active{% endif %}" data-panel="orig">
|
||||
<h3 class="m0">
|
||||
<a href="{{ n.url }}" target="_blank" rel="noopener noreferrer">
|
||||
<a href="{{ detalle_url }}">
|
||||
{{ n.titulo_original or n.titulo }}
|
||||
</a>
|
||||
<span class="badge badge-secondary">ORIG</span>
|
||||
|
|
@ -92,6 +104,14 @@
|
|||
<button class="ver-mas-btn" type="button">Ver más</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if n.url %}
|
||||
<div style="margin-top:8px;">
|
||||
<a href="{{ n.url }}" class="mini-link" target="_blank" rel="noopener noreferrer">
|
||||
Ver fuente original
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -144,7 +164,6 @@
|
|||
root.addEventListener('click', function(e){
|
||||
const t = e.target;
|
||||
|
||||
// Ver más / Ver menos
|
||||
const verMasBtn = t.closest('.ver-mas-btn');
|
||||
if (verMasBtn) {
|
||||
const wrap = verMasBtn.closest('.resumen-container');
|
||||
|
|
@ -158,7 +177,6 @@
|
|||
return;
|
||||
}
|
||||
|
||||
// Pestañas
|
||||
const tabBtn = t.closest('.tab-btn');
|
||||
if (tabBtn && !tabBtn.disabled) {
|
||||
const li = tabBtn.closest('[data-item]');
|
||||
|
|
@ -172,7 +190,6 @@
|
|||
});
|
||||
}
|
||||
|
||||
// Paginación (AJAX)
|
||||
const pageLink = t.closest('.page-link[data-page]');
|
||||
if (pageLink) {
|
||||
e.preventDefault();
|
||||
|
|
|
|||
|
|
@ -1,15 +1,14 @@
|
|||
{% extends "base.html" %}
|
||||
{% block title %}
|
||||
{% set d = dato if dato is defined else (r if r is defined else None) %}
|
||||
{% if d %}
|
||||
{{ d.titulo_trad or d.titulo_orig or d.titulo_traducido or d.titulo_original or 'Detalle de Noticia' }}
|
||||
{% if dato %}
|
||||
{{ dato.titulo_trad or dato.titulo_orig or 'Detalle de Noticia' }}
|
||||
{% else %}
|
||||
Detalle de Noticia
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% set d = dato if dato is defined else (r if r is defined else None) %}
|
||||
{% set d = dato %}
|
||||
|
||||
{% if not d %}
|
||||
<div class="card">
|
||||
|
|
@ -21,12 +20,14 @@
|
|||
<div class="card">
|
||||
<div class="feed-header">
|
||||
<h2 style="margin:0;">
|
||||
{{ d.titulo_trad or d.titulo_orig or d.titulo_traducido or d.titulo_original }}
|
||||
{% if d.lang_to %}<span class="badge" title="Traducción">{{ d.lang_to|upper }}</span>{% endif %}
|
||||
{{ d.titulo_trad or d.titulo_orig }}
|
||||
{% if d.lang_to %}
|
||||
<span class="badge" title="Traducción">{{ d.lang_to|upper }}</span>
|
||||
{% endif %}
|
||||
</h2>
|
||||
{% if d.fuente_url or d.url %}
|
||||
{% if d.url %}
|
||||
<div>
|
||||
<a class="btn btn-small" href="{{ d.fuente_url or d.url }}" target="_blank" rel="noopener">Ver fuente</a>
|
||||
<a class="btn btn-small" href="{{ d.url }}" target="_blank" rel="noopener">Ver fuente</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
|
@ -36,46 +37,52 @@
|
|||
{% set fecha_ = d.fecha %}
|
||||
{% if fecha_ %}
|
||||
<i class="far fa-calendar-alt"></i>
|
||||
{% if fecha_ is string %}{{ fecha_ }}{% else %}{{ fecha_.strftime('%d-%m-%Y %H:%M') }}{% endif %}
|
||||
{% if fecha_ is string %}
|
||||
{{ fecha_ }}
|
||||
{% else %}
|
||||
{{ fecha_.strftime('%d-%m-%Y %H:%M') }}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if d.fuente_nombre %} | <i class="fas fa-newspaper"></i> {{ d.fuente_nombre }}{% endif %}
|
||||
{% if d.categoria %} | <i class="fas fa-tag"></i> {{ d.categoria }}{% endif %}
|
||||
{% if d.pais %} | <i class="fas fa-globe-americas"></i> {{ d.pais }}{% endif %}
|
||||
</div>
|
||||
|
||||
{% if d.resumen_trad or d.cuerpo_traducido %}
|
||||
{% if d.imagen_url %}
|
||||
<div style="margin-bottom:16px; text-align:center;">
|
||||
<img src="{{ d.imagen_url }}" alt="Imagen de la noticia" style="max-width:100%; height:auto;" loading="lazy">
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if d.resumen_trad %}
|
||||
<h3>Resumen (traducido)</h3>
|
||||
<div>{{ (d.resumen_trad or d.cuerpo_traducido)|safe_html }}</div>
|
||||
<div>{{ d.resumen_trad|safe_html }}</div>
|
||||
<hr>
|
||||
{% endif %}
|
||||
|
||||
{% if d.resumen_orig or d.cuerpo_original or d.resumen or d.titulo_original %}
|
||||
{% if d.resumen_orig %}
|
||||
<h3>Resumen (original)</h3>
|
||||
<div>{{ (d.resumen_orig or d.cuerpo_original or d.resumen)|safe_html }}</div>
|
||||
<div>{{ d.resumen_orig|safe_html }}</div>
|
||||
{% endif %}
|
||||
|
||||
{% if tags is defined and tags and tags|length %}
|
||||
{% if tags and tags|length %}
|
||||
<div style="margin-top:16px;">
|
||||
{% for t in tags %}
|
||||
{# t puede ser DictRow (t['valor']) o tupla (t.0) #}
|
||||
{% set valor = t.valor if t.valor is defined else (t[0] if t[0] is defined else '') %}
|
||||
{% set tipo = t.tipo if t.tipo is defined else (t[1] if t[1] is defined else '') %}
|
||||
<span class="badge" title="{{ (tipo or '')|capitalize }}">{{ valor }}</span>
|
||||
<span class="badge" title="{{ (t.tipo or '')|capitalize }}">{{ t.valor }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% set rels = relacionadas if relacionadas is defined else None %}
|
||||
{% if rels and rels|length %}
|
||||
{% if relacionadas and relacionadas|length %}
|
||||
<div class="card" style="margin-top:18px;">
|
||||
<div class="card-header">
|
||||
<h3 style="margin:0;">Noticias relacionadas</h3>
|
||||
</div>
|
||||
<div class="feed-body">
|
||||
<ul class="noticias-list">
|
||||
{% for r in rels %}
|
||||
{% for r in relacionadas %}
|
||||
<li class="noticia-item">
|
||||
{% if r.imagen_url %}
|
||||
<div class="noticia-imagen">
|
||||
|
|
@ -91,14 +98,17 @@
|
|||
<div class="noticia-meta">
|
||||
{% if r.fecha %}
|
||||
<i class="far fa-calendar-alt"></i>
|
||||
{% if r.fecha is string %}{{ r.fecha }}{% else %}{{ r.fecha.strftime('%d-%m-%Y %H:%M') }}{% endif %}
|
||||
{% if r.fecha is string %}
|
||||
{{ r.fecha }}
|
||||
{% else %}
|
||||
{{ r.fecha.strftime('%d-%m-%Y %H:%M') }}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if r.fuente_nombre %} | <i class="fas fa-newspaper"></i> {{ r.fuente_nombre }}{% endif %}
|
||||
{% if r.score is defined %} | <span title="Similitud coseno">score: {{ "%.3f"|format(r.score) }}</span>{% endif %}
|
||||
{% if r.score is defined %}
|
||||
| <span title="Similitud coseno">score: {{ "%.3f"|format(r.score) }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if r.resumen %}
|
||||
<div class="clamp">{{ r.resumen }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,13 @@
|
|||
<div class="filter-main-row">
|
||||
<div class="filter-search-box">
|
||||
<label for="q">Buscar por palabra clave</label>
|
||||
<input type="search" name="q" id="q" placeholder="Ej: Trump, California, IA..." value="{{ q or '' }}">
|
||||
<input
|
||||
type="search"
|
||||
name="q"
|
||||
id="q"
|
||||
placeholder="Ej: Trump, California, guerra en el mar Rojo..."
|
||||
value="{{ q or '' }}"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="filter-actions">
|
||||
|
|
@ -51,7 +57,11 @@
|
|||
<select name="pais_id" id="pais_id">
|
||||
<option value="">— Todos —</option>
|
||||
{% for pais in paises %}
|
||||
<option value="{{ pais.id }}" data-continente-id="{{ pais.continente_id }}" {% if pais_id == pais.id %}selected{% endif %}>
|
||||
<option
|
||||
value="{{ pais.id }}"
|
||||
data-continente-id="{{ pais.continente_id }}"
|
||||
{% if pais_id == pais.id %}selected{% endif %}
|
||||
>
|
||||
{{ pais.nombre }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
|
|
@ -135,17 +145,14 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
const newUrl = `${form.action}?${params.toString()}`;
|
||||
|
||||
await cargarNoticiasFromURL(newUrl);
|
||||
// Actualizar historial
|
||||
window.history.pushState({ path: newUrl }, '', newUrl);
|
||||
}
|
||||
|
||||
// Submit manual
|
||||
form.addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
cargarNoticias(false);
|
||||
});
|
||||
|
||||
// Toggle traducción/original
|
||||
const toggleOrig = document.getElementById('toggle-orig');
|
||||
const toggleTr = document.getElementById('toggle-tr');
|
||||
|
||||
|
|
@ -165,7 +172,6 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
});
|
||||
}
|
||||
|
||||
// Cambios en selects/fecha -> recarga automática
|
||||
continenteSelect.addEventListener('change', function() {
|
||||
filtrarPaises();
|
||||
cargarNoticias(false);
|
||||
|
|
@ -180,7 +186,6 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
cargarNoticias(false);
|
||||
});
|
||||
|
||||
// Debounce búsqueda
|
||||
let qTimer = null;
|
||||
qInput.addEventListener('input', function() {
|
||||
if (qTimer) clearTimeout(qTimer);
|
||||
|
|
@ -189,10 +194,8 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
}, 450);
|
||||
});
|
||||
|
||||
// Cargar países al inicio
|
||||
filtrarPaises();
|
||||
|
||||
// Soporte de navegación del historial
|
||||
window.addEventListener('popstate', function(e) {
|
||||
const url = (e.state && e.state.path) ? e.state.path : window.location.href;
|
||||
cargarNoticiasFromURL(url);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue