Mejoras: NER, embeddings, dashboard, docker-compose y limpieza

This commit is contained in:
jlimolina 2025-11-17 19:37:05 +01:00
parent 6c5aff9936
commit d508dc2058
19 changed files with 2218 additions and 1185 deletions

View file

@ -1,26 +1,36 @@
# Dockerfile
# -----------
# Imagen base Python # Imagen base Python
FROM python:3.11-slim FROM python:3.11-slim
# Por defecto construimos para CUDA 12.1 (cu121) # Construcción para CUDA 12.1 por defecto (usa --build-arg TORCH_CUDA=cpu para CPU)
# Si alguna vez quisieras CPU, pásale: --build-arg TORCH_CUDA=cpu
ARG TORCH_CUDA=cu121 ARG TORCH_CUDA=cu121
WORKDIR /app WORKDIR /app
# Paquetes nativos necesarios # 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/*
# Requerimientos # Ajustes de pip / runtime
COPY requirements.txt . ENV PYTHONUNBUFFERED=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1 \
TOKENIZERS_PARALLELISM=false \
HF_HUB_DISABLE_SYMLINKS_WARNING=1
# Actualiza pip y herramientas base # Dependencias Python
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 el runtime CUDA 12.1 (o CPU si TORCH_CUDA=cpu) # 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 ; \
@ -32,18 +42,18 @@ RUN if [ "$TORCH_CUDA" = "cu121" ]; then \
# Instala el resto de dependencias de tu app # 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
# spaCy + modelo español (para el NER que quieres después) # Descarga el modelo de spaCy (español) para NER
RUN pip install --no-cache-dir "spacy>=3.7,<4.0" \ # Si el entorno de build no tiene red, no rompas la build: intenta en runtime.
&& python -m spacy download es_core_news_md RUN python -m spacy download es_core_news_md || true
# Copia el código # Copia el código de la app
COPY . . COPY . .
# (Opcional) descarga recursos NLTK si tu app los usa; si no, déjalo como no-op # 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 # Puerto que usa gunicorn en el servicio web
EXPOSE 8000 EXPOSE 8000
# El CMD lo define docker-compose # El CMD/entrypoint se define en docker-compose.yml (web, scheduler, workers)

View file

@ -1,35 +0,0 @@
#!/bin/bash
# --- Script para actualizar el repositorio de Git de forma robusta ---
echo "🚀 Iniciando actualización del repositorio..."
# 1. Sincronizar con el repositorio remoto para evitar conflictos
echo "----------------------------------------"
echo "🔄 Sincronizando con el repositorio remoto (git pull)..."
git pull || { echo "❌ Error al hacer git pull. Soluciona los conflictos y vuelve a intentarlo."; exit 1; }
echo "----------------------------------------"
# 2. Preparar todos los archivos modificados y nuevos
echo " Añadiendo todos los archivos al área de preparación (git add .)"
git add .
git add -u # Asegura que los archivos eliminados también se registren
# 3. Crear el mensaje del commit solo si hay cambios
COMMIT_MSG="Actualización del $(date +'%Y-%m-%d a las %H:%M:%S')"
echo "💬 Creando commit con el mensaje: '$COMMIT_MSG'"
# Solo hacemos commit si hay algo que añadir para evitar commits vacíos
if ! git diff-index --quiet HEAD --; then
git commit -m "$COMMIT_MSG"
else
echo " No hay cambios que subir. El repositorio ya está actualizado."
exit 0
fi
# 4. Subir los cambios a GitHub
echo "⬆️ Subiendo cambios al repositorio remoto (git push)..."
git push || { echo "❌ Error al hacer git push. Revisa la conexión o los permisos."; exit 1; }
echo "✅ ¡Actualización completada!"

1874
app.py

File diff suppressed because it is too large Load diff

16
categorias.csv Normal file
View file

@ -0,0 +1,16 @@
id,nombre
1,Ciencia
2,Cultura
3,Deportes
4,Economía
5,Educación
6,Entretenimiento
7,Internacional
8,Medio Ambiente
9,Moda
10,Opinión
11,Política
12,Salud
13,Sociedad
14,Tecnología
15,Viajes
1 id nombre
2 1 Ciencia
3 2 Cultura
4 3 Deportes
5 4 Economía
6 5 Educación
7 6 Entretenimiento
8 7 Internacional
9 8 Medio Ambiente
10 9 Moda
11 10 Opinión
12 11 Política
13 12 Salud
14 13 Sociedad
15 14 Tecnología
16 15 Viajes

View file

@ -1,18 +0,0 @@
INSERT INTO categorias (nombre) VALUES
('Ciencia'),
('Cultura'),
('Deportes'),
('Economía'),
('Educación'),
('Entretenimiento'),
('Internacional'),
('Medio Ambiente'),
('Moda'),
('Opinión'),
('Política'),
('Salud'),
('Sociedad'),
('Tecnología'),
('Viajes')
ON CONFLICT DO NOTHING;

7
continentes.csv Normal file
View file

@ -0,0 +1,7 @@
id,nombre
1,África
2,América
3,Asia
4,Europa
5,Oceanía
6,Antártida
1 id nombre
2 1 África
3 2 América
4 3 Asia
5 4 Europa
6 5 Oceanía
7 6 Antártida

View file

@ -1,9 +0,0 @@
INSERT INTO continentes (id, nombre) VALUES
(1, 'África'),
(2, 'América'),
(3, 'Asia'),
(4, 'Europa'),
(5, 'Oceanía'),
(6, 'Antártida')
ON CONFLICT (id) DO NOTHING;

View file

@ -1,20 +1,27 @@
services: services:
db: db:
image: postgres:15 image: postgres:18
container_name: rss_db container_name: rss_db
environment: environment:
- POSTGRES_DB=${DB_NAME} POSTGRES_DB: ${DB_NAME}
- POSTGRES_USER=${DB_USER} POSTGRES_USER: ${DB_USER}
- POSTGRES_PASSWORD=${DB_PASS} POSTGRES_PASSWORD: ${DB_PASS}
POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale=C.UTF-8"
LANG: C.UTF-8
LC_ALL: C.UTF-8
TZ: Europe/Madrid
PGDATA: /var/lib/postgresql/data/18/main
command: ["postgres", "-c", "max_connections=400"]
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - /datos/rss/postgres/18:/var/lib/postgresql/data
- ./init-db:/docker-entrypoint-initdb.d - ./init-db:/docker-entrypoint-initdb.d:ro
restart: always restart: always
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"] test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -p 5432 -U $$POSTGRES_USER -d $$POSTGRES_DB || exit 1"]
interval: 5s interval: 5s
timeout: 5s timeout: 5s
retries: 5 retries: 30
start_period: 20s
web: web:
build: build:
@ -22,7 +29,7 @@ services:
args: args:
TORCH_CUDA: cu121 TORCH_CUDA: cu121
container_name: rss_web container_name: rss_web
command: gunicorn --bind 0.0.0.0:8000 --workers 3 app:app command: bash -lc "gunicorn --bind 0.0.0.0:8000 --workers 3 --timeout 120 app:app"
ports: ports:
- "8001:8000" - "8001:8000"
environment: environment:
@ -46,7 +53,7 @@ services:
args: args:
TORCH_CUDA: cu121 TORCH_CUDA: cu121
container_name: rss_scheduler container_name: rss_scheduler
command: python scheduler.py command: bash -lc "python scheduler.py"
environment: environment:
- DB_HOST=db - DB_HOST=db
- DB_PORT=5432 - DB_PORT=5432
@ -54,6 +61,7 @@ services:
- DB_USER=${DB_USER} - DB_USER=${DB_USER}
- DB_PASS=${DB_PASS} - DB_PASS=${DB_PASS}
- SECRET_KEY=${SECRET_KEY} - SECRET_KEY=${SECRET_KEY}
- RSS_MAX_WORKERS=8
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
@ -65,7 +73,7 @@ services:
args: args:
TORCH_CUDA: cu121 TORCH_CUDA: cu121
container_name: rss_translator container_name: rss_translator
command: python translation_worker.py command: bash -lc "python translation_worker.py"
environment: environment:
- DB_HOST=db - DB_HOST=db
- DB_PORT=5432 - DB_PORT=5432
@ -101,7 +109,7 @@ services:
- NVIDIA_VISIBLE_DEVICES=all - NVIDIA_VISIBLE_DEVICES=all
- NVIDIA_DRIVER_CAPABILITIES=compute,utility - NVIDIA_DRIVER_CAPABILITIES=compute,utility
volumes: volumes:
- hf_cache:/root/.cache/huggingface - /datos/rss/hf_cache:/root/.cache/huggingface
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
@ -114,7 +122,7 @@ services:
args: args:
TORCH_CUDA: cu121 TORCH_CUDA: cu121
container_name: rss_ner container_name: rss_ner
command: python ner_worker.py command: bash -lc "python ner_worker.py"
environment: environment:
- DB_HOST=db - DB_HOST=db
- DB_PORT=5432 - DB_PORT=5432
@ -128,7 +136,61 @@ services:
condition: service_healthy condition: service_healthy
restart: always restart: always
volumes: embeddings:
postgres_data: build:
hf_cache: context: .
args:
TORCH_CUDA: cu121
container_name: rss_embeddings
command: bash -lc "python embeddings_worker.py"
environment:
- DB_HOST=db
- DB_PORT=5432
- 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
- PYTHONUNBUFFERED=1
- HF_HOME=/root/.cache/huggingface
- TOKENIZERS_PARALLELISM=false
volumes:
- /datos/rss/hf_cache:/root/.cache/huggingface
depends_on:
db:
condition: service_healthy
restart: always
# gpus: all
related:
build:
context: .
args:
TORCH_CUDA: cu121
container_name: rss_related
command: bash -lc "python related_worker.py"
environment:
- DB_HOST=db
- DB_PORT=5432
- DB_NAME=${DB_NAME}
- DB_USER=${DB_USER}
- DB_PASS=${DB_PASS}
- RELATED_TOPK=10
- RELATED_BATCH_IDS=200
- RELATED_BATCH_SIM=2000
- RELATED_SLEEP=10
- RELATED_MIN_SCORE=0.0
- RELATED_WINDOW_H=0
depends_on:
db:
condition: service_healthy
restart: always
networks:
default:
name: rss_default

161
embeddings_worker.py Normal file
View file

@ -0,0 +1,161 @@
# 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
# - Guarda en traduccion_embeddings (traduccion_id, model, dim, embedding)
import os
import time
import logging
from typing import List, Tuple
import numpy as np
import psycopg2
import psycopg2.extras
from sentence_transformers import SentenceTransformer
logging.basicConfig(level=logging.INFO, format='[%(asctime)s] %(levelname)s: %(message)s')
log = logging.getLogger(__name__)
# ---------- Configuración DB ----------
DB = dict(
host=os.environ.get("DB_HOST", "localhost"),
port=int(os.environ.get("DB_PORT", 5432)),
dbname=os.environ.get("DB_NAME", "rss"),
user=os.environ.get("DB_USER", "rss"),
password=os.environ.get("DB_PASS", "x"),
)
# ---------- 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"))
# 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'
# Límite por iteración (para no tragar toda la tabla de golpe)
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("""
CREATE TABLE IF NOT EXISTS traduccion_embeddings (
id SERIAL PRIMARY KEY,
traduccion_id INT NOT NULL REFERENCES traducciones(id) ON DELETE CASCADE,
model TEXT NOT NULL,
dim INT NOT NULL,
embedding DOUBLE PRECISION[] NOT NULL,
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"""
SELECT t.id AS traduccion_id,
t.lang_to AS lang_to,
COALESCE(NULLIF(t.titulo_trad, ''), '') AS titulo_trad,
COALESCE(NULLIF(t.resumen_trad,''), '') AS resumen_trad,
n.id AS noticia_id
FROM traducciones t
JOIN noticias n ON n.id = t.noticia_id
LEFT JOIN traduccion_embeddings e
ON e.traduccion_id = t.id AND e.model = %s
WHERE t.status = 'done'
AND t.lang_to = ANY(%s)
AND e.traduccion_id IS NULL
ORDER BY t.id
LIMIT %s
""", (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:
'titulo_trad' + '\n' + 'resumen_trad'. Si alguno falta, usa lo disponible.
"""
texts = []
for r in rows:
title = (r["titulo_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.
"""
if embs.size == 0 or not rows:
return
dim = int(embs.shape[1])
with conn.cursor() as cur:
for r, e in zip(rows, embs):
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))))
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)
# 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)
while True:
try:
with get_conn() as conn:
ensure_schema(conn)
rows = fetch_batch_pending(conn)
if not rows:
time.sleep(SLEEP_IDLE)
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
)
upsert_embeddings(conn, rows, embs, EMB_MODEL)
log.info("Embeddings upserted: %d", len(rows))
except Exception as e:
log.exception("Error en embeddings_worker: %s", e)
time.sleep(SLEEP_IDLE)
if __name__ == "__main__":
main()

81
init-db/08-embeddings.sql Normal file
View file

@ -0,0 +1,81 @@
-- 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,
model TEXT NOT NULL,
dim INT NOT NULL,
embedding DOUBLE PRECISION[] NOT NULL,
created_at TIMESTAMP DEFAULT NOW(),
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'
) THEN
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,
te.dim,
te.embedding AS vec
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,
score DOUBLE PRECISION NOT NULL,
created_at TIMESTAMP DEFAULT NOW(),
PRIMARY KEY (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_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

@ -0,0 +1,62 @@
-- Función para “normalizar” URLs: minúsculas en esquema/host, quitar www,
-- quitar fragmentos (#...), limpiar trackers comunes (utm_*, gclid, fbclid, ref, etc.),
-- colapsar dobles “/”, y quitar la “/” final salvo si es la raíz.
CREATE OR REPLACE FUNCTION normalize_url(in_url text)
RETURNS text
LANGUAGE plpgsql
AS $$
DECLARE
u text := trim(in_url);
scheme_host text;
path_q text;
BEGIN
IF u IS NULL OR u = '' THEN
RETURN NULL;
END IF;
-- quitar espacios y fragmentos
u := regexp_replace(u, '#.*$', '', 'i');
-- separar esquema+host de path+query
-- ej: https://example.com:443/foo?bar -> scheme_host=https://example.com:443 ; path_q=/foo?bar
scheme_host := substring(u FROM '^[a-z]+://[^/]*');
IF scheme_host IS NULL THEN
-- si no hay esquema, asumimos http
u := 'http://' || u;
scheme_host := substring(u FROM '^[a-z]+://[^/]*');
END IF;
path_q := substring(u FROM '^[a-z]+://[^/]*(/.*)$');
IF path_q IS NULL THEN
path_q := '/';
END IF;
-- normalizar esquema y host (minúsculas, quitar www.)
scheme_host := lower(scheme_host);
scheme_host := regexp_replace(scheme_host, '^(https?://)www\.', '\1', 'i');
-- quitar puerto por defecto (:80 en http, :443 en https)
scheme_host := regexp_replace(scheme_host, '^http://([^/:]+):80$', 'http://\1', 'i');
scheme_host := regexp_replace(scheme_host, '^https://([^/:]+):443$', 'https://\1', 'i');
-- limpiar parámetros de tracking en la query
-- elimina ?utm_... &utm_... gclid fbclid mc_cid mc_eid ref ref_src etc.
path_q := regexp_replace(path_q, '([?&])(utm_[^=&]+|gclid|fbclid|mc_cid|mc_eid|ref|ref_src|yclid|igshid)=[^&#]*', '\1', 'gi');
-- limpiar conectores sobrantes ?, &, &&, ?&, etc.
path_q := regexp_replace(path_q, '\?&+', '?', 'g');
path_q := regexp_replace(path_q, '&{2,}', '&', 'g');
path_q := regexp_replace(path_q, '\?$', '', 'g');
path_q := regexp_replace(path_q, '\?$','', 'g');
-- colapsar dobles barras en path (no tocar “://”)
path_q := regexp_replace(path_q, '/{2,}', '/', 'g');
-- quitar “/” final si no es la raíz
IF path_q <> '/' THEN
path_q := regexp_replace(path_q, '/+$', '', 'g');
END IF;
RETURN scheme_host || path_q;
END;
$$;

View file

@ -0,0 +1,38 @@
-- Añadir columna generada url_norm y crear índice único sobre ella.
-- OJO: si ya existen duplicados, este índice fallará.
-- Primero crea la columna si no existe:
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name='feeds' AND column_name='url_norm'
) THEN
ALTER TABLE feeds
ADD COLUMN url_norm text GENERATED ALWAYS AS (normalize_url(url)) STORED;
END IF;
END $$;
-- Índice único (concurrently para no bloquear). Requiere estar fuera de transacción.
-- Si tu herramienta corre todo en una transacción, ejecuta estas dos líneas aparte.
-- Quita duplicados antes si da error.
CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS feeds_url_norm_uniq ON feeds (url_norm)
WHERE url_norm IS NOT NULL;
-- (Opcional) repetir lo mismo para fuentes_url y noticias si quieres esa garantía también:
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name='fuentes_url' AND column_name='url_norm'
) THEN
ALTER TABLE fuentes_url
ADD COLUMN url_norm text GENERATED ALWAYS AS (normalize_url(url)) STORED;
END IF;
END $$;
CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS fuentes_url_norm_uniq ON fuentes_url (url_norm)
WHERE url_norm IS NOT NULL;

View file

@ -5,6 +5,7 @@ import re
import psycopg2 import psycopg2
import psycopg2.extras import psycopg2.extras
import spacy import spacy
from bs4 import BeautifulSoup
logging.basicConfig(level=logging.INFO, format='[%(asctime)s] %(levelname)s: %(message)s') logging.basicConfig(level=logging.INFO, format='[%(asctime)s] %(levelname)s: %(message)s')
@ -16,13 +17,9 @@ DB = dict(
password=os.environ.get("DB_PASS", "x"), password=os.environ.get("DB_PASS", "x"),
) )
# Idioma de las traducciones que vamos a etiquetar
NER_LANG = os.environ.get("NER_LANG", "es").strip().lower() NER_LANG = os.environ.get("NER_LANG", "es").strip().lower()
# Tamaño de lote de traducciones a procesar por iteración
BATCH = int(os.environ.get("NER_BATCH", 64)) BATCH = int(os.environ.get("NER_BATCH", 64))
# Mapeo de etiquetas de spaCy -> tipos de nuestro esquema
ENT_LABELS = { ENT_LABELS = {
"PERSON": "persona", "PERSON": "persona",
"ORG": "organizacion", "ORG": "organizacion",
@ -30,28 +27,65 @@ ENT_LABELS = {
"LOC": "lugar", "LOC": "lugar",
} }
# Normaliza el valor del tag (quita espacios extra, colapsa espacios internos)
_ws_re = re.compile(r"\s+") _ws_re = re.compile(r"\s+")
def _clean_value(s: str) -> str: HTML_TRASH_PATTERNS = [
if not s: r"<[^>]+>",
return "" r"&[a-z]+;",
s = s.strip() r'width="\d+"',
s = _ws_re.sub(" ", s) r'height="\d+"',
return s ]
GENERIC_BAD_TAGS = {
"república",
"estado",
"centro",
"gobierno",
"report",
"sp",
"unión",
}
def clean_tag_text(text):
if not text:
return None
text = BeautifulSoup(text, "html.parser").get_text()
for pat in HTML_TRASH_PATTERNS:
text = re.sub(pat, "", text)
text = _ws_re.sub(" ", text).strip()
if len(text) < 3:
return None
if re.search(r"[<>/\\]", text):
return None
lower = text.lower()
if lower.startswith("href="):
return None
if lower.startswith("http"):
return None
if lower in GENERIC_BAD_TAGS:
return None
replacements = {
"ee.uu.": "Estados Unidos",
"los estados unidos": "Estados Unidos",
"eu": "Unión Europea",
"ue": "Unión Europea",
"kosova": "Kosovo",
}
if lower in replacements:
text = replacements[lower]
return text
def get_conn(): def get_conn():
return psycopg2.connect(**DB) return psycopg2.connect(**DB)
def main(): def main():
# Nota: asumimos español porque el contenedor instala es_core_news_md en el Dockerfile.
# Si quisieras soportar más idiomas, instala el modelo correspondiente y haz un mapping.
nlp = spacy.load("es_core_news_md", disable=["parser", "lemmatizer", "textcat"]) nlp = spacy.load("es_core_news_md", disable=["parser", "lemmatizer", "textcat"])
logging.info("spaCy cargado: es_core_news_md") logging.info("spaCy cargado: es_core_news_md")
while True: while True:
try: try:
with get_conn() as conn, conn.cursor(cursor_factory=psycopg2.extras.DictCursor) as cur: with get_conn() as conn, conn.cursor(cursor_factory=psycopg2.extras.DictCursor) as cur:
# Tomamos traducciones 'done' hacia NER_LANG que aún no tengan ninguna relación en tags_noticia
cur.execute( cur.execute(
""" """
WITH pend AS ( WITH pend AS (
@ -78,7 +112,7 @@ def main():
logging.info(f"Procesando {len(rows)} traducciones para NER...") logging.info(f"Procesando {len(rows)} traducciones para NER...")
new_links = 0 new_links = 0
new_tags = 0
for r in rows: for r in rows:
text = f"{r['titulo_trad'] or ''}\n{r['resumen_trad'] or ''}".strip() text = f"{r['titulo_trad'] or ''}\n{r['resumen_trad'] or ''}".strip()
if not text: if not text:
@ -91,17 +125,14 @@ def main():
tipo = ENT_LABELS.get(ent.label_) tipo = ENT_LABELS.get(ent.label_)
if not tipo: if not tipo:
continue continue
val = _clean_value(ent.text) val = clean_tag_text(ent.text)
# filtros simples if not val:
if len(val) < 2:
continue continue
ents.append((val, tipo)) ents.append((val, tipo))
if not ents: if not ents:
continue continue
# Insertamos (o actualizamos si ya existe) el tag y luego la relación
# IMPORTANTE: requiere UNIQUE(valor, tipo) en 'tags' y UNIQUE(traduccion_id, tag_id) en 'tags_noticia'
for valor, tipo in set(ents): for valor, tipo in set(ents):
try: try:
cur.execute( cur.execute(
@ -115,7 +146,6 @@ def main():
(valor, tipo), (valor, tipo),
) )
tag_id = cur.fetchone()[0] tag_id = cur.fetchone()[0]
# Intenta crear la relación; si existe (por UNIQUE), se ignora
cur.execute( cur.execute(
""" """
INSERT INTO tags_noticia (traduccion_id, tag_id) INSERT INTO tags_noticia (traduccion_id, tag_id)
@ -126,11 +156,7 @@ def main():
) )
if cur.rowcount > 0: if cur.rowcount > 0:
new_links += 1 new_links += 1
# Heurística: si el tag se ha creado (no hay forma directa aquí),
# lo aproximamos contando que el RETURNING vino de un insert o un update.
# Para no complicar: cuenta enlaces nuevos, y deja 'new_tags' como métrica opcional.
except Exception: except Exception:
# No abortar el lote por un único fallo en un valor raro.
logging.exception("Fallo insertando tag/relación") logging.exception("Fallo insertando tag/relación")
conn.commit() conn.commit()
@ -139,6 +165,7 @@ def main():
logging.exception(f"Error en NER loop: {e}") logging.exception(f"Error en NER loop: {e}")
time.sleep(5) time.sleep(5)
if __name__ == "__main__": if __name__ == "__main__":
main() main()

196
paises.csv Normal file
View file

@ -0,0 +1,196 @@
id,nombre,continente_id
1,Afganistán,3
2,Albania,4
3,Alemania,4
4,Andorra,4
5,Angola,1
6,Antigua y Barbuda,2
7,Arabia Saudita,3
8,Argelia,1
9,Argentina,2
10,Armenia,3
11,Australia,5
12,Austria,4
13,Azerbaiyán,3
14,Bahamas,2
15,Bangladés,3
16,Barbados,2
17,Baréin,3
19,Belice,2
20,Benín,1
21,Bielorrusia,4
22,Birmania,3
23,Bolivia,2
24,Bosnia y Herzegovina,4
25,Botsuana,1
26,Brasil,2
27,Brunéi,3
28,Bulgaria,4
29,Burkina Faso,1
30,Burundi,1
31,Bután,3
18,Bélgica,4
32,Cabo Verde,1
33,Camboya,3
34,Camerún,1
35,Canadá,2
36,Catar,3
37,Chad,1
38,Chile,2
39,China,3
40,Chipre,3
41,Colombia,2
42,Comoras,1
43,Corea del Norte,3
44,Corea del Sur,3
46,Costa Rica,2
45,Costa de Marfil,1
47,Croacia,4
48,Cuba,2
49,Dinamarca,4
50,Dominica,2
51,Ecuador,2
52,Egipto,1
53,El Salvador,2
54,Emiratos Árabes Unidos,3
55,Eritrea,1
56,Eslovaquia,4
57,Eslovenia,4
58,España,4
59,Estados Unidos,2
60,Estonia,4
61,Esuatini,1
62,Etiopía,1
63,Filipinas,3
64,Finlandia,4
65,Fiyi,5
66,Francia,4
67,Gabón,1
68,Gambia,1
69,Georgia,3
70,Ghana,1
71,Granada,2
72,Grecia,4
73,Guatemala,2
74,Guinea,1
76,Guinea Ecuatorial,1
75,Guinea-Bisáu,1
77,Guyana,2
78,Haití,2
79,Honduras,2
80,Hungría,4
81,India,3
82,Indonesia,3
83,Irak,3
85,Irlanda,4
84,Irán,3
86,Islandia,4
87,Islas Marshall,5
88,Islas Salomón,5
89,Israel,3
90,Italia,4
91,Jamaica,2
92,Japón,3
93,Jordania,3
94,Kazajistán,3
95,Kenia,1
96,Kirguistán,3
97,Kiribati,5
98,Kuwait,3
99,Laos,3
100,Lesoto,1
101,Letonia,4
103,Liberia,1
104,Libia,1
105,Liechtenstein,4
106,Lituania,4
107,Luxemburgo,4
102,Líbano,3
108,Macedonia del Norte,4
109,Madagascar,1
110,Malasia,3
111,Malaui,1
112,Maldivas,3
114,Malta,4
113,Malí,1
115,Marruecos,1
116,Mauricio,1
117,Mauritania,1
119,Micronesia,5
120,Moldavia,4
122,Mongolia,3
123,Montenegro,4
124,Mozambique,1
118,México,2
121,Mónaco,4
125,Namibia,1
126,Nauru,5
127,Nepal,3
128,Nicaragua,2
130,Nigeria,1
131,Noruega,4
132,Nueva Zelanda,5
129,Níger,1
133,Omán,3
135,Pakistán,3
136,Palaos,5
137,Palestina,3
138,Panamá,2
139,Papúa Nueva Guinea,5
140,Paraguay,2
134,Países Bajos,4
141,Perú,2
142,Polonia,4
143,Portugal,4
144,Reino Unido,4
145,República Centroafricana,1
146,República Checa,4
148,República Democrática del Congo,1
149,República Dominicana,2
147,República del Congo,1
150,Ruanda,1
151,Rumanía,4
152,Rusia,3
153,Samoa,5
154,San Cristóbal y Nieves,2
155,San Marino,4
156,San Vicente y las Granadinas,2
157,Santa Lucía,2
158,Santo Tomé y Príncipe,1
159,Senegal,1
160,Serbia,4
161,Seychelles,1
162,Sierra Leona,1
163,Singapur,3
164,Siria,3
165,Somalia,1
166,Sri Lanka,3
167,Sudáfrica,1
168,Sudán,1
169,Sudán del Sur,1
170,Suecia,4
171,Suiza,4
172,Surinam,2
173,Tailandia,3
174,Tanzania,1
175,Tayikistán,3
176,Timor Oriental,3
177,Togo,1
178,Tonga,5
179,Trinidad y Tobago,2
181,Turkmenistán,3
182,Turquía,3
183,Tuvalu,5
180,Túnez,1
184,Ucrania,4
185,Uganda,1
186,Uruguay,2
187,Uzbekistán,3
188,Vanuatu,5
189,Vaticano,4
190,Venezuela,2
191,Vietnam,3
192,Yemen,3
193,Yibuti,1
194,Zambia,1
195,Zimbabue,1
1 id nombre continente_id
2 1 Afganistán 3
3 2 Albania 4
4 3 Alemania 4
5 4 Andorra 4
6 5 Angola 1
7 6 Antigua y Barbuda 2
8 7 Arabia Saudita 3
9 8 Argelia 1
10 9 Argentina 2
11 10 Armenia 3
12 11 Australia 5
13 12 Austria 4
14 13 Azerbaiyán 3
15 14 Bahamas 2
16 15 Bangladés 3
17 16 Barbados 2
18 17 Baréin 3
19 19 Belice 2
20 20 Benín 1
21 21 Bielorrusia 4
22 22 Birmania 3
23 23 Bolivia 2
24 24 Bosnia y Herzegovina 4
25 25 Botsuana 1
26 26 Brasil 2
27 27 Brunéi 3
28 28 Bulgaria 4
29 29 Burkina Faso 1
30 30 Burundi 1
31 31 Bután 3
32 18 Bélgica 4
33 32 Cabo Verde 1
34 33 Camboya 3
35 34 Camerún 1
36 35 Canadá 2
37 36 Catar 3
38 37 Chad 1
39 38 Chile 2
40 39 China 3
41 40 Chipre 3
42 41 Colombia 2
43 42 Comoras 1
44 43 Corea del Norte 3
45 44 Corea del Sur 3
46 46 Costa Rica 2
47 45 Costa de Marfil 1
48 47 Croacia 4
49 48 Cuba 2
50 49 Dinamarca 4
51 50 Dominica 2
52 51 Ecuador 2
53 52 Egipto 1
54 53 El Salvador 2
55 54 Emiratos Árabes Unidos 3
56 55 Eritrea 1
57 56 Eslovaquia 4
58 57 Eslovenia 4
59 58 España 4
60 59 Estados Unidos 2
61 60 Estonia 4
62 61 Esuatini 1
63 62 Etiopía 1
64 63 Filipinas 3
65 64 Finlandia 4
66 65 Fiyi 5
67 66 Francia 4
68 67 Gabón 1
69 68 Gambia 1
70 69 Georgia 3
71 70 Ghana 1
72 71 Granada 2
73 72 Grecia 4
74 73 Guatemala 2
75 74 Guinea 1
76 76 Guinea Ecuatorial 1
77 75 Guinea-Bisáu 1
78 77 Guyana 2
79 78 Haití 2
80 79 Honduras 2
81 80 Hungría 4
82 81 India 3
83 82 Indonesia 3
84 83 Irak 3
85 85 Irlanda 4
86 84 Irán 3
87 86 Islandia 4
88 87 Islas Marshall 5
89 88 Islas Salomón 5
90 89 Israel 3
91 90 Italia 4
92 91 Jamaica 2
93 92 Japón 3
94 93 Jordania 3
95 94 Kazajistán 3
96 95 Kenia 1
97 96 Kirguistán 3
98 97 Kiribati 5
99 98 Kuwait 3
100 99 Laos 3
101 100 Lesoto 1
102 101 Letonia 4
103 103 Liberia 1
104 104 Libia 1
105 105 Liechtenstein 4
106 106 Lituania 4
107 107 Luxemburgo 4
108 102 Líbano 3
109 108 Macedonia del Norte 4
110 109 Madagascar 1
111 110 Malasia 3
112 111 Malaui 1
113 112 Maldivas 3
114 114 Malta 4
115 113 Malí 1
116 115 Marruecos 1
117 116 Mauricio 1
118 117 Mauritania 1
119 119 Micronesia 5
120 120 Moldavia 4
121 122 Mongolia 3
122 123 Montenegro 4
123 124 Mozambique 1
124 118 México 2
125 121 Mónaco 4
126 125 Namibia 1
127 126 Nauru 5
128 127 Nepal 3
129 128 Nicaragua 2
130 130 Nigeria 1
131 131 Noruega 4
132 132 Nueva Zelanda 5
133 129 Níger 1
134 133 Omán 3
135 135 Pakistán 3
136 136 Palaos 5
137 137 Palestina 3
138 138 Panamá 2
139 139 Papúa Nueva Guinea 5
140 140 Paraguay 2
141 134 Países Bajos 4
142 141 Perú 2
143 142 Polonia 4
144 143 Portugal 4
145 144 Reino Unido 4
146 145 República Centroafricana 1
147 146 República Checa 4
148 148 República Democrática del Congo 1
149 149 República Dominicana 2
150 147 República del Congo 1
151 150 Ruanda 1
152 151 Rumanía 4
153 152 Rusia 3
154 153 Samoa 5
155 154 San Cristóbal y Nieves 2
156 155 San Marino 4
157 156 San Vicente y las Granadinas 2
158 157 Santa Lucía 2
159 158 Santo Tomé y Príncipe 1
160 159 Senegal 1
161 160 Serbia 4
162 161 Seychelles 1
163 162 Sierra Leona 1
164 163 Singapur 3
165 164 Siria 3
166 165 Somalia 1
167 166 Sri Lanka 3
168 167 Sudáfrica 1
169 168 Sudán 1
170 169 Sudán del Sur 1
171 170 Suecia 4
172 171 Suiza 4
173 172 Surinam 2
174 173 Tailandia 3
175 174 Tanzania 1
176 175 Tayikistán 3
177 176 Timor Oriental 3
178 177 Togo 1
179 178 Tonga 5
180 179 Trinidad y Tobago 2
181 181 Turkmenistán 3
182 182 Turquía 3
183 183 Tuvalu 5
184 180 Túnez 1
185 184 Ucrania 4
186 185 Uganda 1
187 186 Uruguay 2
188 187 Uzbekistán 3
189 188 Vanuatu 5
190 189 Vaticano 4
191 190 Venezuela 2
192 191 Vietnam 3
193 192 Yemen 3
194 193 Yibuti 1
195 194 Zambia 1
196 195 Zimbabue 1

View file

@ -1,198 +0,0 @@
INSERT INTO paises (nombre, continente_id) VALUES
('Afganistán', 3),
('Albania', 4),
('Alemania', 4),
('Andorra', 4),
('Angola', 1),
('Antigua y Barbuda', 2),
('Arabia Saudita', 3),
('Argelia', 1),
('Argentina', 2),
('Armenia', 3),
('Australia', 5),
('Austria', 4),
('Azerbaiyán', 3),
('Bahamas', 2),
('Bangladés', 3),
('Barbados', 2),
('Baréin', 3),
('Bélgica', 4),
('Belice', 2),
('Benín', 1),
('Bielorrusia', 4),
('Birmania', 3),
('Bolivia', 2),
('Bosnia y Herzegovina', 4),
('Botsuana', 1),
('Brasil', 2),
('Brunéi', 3),
('Bulgaria', 4),
('Burkina Faso', 1),
('Burundi', 1),
('Bután', 3),
('Cabo Verde', 1),
('Camboya', 3),
('Camerún', 1),
('Canadá', 2),
('Catar', 3),
('Chad', 1),
('Chile', 2),
('China', 3),
('Chipre', 3),
('Colombia', 2),
('Comoras', 1),
('Corea del Norte', 3),
('Corea del Sur', 3),
('Costa de Marfil', 1),
('Costa Rica', 2),
('Croacia', 4),
('Cuba', 2),
('Dinamarca', 4),
('Dominica', 2),
('Ecuador', 2),
('Egipto', 1),
('El Salvador', 2),
('Emiratos Árabes Unidos', 3),
('Eritrea', 1),
('Eslovaquia', 4),
('Eslovenia', 4),
('España', 4),
('Estados Unidos', 2),
('Estonia', 4),
('Esuatini', 1),
('Etiopía', 1),
('Filipinas', 3),
('Finlandia', 4),
('Fiyi', 5),
('Francia', 4),
('Gabón', 1),
('Gambia', 1),
('Georgia', 3),
('Ghana', 1),
('Granada', 2),
('Grecia', 4),
('Guatemala', 2),
('Guinea', 1),
('Guinea-Bisáu', 1),
('Guinea Ecuatorial', 1),
('Guyana', 2),
('Haití', 2),
('Honduras', 2),
('Hungría', 4),
('India', 3),
('Indonesia', 3),
('Irak', 3),
('Irán', 3),
('Irlanda', 4),
('Islandia', 4),
('Islas Marshall', 5),
('Islas Salomón', 5),
('Israel', 3),
('Italia', 4),
('Jamaica', 2),
('Japón', 3),
('Jordania', 3),
('Kazajistán', 3),
('Kenia', 1),
('Kirguistán', 3),
('Kiribati', 5),
('Kuwait', 3),
('Laos', 3),
('Lesoto', 1),
('Letonia', 4),
('Líbano', 3),
('Liberia', 1),
('Libia', 1),
('Liechtenstein', 4),
('Lituania', 4),
('Luxemburgo', 4),
('Macedonia del Norte', 4),
('Madagascar', 1),
('Malasia', 3),
('Malaui', 1),
('Maldivas', 3),
('Malí', 1),
('Malta', 4),
('Marruecos', 1),
('Mauricio', 1),
('Mauritania', 1),
('México', 2),
('Micronesia', 5),
('Moldavia', 4),
('Mónaco', 4),
('Mongolia', 3),
('Montenegro', 4),
('Mozambique', 1),
('Namibia', 1),
('Nauru', 5),
('Nepal', 3),
('Nicaragua', 2),
('Níger', 1),
('Nigeria', 1),
('Noruega', 4),
('Nueva Zelanda', 5),
('Omán', 3),
('Países Bajos', 4),
('Pakistán', 3),
('Palaos', 5),
('Palestina', 3),
('Panamá', 2),
('Papúa Nueva Guinea', 5),
('Paraguay', 2),
('Perú', 2),
('Polonia', 4),
('Portugal', 4),
('Reino Unido', 4),
('República Centroafricana', 1),
('República Checa', 4),
('República del Congo', 1),
('República Democrática del Congo', 1),
('República Dominicana', 2),
('Ruanda', 1),
('Rumanía', 4),
('Rusia', 3),
('Samoa', 5),
('San Cristóbal y Nieves', 2),
('San Marino', 4),
('San Vicente y las Granadinas', 2),
('Santa Lucía', 2),
('Santo Tomé y Príncipe', 1),
('Senegal', 1),
('Serbia', 4),
('Seychelles', 1),
('Sierra Leona', 1),
('Singapur', 3),
('Siria', 3),
('Somalia', 1),
('Sri Lanka', 3),
('Sudáfrica', 1),
('Sudán', 1),
('Sudán del Sur', 1),
('Suecia', 4),
('Suiza', 4),
('Surinam', 2),
('Tailandia', 3),
('Tanzania', 1),
('Tayikistán', 3),
('Timor Oriental', 3),
('Togo', 1),
('Tonga', 5),
('Trinidad y Tobago', 2),
('Túnez', 1),
('Turkmenistán', 3),
('Turquía', 3),
('Tuvalu', 5),
('Ucrania', 4),
('Uganda', 1),
('Uruguay', 2),
('Uzbekistán', 3),
('Vanuatu', 5),
('Vaticano', 4),
('Venezuela', 2),
('Vietnam', 3),
('Yemen', 3),
('Yibuti', 1),
('Zambia', 1),
('Zimbabue', 1)
ON CONFLICT DO NOTHING;

206
related_worker.py Normal file
View file

@ -0,0 +1,206 @@
# related_worker.py
import os
import time
import math
import logging
from typing import List, Tuple
import psycopg2
import psycopg2.extras
logging.basicConfig(
level=logging.INFO,
format='[related] %(asctime)s %(levelname)s: %(message)s'
)
DB = dict(
host=os.environ.get("DB_HOST", "localhost"),
port=int(os.environ.get("DB_PORT", 5432)),
dbname=os.environ.get("DB_NAME", "rss"),
user=os.environ.get("DB_USER", "rss"),
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
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("""
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",))
else:
cur.execute("SELECT traduccion_id, vec FROM embeddings")
rows = cur.fetchall()
if not rows:
return [], [], []
ids = []
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("""
SELECT e.traduccion_id
FROM embeddings e
LEFT JOIN related_noticias r ON r.traduccion_id = e.traduccion_id
GROUP BY e.traduccion_id
HAVING COUNT(r.related_traduccion_id) = 0
ORDER BY e.traduccion_id DESC
LIMIT %s;
""", (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
num += xv * yv
denom = na * nb
if denom <= 0.0:
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.
"""
me_vec = vecs_all[idx]
me_norm = norms_all[idx]
out: List[Tuple[int, float]] = []
for j in pool_indices:
if j == idx:
continue
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
psycopg2.extras.execute_values(
cur,
"""
INSERT INTO related_noticias (traduccion_id, related_traduccion_id, score)
VALUES %s
ON CONFLICT (traduccion_id, related_traduccion_id)
DO UPDATE SET score = EXCLUDED.score
""",
[(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
with conn.cursor() as cur:
for tr_id in target_ids:
if tr_id not in pos:
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:
top = top[:TOPK]
_insert_related(cur, tr_id, top)
processed += 1
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
)
while True:
try:
with get_conn() as conn, conn.cursor() as cur:
todo = _fetch_pending_ids(cur, BATCH_IDS)
if not todo:
time.sleep(SLEEP_IDLE)
continue
with get_conn() as conn:
done = build_for_ids(conn, todo)
logging.info("Relacionadas generadas/actualizadas para %d traducciones.", done)
except Exception:
logging.exception("Error en related_worker")
time.sleep(SLEEP_IDLE)
if __name__ == "__main__":
main()

View file

@ -16,6 +16,8 @@ sentencepiece==0.2.0
sacremoses==0.1.1 sacremoses==0.1.1
accelerate==0.33.0 accelerate==0.33.0
spacy>=3.7,<4.0 spacy>=3.7,<4.0
# Nota: PyTorch (torch) NO se fija aquí. pgvector==0.2.5
# Se instala en el Dockerfile con la wheel adecuada de CUDA (cu121) para tu GPU. sentence-transformers==3.0.1
numpy>=1.26
scikit-learn>=1.4
python-dotenv>=1.0

111
templates/noticia.html Normal file
View file

@ -0,0 +1,111 @@
{% 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' }}
{% else %}
Detalle de Noticia
{% endif %}
{% endblock %}
{% block content %}
{% set d = dato if dato is defined else (r if r is defined else None) %}
{% if not d %}
<div class="card">
<div class="card-body">
<p>No se encontró la noticia solicitada.</p>
</div>
</div>
{% else %}
<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 %}
</h2>
{% if d.fuente_url or d.url %}
<div>
<a class="btn btn-small" href="{{ d.fuente_url or d.url }}" target="_blank" rel="noopener">Ver fuente</a>
</div>
{% endif %}
</div>
<div class="feed-body">
<div class="noticia-meta" style="margin-bottom:12px;">
{% 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 %}
{% 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 %}
<h3>Resumen (traducido)</h3>
<div>{{ (d.resumen_trad or d.cuerpo_traducido)|safe_html }}</div>
<hr>
{% endif %}
{% if d.resumen_orig or d.cuerpo_original or d.resumen or d.titulo_original %}
<h3>Resumen (original)</h3>
<div>{{ (d.resumen_orig or d.cuerpo_original or d.resumen)|safe_html }}</div>
{% endif %}
{% if tags is defined and 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>
{% endfor %}
</div>
{% endif %}
</div>
</div>
{% set rels = relacionadas if relacionadas is defined else None %}
{% if rels and rels|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 %}
<li class="noticia-item">
{% if r.imagen_url %}
<div class="noticia-imagen">
<a href="{{ r.url }}" target="_blank" rel="noopener">
<img src="{{ r.imagen_url }}" alt="Imagen relacionada" loading="lazy">
</a>
</div>
{% endif %}
<div class="noticia-texto">
<h3 class="m0">
<a href="{{ r.url }}" target="_blank" rel="noopener">{{ r.titulo }}</a>
</h3>
<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 %}
{% 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 %}
</div>
{% if r.resumen %}
<div class="clamp">{{ r.resumen }}</div>
{% endif %}
</div>
</li>
{% endfor %}
</ul>
</div>
</div>
{% endif %}
{% endif %}
{% endblock %}

View file

@ -88,10 +88,16 @@ document.addEventListener('DOMContentLoaded', function() {
const form = document.getElementById('filter-form'); const form = document.getElementById('filter-form');
const continenteSelect = document.getElementById('continente_id'); const continenteSelect = document.getElementById('continente_id');
const paisSelect = document.getElementById('pais_id'); const paisSelect = document.getElementById('pais_id');
const categoriaSelect = document.getElementById('categoria_id');
const fechaInput = document.getElementById('fecha');
const qInput = document.getElementById('q');
const pageInput = document.getElementById('page'); const pageInput = document.getElementById('page');
const origInput = document.getElementById('orig'); const origInput = document.getElementById('orig');
const langInput = document.getElementById('lang'); const langInput = document.getElementById('lang');
function setPage1() { pageInput.value = 1; }
function filtrarPaises() { function filtrarPaises() {
const continenteId = continenteSelect.value; const continenteId = continenteSelect.value;
for (let i = 1; i < paisSelect.options.length; i++) { for (let i = 1; i < paisSelect.options.length; i++) {
@ -105,22 +111,14 @@ document.addEventListener('DOMContentLoaded', function() {
} }
} }
async function cargarNoticias(keepPage) { async function cargarNoticiasFromURL(url) {
if (!keepPage) pageInput.value = 1;
const formData = new FormData(form);
const params = new URLSearchParams(formData);
const newUrl = `${form.action}?${params.toString()}`;
const container = document.getElementById('noticias-container'); const container = document.getElementById('noticias-container');
container.style.opacity = '0.5'; container.style.opacity = '0.5';
container.innerHTML = '<div style="text-align:center; padding: 40px;"><i class="fas fa-spinner fa-spin fa-2x"></i></div>'; container.innerHTML = '<div style="text-align:center; padding: 40px;"><i class="fas fa-spinner fa-spin fa-2x"></i></div>';
try { try {
const response = await fetch(newUrl, { headers: { 'X-Requested-With': 'XMLHttpRequest' } }); const response = await fetch(url, { headers: { 'X-Requested-With': 'XMLHttpRequest' } });
const html = await response.text(); const html = await response.text();
container.innerHTML = html; container.innerHTML = html;
window.history.pushState({path: newUrl}, '', newUrl);
} catch (error) { } catch (error) {
console.error('Error al filtrar noticias:', error); console.error('Error al filtrar noticias:', error);
container.innerHTML = '<p style="color:var(--error-color); text-align:center;">Error al cargar las noticias.</p>'; container.innerHTML = '<p style="color:var(--error-color); text-align:center;">Error al cargar las noticias.</p>';
@ -129,11 +127,25 @@ document.addEventListener('DOMContentLoaded', function() {
} }
} }
async function cargarNoticias(keepPage) {
if (!keepPage) setPage1();
const formData = new FormData(form);
const params = new URLSearchParams(formData);
const newUrl = `${form.action}?${params.toString()}`;
await cargarNoticiasFromURL(newUrl);
// Actualizar historial
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');
@ -153,12 +165,38 @@ 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);
}); });
paisSelect.addEventListener('change', function() {
cargarNoticias(false);
});
categoriaSelect.addEventListener('change', function() {
cargarNoticias(false);
});
fechaInput.addEventListener('change', function() {
cargarNoticias(false);
});
// Debounce búsqueda
let qTimer = null;
qInput.addEventListener('input', function() {
if (qTimer) clearTimeout(qTimer);
qTimer = setTimeout(() => {
cargarNoticias(false);
}, 450);
});
// Cargar países al inicio
filtrarPaises(); 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);
});
}); });
</script> </script>
{% endblock %} {% endblock %}