Mejoras: NER, embeddings, dashboard, docker-compose y limpieza
This commit is contained in:
parent
6c5aff9936
commit
d508dc2058
19 changed files with 2218 additions and 1185 deletions
44
Dockerfile
44
Dockerfile
|
|
@ -1,49 +1,59 @@
|
||||||
|
# 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 ; \
|
||||||
else \
|
else \
|
||||||
pip install --no-cache-dir \
|
pip install --no-cache-dir \
|
||||||
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
|
# 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 usará 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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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!"
|
|
||||||
16
categorias.csv
Normal file
16
categorias.csv
Normal 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,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
7
continentes.csv
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
id,nombre
|
||||||
|
1,África
|
||||||
|
2,América
|
||||||
|
3,Asia
|
||||||
|
4,Europa
|
||||||
|
5,Oceanía
|
||||||
|
6,Antártida
|
||||||
|
|
|
@ -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;
|
|
||||||
|
|
||||||
|
|
@ -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
161
embeddings_worker.py
Normal 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
81
init-db/08-embeddings.sql
Normal 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';
|
||||||
|
|
||||||
62
migrations/001_utils_normalize_url.sql
Normal file
62
migrations/001_utils_normalize_url.sql
Normal 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;
|
||||||
|
$$;
|
||||||
|
|
||||||
38
migrations/002_unique_index_url_norm.sql
Normal file
38
migrations/002_unique_index_url_norm.sql
Normal 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;
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -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
196
paises.csv
Normal 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
|
||||||
|
198
paises.sql
198
paises.sql
|
|
@ -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
206
related_worker.py
Normal 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()
|
||||||
|
|
||||||
|
|
@ -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
111
templates/noticia.html
Normal 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 %}
|
||||||
|
|
||||||
|
|
@ -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 %}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue