mejora de la semantica

This commit is contained in:
jlimolina 2025-11-19 21:29:15 +01:00
parent d508dc2058
commit cb8f69fb93
10 changed files with 191 additions and 227 deletions

28
.env
View file

@ -1,58 +1,34 @@
# =========================
# Base de datos
# =========================
DB_NAME=rss DB_NAME=rss
DB_USER=rss DB_USER=rss
DB_PASS=lalalilo DB_PASS=lalalilo
# DB_HOST y DB_PORT los inyecta docker-compose (DB_HOST=db). DB_HOST=localhost
# Si ejecutas la app fuera de Docker, puedes descomentar: 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 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_LANG=es
DEFAULT_TRANSLATION_LANG=es DEFAULT_TRANSLATION_LANG=es
WEB_TRANSLATED_DEFAULT=1 WEB_TRANSLATED_DEFAULT=1
# Paginación por defecto (app.py limita entre 10 y 100)
NEWS_PER_PAGE=20 NEWS_PER_PAGE=20
# =========================
# Ingesta / Scheduler
# =========================
RSS_MAX_WORKERS=20 RSS_MAX_WORKERS=20
RSS_FEED_TIMEOUT=30 RSS_FEED_TIMEOUT=30
RSS_MAX_FAILURES=5 RSS_MAX_FAILURES=5
# =========================
# Worker de traducción (NLLB 1.3B)
# =========================
TARGET_LANGS=es TARGET_LANGS=es
TRANSLATOR_BATCH=4 TRANSLATOR_BATCH=4
ENQUEUE=200 ENQUEUE=200
TRANSLATOR_SLEEP_IDLE=5 TRANSLATOR_SLEEP_IDLE=5
# Límites de tokens (equilibrio calidad/VRAM para 12 GB)
MAX_SRC_TOKENS=512 MAX_SRC_TOKENS=512
MAX_NEW_TOKENS=256 MAX_NEW_TOKENS=256
# Beams (más calidad en títulos)
NUM_BEAMS_TITLE=3 NUM_BEAMS_TITLE=3
NUM_BEAMS_BODY=2 NUM_BEAMS_BODY=2
# Modelo y dispositivo
UNIVERSAL_MODEL=facebook/nllb-200-1.3B UNIVERSAL_MODEL=facebook/nllb-200-1.3B
DEVICE=cuda DEVICE=cuda
# =========================
# Runtime (estabilidad/VRAM)
# =========================
PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True,max_split_size_mb:64,garbage_collection_threshold:0.9 PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True,max_split_size_mb:64,garbage_collection_threshold:0.9
TOKENIZERS_PARALLELISM=false TOKENIZERS_PARALLELISM=false
PYTHONUNBUFFERED=1 PYTHONUNBUFFERED=1

View file

@ -1,59 +1,38 @@
# Dockerfile
# -----------
# Imagen base Python
FROM python:3.11-slim 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 ARG TORCH_CUDA=cu121
WORKDIR /app 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 \ RUN apt-get update && apt-get install -y --no-install-recommends \
libpq-dev \ libpq-dev \
gcc \ gcc \
git \ git \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Ajustes de pip / runtime
ENV PYTHONUNBUFFERED=1 \ ENV PYTHONUNBUFFERED=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1 \ PIP_DISABLE_PIP_VERSION_CHECK=1 \
TOKENIZERS_PARALLELISM=false \ TOKENIZERS_PARALLELISM=false \
HF_HUB_DISABLE_SYMLINKS_WARNING=1 HF_HUB_DISABLE_SYMLINKS_WARNING=1
# Dependencias Python
COPY requirements.txt ./ COPY requirements.txt ./
RUN python -m pip install --no-cache-dir --upgrade pip setuptools wheel 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 \ RUN if [ "$TORCH_CUDA" = "cu121" ]; then \
pip install --no-cache-dir --index-url https://download.pytorch.org/whl/cu121 \ 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 ; \ torch==2.4.1 torchvision==0.19.1 torchaudio==2.4.1 ; \
else \ 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 ; \ torch==2.4.1 torchvision==0.19.1 torchaudio==2.4.1 ; \
fi fi
# Instala el resto de dependencias de tu app
RUN pip install --no-cache-dir -r requirements.txt 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 RUN python -m spacy download es_core_news_md || true
# Copia el código de la app
COPY . . COPY . .
# Descarga de recursos NLTK que usa newspaper3k (no crítico en build)
RUN python download_models.py || true RUN python download_models.py || true
# Puerto que usa gunicorn en el servicio web
EXPOSE 8000 EXPOSE 8000
# El CMD/entrypoint se define en docker-compose.yml (web, scheduler, workers)

21
app.py
View file

@ -224,7 +224,6 @@ def _process_feed(feed_row):
except psycopg2.Error as e: except psycopg2.Error as e:
app.logger.warning(f"[ingesta] Error insertando noticia de {feed_url}: {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: with get_conn() as conn, conn.cursor() as cur:
cur.execute( cur.execute(
"UPDATE feeds SET fallos = 0 WHERE id = %s;", "UPDATE feeds SET fallos = 0 WHERE id = %s;",
@ -236,7 +235,6 @@ def _process_feed(feed_row):
except Exception as e: except Exception as e:
app.logger.exception(f"[ingesta] Error procesando feed {feed_id} ({feed_url}): {e}") app.logger.exception(f"[ingesta] Error procesando feed {feed_id} ({feed_url}): {e}")
try: try:
# Incrementamos fallos y marcamos inactivo si supera RSS_MAX_FAILURES
with get_conn() as conn, conn.cursor() as cur: with get_conn() as conn, conn.cursor() as cur:
cur.execute( cur.execute(
""" """
@ -818,11 +816,6 @@ def restore_feeds():
return redirect(url_for("restore_feeds")) return redirect(url_for("restore_feeds"))
def parse_int_field(row, key): 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) val = row.get(key)
if val is None or str(val).strip() == "": if val is None or str(val).strip() == "":
return None return None
@ -842,6 +835,18 @@ def restore_feeds():
categoria_id = parse_int_field(row, "categoria_id") categoria_id = parse_int_field(row, "categoria_id")
pais_id = parse_int_field(row, "pais_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( cur.execute(
""" """
INSERT INTO feeds (nombre, descripcion, url, categoria_id, pais_id, idioma, activo, fallos) INSERT INTO feeds (nombre, descripcion, url, categoria_id, pais_id, idioma, activo, fallos)
@ -863,7 +868,7 @@ def restore_feeds():
pais_id, pais_id,
(row.get("idioma") or "").strip().lower()[:2] or None, (row.get("idioma") or "").strip().lower()[:2] or None,
row.get("activo") in ("1", "True", "true", "t", "on"), row.get("activo") in ("1", "True", "true", "t", "on"),
int(row.get("fallos") or 0), fallos,
), ),
) )
conn.commit() conn.commit()

View file

@ -80,32 +80,24 @@ services:
- DB_NAME=${DB_NAME} - DB_NAME=${DB_NAME}
- DB_USER=${DB_USER} - DB_USER=${DB_USER}
- DB_PASS=${DB_PASS} - DB_PASS=${DB_PASS}
- TARGET_LANGS=es - TARGET_LANGS=es
- TRANSLATOR_BATCH=8 - TRANSLATOR_BATCH=32
- ENQUEUE=200 - ENQUEUE=200
- TRANSLATOR_SLEEP_IDLE=5 - TRANSLATOR_SLEEP_IDLE=5
- MAX_SRC_TOKENS=680 - MAX_SRC_TOKENS=680
- MAX_NEW_TOKENS=400 - MAX_NEW_TOKENS=400
- NUM_BEAMS_TITLE=2 - NUM_BEAMS_TITLE=2
- NUM_BEAMS_BODY=1 - NUM_BEAMS_BODY=1
- UNIVERSAL_MODEL=facebook/nllb-200-1.3B - UNIVERSAL_MODEL=facebook/nllb-200-1.3B
- CHUNK_BY_SENTENCES=True - CHUNK_BY_SENTENCES=True
- CHUNK_MAX_TOKENS=700 - CHUNK_MAX_TOKENS=400
- CHUNK_OVERLAP_SENTS=1 - CHUNK_OVERLAP_SENTS=1
- CLEAN_ARTICLE=1 - CLEAN_ARTICLE=1
- DEVICE=cuda - DEVICE=cuda
- PYTHONUNBUFFERED=1 - PYTHONUNBUFFERED=1
- HF_HOME=/root/.cache/huggingface - HF_HOME=/root/.cache/huggingface
- TOKENIZERS_PARALLELISM=false - TOKENIZERS_PARALLELISM=false
- PYTORCH_CUDA_ALLOC_CONF=max_split_size_mb:64,garbage_collection_threshold:0.9 - PYTORCH_CUDA_ALLOC_CONF=max_split_size_mb:64,garbage_collection_threshold:0.9
- NVIDIA_VISIBLE_DEVICES=all - NVIDIA_VISIBLE_DEVICES=all
- NVIDIA_DRIVER_CAPABILITIES=compute,utility - NVIDIA_DRIVER_CAPABILITIES=compute,utility
volumes: volumes:
@ -149,11 +141,12 @@ services:
- DB_NAME=${DB_NAME} - DB_NAME=${DB_NAME}
- DB_USER=${DB_USER} - DB_USER=${DB_USER}
- DB_PASS=${DB_PASS} - DB_PASS=${DB_PASS}
- EMB_MODEL=sentence-transformers/all-MiniLM-L6-v2 - EMB_MODEL=sentence-transformers/all-MiniLM-L6-v2
- EMB_BATCH=64 - EMB_BATCH=256
- EMB_SLEEP=5 - EMB_SLEEP_IDLE=5
- EMB_LANGS=es
- EMB_LIMIT=5000
- DEVICE=cuda
- PYTHONUNBUFFERED=1 - PYTHONUNBUFFERED=1
- HF_HOME=/root/.cache/huggingface - HF_HOME=/root/.cache/huggingface
- TOKENIZERS_PARALLELISM=false - TOKENIZERS_PARALLELISM=false
@ -163,7 +156,7 @@ services:
db: db:
condition: service_healthy condition: service_healthy
restart: always restart: always
# gpus: all gpus: all
related: related:
build: build:
@ -178,7 +171,6 @@ services:
- DB_NAME=${DB_NAME} - DB_NAME=${DB_NAME}
- DB_USER=${DB_USER} - DB_USER=${DB_USER}
- DB_PASS=${DB_PASS} - DB_PASS=${DB_PASS}
- RELATED_TOPK=10 - RELATED_TOPK=10
- RELATED_BATCH_IDS=200 - RELATED_BATCH_IDS=200
- RELATED_BATCH_SIM=2000 - RELATED_BATCH_SIM=2000

View file

@ -1,17 +1,18 @@
# embeddings_worker.py # embeddings_worker.py
# Worker de embeddings para TRADUCCIONES: # Worker de embeddings para TRADUCCIONES:
# - Lee traducciones con status='done' y sin embedding para un modelo concreto # - 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) # - Guarda en traduccion_embeddings (traduccion_id, model, dim, embedding)
import os import os
import time import time
import logging import logging
from typing import List, Tuple from typing import List
import numpy as np import numpy as np
import psycopg2 import psycopg2
import psycopg2.extras import psycopg2.extras
from sentence_transformers import SentenceTransformer from sentence_transformers import SentenceTransformer
import torch
logging.basicConfig(level=logging.INFO, format='[%(asctime)s] %(levelname)s: %(message)s') logging.basicConfig(level=logging.INFO, format='[%(asctime)s] %(levelname)s: %(message)s')
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@ -26,25 +27,33 @@ DB = dict(
) )
# ---------- Parámetros de worker ---------- # ---------- Parámetros de worker ----------
# Modelo por defecto: MiniLM pequeño y rápido # Modelo por defecto: multilingüe, bueno para muchas lenguas
EMB_MODEL = os.environ.get("EMB_MODEL", "sentence-transformers/all-MiniLM-L6-v2") EMB_MODEL = os.environ.get(
EMB_BATCH = int(os.environ.get("EMB_BATCH", "128")) "EMB_MODEL",
SLEEP_IDLE = float(os.environ.get("EMB_SLEEP_IDLE", "5.0")) "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' # 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()] 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'
# 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) # 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 ---------- # ---------- Utilidades ----------
def get_conn(): def get_conn():
return psycopg2.connect(**DB) return psycopg2.connect(**DB)
def ensure_schema(conn): def ensure_schema(conn):
"""Crea la tabla de embeddings para traducciones si no existe.""" """Crea la tabla de embeddings para traducciones si no existe."""
with conn.cursor() as cur: with conn.cursor() as cur:
cur.execute(""" cur.execute(
"""
CREATE TABLE IF NOT EXISTS traduccion_embeddings ( CREATE TABLE IF NOT EXISTS traduccion_embeddings (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
traduccion_id INT NOT NULL REFERENCES traducciones(id) ON DELETE CASCADE, traduccion_id INT NOT NULL REFERENCES traducciones(id) ON DELETE CASCADE,
@ -54,19 +63,21 @@ def ensure_schema(conn):
created_at TIMESTAMP DEFAULT NOW(), created_at TIMESTAMP DEFAULT NOW(),
UNIQUE (traduccion_id, model) 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_model ON traduccion_embeddings(model);")
cur.execute("CREATE INDEX IF NOT EXISTS idx_tr_emb_trid ON traduccion_embeddings(traduccion_id);") cur.execute("CREATE INDEX IF NOT EXISTS idx_tr_emb_trid ON traduccion_embeddings(traduccion_id);")
conn.commit() conn.commit()
def fetch_batch_pending(conn) -> List[psycopg2.extras.DictRow]: def fetch_batch_pending(conn) -> List[psycopg2.extras.DictRow]:
""" """
Devuelve un lote de traducciones 'done' del/los idioma(s) objetivo Devuelve un lote de traducciones 'done' del/los idioma(s) objetivo
que no tienen embedding aún para el EMB_MODEL indicado. que no tienen embedding aún para el EMB_MODEL indicado.
""" """
with conn.cursor(cursor_factory=psycopg2.extras.DictCursor) as cur: with conn.cursor(cursor_factory=psycopg2.extras.DictCursor) as cur:
# Usamos ANY(%s) para filtrar por múltiples idiomas destino cur.execute(
cur.execute(f""" """
SELECT t.id AS traduccion_id, SELECT t.id AS traduccion_id,
t.lang_to AS lang_to, t.lang_to AS lang_to,
COALESCE(NULLIF(t.titulo_trad, ''), '') AS titulo_trad, 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 AND e.traduccion_id IS NULL
ORDER BY t.id ORDER BY t.id
LIMIT %s LIMIT %s
""", (EMB_MODEL, EMB_LANGS, EMB_LIMIT)) """,
(EMB_MODEL, EMB_LANGS, EMB_LIMIT),
)
rows = cur.fetchall() rows = cur.fetchall()
return rows return rows
def texts_from_rows(rows: List[psycopg2.extras.DictRow]) -> List[str]: def texts_from_rows(rows: List[psycopg2.extras.DictRow]) -> List[str]:
""" """
Compone el texto a vectorizar por cada traducción: 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 = [] texts = []
for r in rows: for r in rows:
title = (r["titulo_trad"] or "").strip() title = (r["titulo_trad"] or "").strip()
body = (r["resumen_trad"] or "").strip() body = (r["resumen_trad"] or "").strip()
if title and body: if title and body:
texts.append(f"{title}\n{body}") texts.append(f"{title}\n{body}")
else: else:
texts.append(title or body or "") texts.append(title or body or "")
return texts return texts
def upsert_embeddings(conn, rows, embs: np.ndarray, model_name: str): def upsert_embeddings(conn, rows, embs: np.ndarray, model_name: str):
""" """
Inserta/actualiza embeddings por traducción. 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]) dim = int(embs.shape[1])
with conn.cursor() as cur: with conn.cursor() as cur:
for r, e in zip(rows, embs): for r, e in zip(rows, embs):
cur.execute(""" cur.execute(
"""
INSERT INTO traduccion_embeddings (traduccion_id, model, dim, embedding) INSERT INTO traduccion_embeddings (traduccion_id, model, dim, embedding)
VALUES (%s, %s, %s, %s) VALUES (%s, %s, %s, %s)
ON CONFLICT (traduccion_id, model) DO UPDATE ON CONFLICT (traduccion_id, model) DO UPDATE
SET embedding = EXCLUDED.embedding, SET embedding = EXCLUDED.embedding,
dim = EXCLUDED.dim, dim = EXCLUDED.dim,
created_at = NOW() 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() conn.commit()
# ---------- Main loop ---------- # ---------- Main loop ----------
def main(): def main():
log.info("Arrancando embeddings_worker para TRADUCCIONES") log.info("Arrancando embeddings_worker para TRADUCCIONES")
log.info("Modelo: %s | Batch: %s | Idiomas: %s | Device: %s", log.info(
EMB_MODEL, EMB_BATCH, ",".join(EMB_LANGS), DEVICE) "Modelo: %s | Batch: %s | Idiomas: %s | DEVICE env: %s",
EMB_MODEL,
EMB_BATCH,
",".join(EMB_LANGS),
DEVICE_ENV,
)
# Carga modelo if DEVICE_ENV == "auto":
# DEVICE='auto' -> deja que S-B decida (usa CUDA si está disponible) device = "cuda" if torch.cuda.is_available() else "cpu"
model = SentenceTransformer(EMB_MODEL, device=None if DEVICE == "auto" else DEVICE) else:
device = DEVICE_ENV
log.info("Usando dispositivo: %s", device)
model = SentenceTransformer(EMB_MODEL, device=device)
while True: while True:
try: try:
@ -140,13 +169,12 @@ def main():
continue continue
texts = texts_from_rows(rows) texts = texts_from_rows(rows)
# Normalizamos embeddings (unit-length) para facilitar similitudes posteriores
embs = model.encode( embs = model.encode(
texts, texts,
batch_size=EMB_BATCH, batch_size=EMB_BATCH,
convert_to_numpy=True, convert_to_numpy=True,
show_progress_bar=False, show_progress_bar=False,
normalize_embeddings=True normalize_embeddings=True,
) )
upsert_embeddings(conn, rows, embs, EMB_MODEL) upsert_embeddings(conn, rows, embs, EMB_MODEL)
@ -156,6 +184,7 @@ def main():
log.exception("Error en embeddings_worker: %s", e) log.exception("Error en embeddings_worker: %s", e)
time.sleep(SLEEP_IDLE) time.sleep(SLEEP_IDLE)
if __name__ == "__main__": if __name__ == "__main__":
main() main()

View file

@ -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 ( CREATE TABLE IF NOT EXISTS traduccion_embeddings (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
traduccion_id INT NOT NULL REFERENCES traducciones(id) ON DELETE CASCADE, 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) 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_traduccion_id ON traduccion_embeddings(traduccion_id);
CREATE INDEX IF NOT EXISTS idx_tr_emb_model ON traduccion_embeddings(model); 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 $$ DO $$
BEGIN BEGIN
-- Si ya existe una tabla llamada embeddings, la renombramos a embeddings_legacy para evitar conflicto
IF EXISTS ( IF EXISTS (
SELECT 1 FROM information_schema.tables SELECT 1 FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = 'embeddings' WHERE table_schema = 'public' AND table_name = 'embeddings'
@ -35,11 +20,9 @@ BEGIN
EXECUTE 'ALTER TABLE embeddings RENAME TO embeddings_legacy'; EXECUTE 'ALTER TABLE embeddings RENAME TO embeddings_legacy';
END IF; END IF;
EXCEPTION WHEN others THEN EXCEPTION WHEN others THEN
-- No bloqueamos la migración por esto
NULL; NULL;
END$$; END$$;
-- Crea/actualiza la vista
CREATE OR REPLACE VIEW embeddings AS CREATE OR REPLACE VIEW embeddings AS
SELECT SELECT
te.traduccion_id, te.traduccion_id,
@ -48,20 +31,6 @@ SELECT
FROM traduccion_embeddings te FROM traduccion_embeddings te
WHERE te.model = 'sentence-transformers/all-MiniLM-L6-v2'; 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 ( CREATE TABLE IF NOT EXISTS related_noticias (
traduccion_id INT NOT NULL REFERENCES traducciones(id) ON DELETE CASCADE, traduccion_id INT NOT NULL REFERENCES traducciones(id) ON DELETE CASCADE,
related_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) 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_tr ON related_noticias (traduccion_id);
CREATE INDEX IF NOT EXISTS idx_related_by_relatedtr ON related_noticias (related_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';

View file

@ -1,4 +1,3 @@
# related_worker.py
import os import os
import time import time
import math import math
@ -21,33 +20,30 @@ DB = dict(
password=os.environ.get("DB_PASS", "x"), password=os.environ.get("DB_PASS", "x"),
) )
# Config TOPK = int(os.environ.get("RELATED_TOPK", 10))
TOPK = int(os.environ.get("RELATED_TOPK", 10)) # vecinos por traducción BATCH_IDS = int(os.environ.get("RELATED_BATCH_IDS", 200))
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))
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))
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))
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))
WINDOW_HOURS = int(os.environ.get("RELATED_WINDOW_H", 0)) # 0 = sin filtro temporal; >0 = últimas X horas
def get_conn(): def get_conn():
return psycopg2.connect(**DB) return psycopg2.connect(**DB)
def _fetch_all_embeddings(cur): 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: if WINDOW_HOURS > 0:
cur.execute(""" cur.execute(
"""
SELECT e.traduccion_id, e.vec SELECT e.traduccion_id, e.vec
FROM embeddings e FROM embeddings e
JOIN traducciones t ON t.id = e.traduccion_id JOIN traducciones t ON t.id = e.traduccion_id
JOIN noticias n ON n.id = t.noticia_id JOIN noticias n ON n.id = t.noticia_id
WHERE n.fecha >= NOW() - INTERVAL %s WHERE n.fecha >= NOW() - INTERVAL %s
""", (f"{WINDOW_HOURS} hours",)) """,
(f"{WINDOW_HOURS} hours",),
)
else: else:
cur.execute("SELECT traduccion_id, vec FROM embeddings") cur.execute("SELECT traduccion_id, vec FROM embeddings")
@ -59,23 +55,18 @@ def _fetch_all_embeddings(cur):
vecs = [] vecs = []
norms = [] norms = []
for tr_id, v in rows: for tr_id, v in rows:
# v llega como lista de floats (DOUBLE PRECISION[]); protegemos None
if v is None: if v is None:
v = [] v = []
# calcular norma
nrm = math.sqrt(sum(((x or 0.0) * (x or 0.0)) for x in v)) or 1e-8 nrm = math.sqrt(sum(((x or 0.0) * (x or 0.0)) for x in v)) or 1e-8
ids.append(tr_id) ids.append(tr_id)
vecs.append(v) vecs.append(v)
norms.append(nrm) norms.append(nrm)
return ids, vecs, norms return ids, vecs, norms
def _fetch_pending_ids(cur, limit) -> List[int]: def _fetch_pending_ids(cur, limit) -> List[int]:
""" cur.execute(
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("""
SELECT e.traduccion_id SELECT e.traduccion_id
FROM embeddings e FROM embeddings e
LEFT JOIN related_noticias r ON r.traduccion_id = e.traduccion_id 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 HAVING COUNT(r.related_traduccion_id) = 0
ORDER BY e.traduccion_id DESC ORDER BY e.traduccion_id DESC
LIMIT %s; LIMIT %s;
""", (limit,)) """,
(limit,),
)
return [r[0] for r in cur.fetchall()] return [r[0] for r in cur.fetchall()]
def _cosine_with_norms(a, b, na, nb): def _cosine_with_norms(a, b, na, nb):
# producto punto
num = 0.0 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): for x, y in zip(a, b):
xv = x or 0.0 xv = x or 0.0
yv = y or 0.0 yv = y or 0.0
@ -99,15 +91,15 @@ def _cosine_with_norms(a, b, na, nb):
return 0.0 return 0.0
return num / denom return num / denom
def _topk_for_one(idx: int,
ids_all: List[int], def _topk_for_one(
vecs_all: List[List[float]], idx: int,
norms_all: List[float], ids_all: List[int],
pool_indices: List[int], vecs_all: List[List[float]],
K: int) -> List[Tuple[int, float]]: norms_all: List[float],
""" pool_indices: List[int],
Devuelve los K mejores (related_id, score) para ids_all[idx] restringido al conjunto pool_indices. K: int,
""" ) -> List[Tuple[int, float]]:
me_vec = vecs_all[idx] me_vec = vecs_all[idx]
me_norm = norms_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]) s = _cosine_with_norms(me_vec, vecs_all[j], me_norm, norms_all[j])
out.append((ids_all[j], s)) out.append((ids_all[j], s))
# top-K ordenado por score desc
out.sort(key=lambda t: t[1], reverse=True) out.sort(key=lambda t: t[1], reverse=True)
if MIN_SCORE > 0.0: if MIN_SCORE > 0.0:
out = [p for p in out if p[1] >= MIN_SCORE] out = [p for p in out if p[1] >= MIN_SCORE]
return out[:K] return out[:K]
def _insert_related(cur, tr_id: int, pairs: List[Tuple[int, float]]): def _insert_related(cur, tr_id: int, pairs: List[Tuple[int, float]]):
if not pairs: if not pairs:
return return
@ -135,22 +127,16 @@ def _insert_related(cur, tr_id: int, pairs: List[Tuple[int, float]]):
ON CONFLICT (traduccion_id, related_traduccion_id) ON CONFLICT (traduccion_id, related_traduccion_id)
DO UPDATE SET score = EXCLUDED.score 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: 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: with conn.cursor() as cur:
ids_all, vecs_all, norms_all = _fetch_all_embeddings(cur) ids_all, vecs_all, norms_all = _fetch_all_embeddings(cur)
if not ids_all: if not ids_all:
return 0 return 0
# mapa traduccion_id -> índice en arrays
pos = {tid: i for i, tid in enumerate(ids_all)} pos = {tid: i for i, tid in enumerate(ids_all)}
n = len(ids_all) n = len(ids_all)
processed = 0 processed = 0
@ -161,13 +147,10 @@ def build_for_ids(conn, target_ids: List[int]) -> int:
continue continue
i = pos[tr_id] i = pos[tr_id]
# barrido por bloques para no disparar memoria
top: List[Tuple[int, float]] = [] top: List[Tuple[int, float]] = []
for start in range(0, n, BATCH_SIM): for start in range(0, n, BATCH_SIM):
block = list(range(start, min(start + BATCH_SIM, n))) block = list(range(start, min(start + BATCH_SIM, n)))
candidates = _topk_for_one(i, ids_all, vecs_all, norms_all, block, TOPK) candidates = _topk_for_one(i, ids_all, vecs_all, norms_all, block, TOPK)
# merge de top-K global
top += candidates top += candidates
top.sort(key=lambda t: t[1], reverse=True) top.sort(key=lambda t: t[1], reverse=True)
if len(top) > TOPK: if len(top) > TOPK:
@ -179,10 +162,15 @@ def build_for_ids(conn, target_ids: List[int]) -> int:
conn.commit() conn.commit()
return processed return processed
def main(): def main():
logging.info( logging.info(
"Iniciando related_worker (TOPK=%s, BATCH_IDS=%s, BATCH_SIM=%s, MIN_SCORE=%.3f, WINDOW_H=%s)", "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: while True:
try: try:
@ -201,6 +189,7 @@ def main():
logging.exception("Error en related_worker") logging.exception("Error en related_worker")
time.sleep(SLEEP_IDLE) time.sleep(SLEEP_IDLE)
if __name__ == "__main__": if __name__ == "__main__":
main() main()

View file

@ -1,9 +1,14 @@
<ul class="noticias-list"> <ul class="noticias-list">
{% for n in noticias %} {% 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> <li class="noticia-item" data-item>
{% if n.imagen_url %} {% if n.imagen_url %}
<div class="noticia-imagen"> <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"> <img src="{{ n.imagen_url }}" alt="Imagen para {{ n.titulo }}" loading="lazy">
</a> </a>
</div> </div>
@ -23,13 +28,13 @@
<div class="tabs-body"> <div class="tabs-body">
<div class="tab-panel {% if use_tr and n.tiene_traduccion %}active{% endif %}" data-panel="trad"> <div class="tab-panel {% if use_tr and n.tiene_traduccion %}active{% endif %}" data-panel="trad">
<h3 class="m0"> <h3 class="m0">
<a href="{{ n.url }}" target="_blank" rel="noopener noreferrer"> <a href="{{ detalle_url }}">
{{ n.titulo_traducido or n.titulo }} {{ n.titulo_traducido or n.titulo }}
</a> </a>
{% if n.tiene_traduccion %} {% 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 %} {% 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 %}
{% endif %} {% endif %}
</h3> </h3>
@ -54,8 +59,7 @@
{% endif %} {% endif %}
</div> </div>
{# === Chips de tags para la TRADUCCIÓN (si existen) === #} {% set chips = (tags_por_tr.get(n.traduccion_id) if (n.traduccion_id and tags_por_tr) else None) %}
{% set chips = (tags_por_trad.get(n.traduccion_id) if (n.traduccion_id and tags_por_trad) else None) %}
{% if chips %} {% if chips %}
<div class="noticia-tags" style="margin-top:8px;" aria-label="Etiquetas"> <div class="noticia-tags" style="margin-top:8px;" aria-label="Etiquetas">
{% for valor, tipo in chips %} {% for valor, tipo in chips %}
@ -63,11 +67,19 @@
{% endfor %} {% endfor %}
</div> </div>
{% endif %} {% 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>
<div class="tab-panel {% if not (use_tr and n.tiene_traduccion) %}active{% endif %}" data-panel="orig"> <div class="tab-panel {% if not (use_tr and n.tiene_traduccion) %}active{% endif %}" data-panel="orig">
<h3 class="m0"> <h3 class="m0">
<a href="{{ n.url }}" target="_blank" rel="noopener noreferrer"> <a href="{{ detalle_url }}">
{{ n.titulo_original or n.titulo }} {{ n.titulo_original or n.titulo }}
</a> </a>
<span class="badge badge-secondary">ORIG</span> <span class="badge badge-secondary">ORIG</span>
@ -92,6 +104,14 @@
<button class="ver-mas-btn" type="button">Ver más</button> <button class="ver-mas-btn" type="button">Ver más</button>
{% endif %} {% endif %}
</div> </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> </div>
</div> </div>
@ -144,7 +164,6 @@
root.addEventListener('click', function(e){ root.addEventListener('click', function(e){
const t = e.target; const t = e.target;
// Ver más / Ver menos
const verMasBtn = t.closest('.ver-mas-btn'); const verMasBtn = t.closest('.ver-mas-btn');
if (verMasBtn) { if (verMasBtn) {
const wrap = verMasBtn.closest('.resumen-container'); const wrap = verMasBtn.closest('.resumen-container');
@ -158,7 +177,6 @@
return; return;
} }
// Pestañas
const tabBtn = t.closest('.tab-btn'); const tabBtn = t.closest('.tab-btn');
if (tabBtn && !tabBtn.disabled) { if (tabBtn && !tabBtn.disabled) {
const li = tabBtn.closest('[data-item]'); const li = tabBtn.closest('[data-item]');
@ -172,7 +190,6 @@
}); });
} }
// Paginación (AJAX)
const pageLink = t.closest('.page-link[data-page]'); const pageLink = t.closest('.page-link[data-page]');
if (pageLink) { if (pageLink) {
e.preventDefault(); e.preventDefault();

View file

@ -1,15 +1,14 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %} {% block title %}
{% set d = dato if dato is defined else (r if r is defined else None) %} {% if dato %}
{% if d %} {{ dato.titulo_trad or dato.titulo_orig or 'Detalle de Noticia' }}
{{ d.titulo_trad or d.titulo_orig or d.titulo_traducido or d.titulo_original or 'Detalle de Noticia' }}
{% else %} {% else %}
Detalle de Noticia Detalle de Noticia
{% endif %} {% endif %}
{% endblock %} {% endblock %}
{% block content %} {% block content %}
{% set d = dato if dato is defined else (r if r is defined else None) %} {% set d = dato %}
{% if not d %} {% if not d %}
<div class="card"> <div class="card">
@ -21,12 +20,14 @@
<div class="card"> <div class="card">
<div class="feed-header"> <div class="feed-header">
<h2 style="margin:0;"> <h2 style="margin:0;">
{{ d.titulo_trad or d.titulo_orig or d.titulo_traducido or d.titulo_original }} {{ d.titulo_trad or d.titulo_orig }}
{% if d.lang_to %}<span class="badge" title="Traducción">{{ d.lang_to|upper }}</span>{% endif %} {% if d.lang_to %}
<span class="badge" title="Traducción">{{ d.lang_to|upper }}</span>
{% endif %}
</h2> </h2>
{% if d.fuente_url or d.url %} {% if d.url %}
<div> <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> </div>
{% endif %} {% endif %}
</div> </div>
@ -36,46 +37,52 @@
{% set fecha_ = d.fecha %} {% set fecha_ = d.fecha %}
{% if fecha_ %} {% if fecha_ %}
<i class="far fa-calendar-alt"></i> <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 %} {% endif %}
{% if d.fuente_nombre %} | <i class="fas fa-newspaper"></i> {{ d.fuente_nombre }}{% 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.categoria %} | <i class="fas fa-tag"></i> {{ d.categoria }}{% endif %}
{% if d.pais %} | <i class="fas fa-globe-americas"></i> {{ d.pais }}{% endif %} {% if d.pais %} | <i class="fas fa-globe-americas"></i> {{ d.pais }}{% endif %}
</div> </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> <h3>Resumen (traducido)</h3>
<div>{{ (d.resumen_trad or d.cuerpo_traducido)|safe_html }}</div> <div>{{ d.resumen_trad|safe_html }}</div>
<hr> <hr>
{% endif %} {% endif %}
{% if d.resumen_orig or d.cuerpo_original or d.resumen or d.titulo_original %} {% if d.resumen_orig %}
<h3>Resumen (original)</h3> <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 %} {% endif %}
{% if tags is defined and tags and tags|length %} {% if tags and tags|length %}
<div style="margin-top:16px;"> <div style="margin-top:16px;">
{% for t in tags %} {% for t in tags %}
{# t puede ser DictRow (t['valor']) o tupla (t.0) #} <span class="badge" title="{{ (t.tipo or '')|capitalize }}">{{ t.valor }}</span>
{% 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>
{% endfor %} {% endfor %}
</div> </div>
{% endif %} {% endif %}
</div> </div>
</div> </div>
{% set rels = relacionadas if relacionadas is defined else None %} {% if relacionadas and relacionadas|length %}
{% if rels and rels|length %}
<div class="card" style="margin-top:18px;"> <div class="card" style="margin-top:18px;">
<div class="card-header"> <div class="card-header">
<h3 style="margin:0;">Noticias relacionadas</h3> <h3 style="margin:0;">Noticias relacionadas</h3>
</div> </div>
<div class="feed-body"> <div class="feed-body">
<ul class="noticias-list"> <ul class="noticias-list">
{% for r in rels %} {% for r in relacionadas %}
<li class="noticia-item"> <li class="noticia-item">
{% if r.imagen_url %} {% if r.imagen_url %}
<div class="noticia-imagen"> <div class="noticia-imagen">
@ -91,14 +98,17 @@
<div class="noticia-meta"> <div class="noticia-meta">
{% if r.fecha %} {% if r.fecha %}
<i class="far fa-calendar-alt"></i> <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 %} {% endif %}
{% if r.fuente_nombre %} | <i class="fas fa-newspaper"></i> {{ r.fuente_nombre }}{% 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> </div>
{% if r.resumen %}
<div class="clamp">{{ r.resumen }}</div>
{% endif %}
</div> </div>
</li> </li>
{% endfor %} {% endfor %}

View file

@ -18,7 +18,13 @@
<div class="filter-main-row"> <div class="filter-main-row">
<div class="filter-search-box"> <div class="filter-search-box">
<label for="q">Buscar por palabra clave</label> <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>
<div class="filter-actions"> <div class="filter-actions">
@ -51,7 +57,11 @@
<select name="pais_id" id="pais_id"> <select name="pais_id" id="pais_id">
<option value="">— Todos —</option> <option value="">— Todos —</option>
{% for pais in paises %} {% 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 }} {{ pais.nombre }}
</option> </option>
{% endfor %} {% endfor %}
@ -135,17 +145,14 @@ document.addEventListener('DOMContentLoaded', function() {
const newUrl = `${form.action}?${params.toString()}`; const newUrl = `${form.action}?${params.toString()}`;
await cargarNoticiasFromURL(newUrl); await cargarNoticiasFromURL(newUrl);
// Actualizar historial
window.history.pushState({ path: newUrl }, '', newUrl); window.history.pushState({ path: newUrl }, '', newUrl);
} }
// Submit manual
form.addEventListener('submit', function(e) { form.addEventListener('submit', function(e) {
e.preventDefault(); e.preventDefault();
cargarNoticias(false); cargarNoticias(false);
}); });
// Toggle traducción/original
const toggleOrig = document.getElementById('toggle-orig'); const toggleOrig = document.getElementById('toggle-orig');
const toggleTr = document.getElementById('toggle-tr'); 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() { continenteSelect.addEventListener('change', function() {
filtrarPaises(); filtrarPaises();
cargarNoticias(false); cargarNoticias(false);
@ -180,7 +186,6 @@ document.addEventListener('DOMContentLoaded', function() {
cargarNoticias(false); cargarNoticias(false);
}); });
// Debounce búsqueda
let qTimer = null; let qTimer = null;
qInput.addEventListener('input', function() { qInput.addEventListener('input', function() {
if (qTimer) clearTimeout(qTimer); if (qTimer) clearTimeout(qTimer);
@ -189,10 +194,8 @@ document.addEventListener('DOMContentLoaded', function() {
}, 450); }, 450);
}); });
// Cargar países al inicio
filtrarPaises(); filtrarPaises();
// Soporte de navegación del historial
window.addEventListener('popstate', function(e) { window.addEventListener('popstate', function(e) {
const url = (e.state && e.state.path) ? e.state.path : window.location.href; const url = (e.state && e.state.path) ? e.state.path : window.location.href;
cargarNoticiasFromURL(url); cargarNoticiasFromURL(url);