Initial clean commit

This commit is contained in:
jlimolina 2026-01-13 13:39:51 +01:00
commit 6784d81c2c
141 changed files with 25219 additions and 0 deletions

3
models/__init__.py Normal file
View file

@ -0,0 +1,3 @@
# models/__init__.py
# Para que Python reconozca el directorio como paquete.

9
models/categorias.py Normal file
View file

@ -0,0 +1,9 @@
from psycopg2 import extras
from typing import List, Dict
def get_categorias(conn) -> List[Dict]:
with conn.cursor(cursor_factory=extras.DictCursor) as cur:
cur.execute("SELECT id, nombre FROM categorias ORDER BY nombre;")
return cur.fetchall()

337
models/describe.txt Normal file
View file

@ -0,0 +1,337 @@
models/
├── __init__.py # Paquete Python (vacío)
├── categorias.py # Operaciones con categorías
├── feeds.py # Operaciones con feeds RSS
├── noticias.py # Búsqueda y consulta de noticias
└── paises.py # Operaciones con países
└── traducciones.py # Operaciones con traducciones
init.py
Propósito: Archivo necesario para que Python reconozca este directorio como un paquete.
Contenido: Vacío o comentario explicativo.
Uso: Permite importar módulos desde models:
python
from models.noticias import buscar_noticias
categorias.py
Propósito: Maneja todas las operaciones relacionadas con categorías de noticias.
Funciones principales:
get_categorias(conn) -> List[Dict]
Descripción: Obtiene todas las categorías disponibles ordenadas alfabéticamente.
Parámetros:
conn: Conexión a PostgreSQL activa
Consulta SQL:
sql
SELECT id, nombre FROM categorias ORDER BY nombre;
Retorna: Lista de diccionarios con estructura:
python
[
{"id": 1, "nombre": "Política"},
{"id": 2, "nombre": "Deportes"},
...
]
Uso típico: Para llenar dropdowns de filtrado en la interfaz web.
feeds.py
Propósito: Maneja operaciones relacionadas con feeds RSS.
Funciones principales:
get_feed_by_id(conn, feed_id: int) -> Optional[Dict]
Descripción: Obtiene un feed específico por su ID.
Parámetros:
conn: Conexión a PostgreSQL
feed_id: ID numérico del feed
Consulta SQL:
sql
SELECT * FROM feeds WHERE id = %s;
Retorna: Un diccionario con todos los campos del feed o None si no existe.
get_feeds_activos(conn) -> List[Dict]
Descripción: Obtiene todos los feeds activos y no caídos.
Criterios de activos:
activo = TRUE
fallos < 5 (o NULL)
Consulta SQL:
sql
SELECT id, nombre, url, categoria_id, pais_id, fallos, activo
FROM feeds
WHERE activo = TRUE
AND (fallos IS NULL OR fallos < 5)
ORDER BY id;
Retorna: Lista de feeds activos para el ingestor RSS.
Uso crítico: Esta función es utilizada por rss_ingestor.py para determinar qué feeds procesar.
noticias.py
Propósito: Módulo más complejo que maneja todas las operaciones de búsqueda y consulta de noticias.
Funciones auxiliares:
_extraer_tags_por_traduccion(cur, traduccion_ids: List[int]) -> Dict[int, List[tuple]]
Descripción: Función privada que obtiene tags agrupados por ID de traducción.
Parámetros:
cur: Cursor de base de datos
traduccion_ids: Lista de IDs de traducciones
Consulta SQL:
sql
SELECT tn.traduccion_id, tg.valor, tg.tipo
FROM tags_noticia tn
JOIN tags tg ON tg.id = tn.tag_id
WHERE tn.traduccion_id = ANY(%s);
Retorna: Diccionario donde:
Clave: traduccion_id
Valor: Lista de tuplas (valor_tag, tipo_tag)
Optimización: Evita el problema N+1 al cargar tags.
Funciones principales:
buscar_noticias(...) -> Tuple[List[Dict], int, int, Dict]
Descripción: Búsqueda avanzada con múltiples filtros, paginación y traducciones.
Parámetros:
conn: Conexión a PostgreSQL
page: Número de página (1-based)
per_page: Noticias por página
q: Término de búsqueda (opcional)
categoria_id: Filtrar por categoría (opcional)
continente_id: Filtrar por continente (opcional)
pais_id: Filtrar por país (opcional)
fecha: Filtrar por fecha exacta YYYY-MM-DD (opcional)
lang: Idioma objetivo para traducciones (default: "es")
use_tr: Incluir traducciones en búsqueda (default: True)
Retorna: Tupla con 4 elementos:
noticias: Lista de noticias con datos completos
total_results: Total de resultados (sin paginación)
total_pages: Total de páginas calculado
tags_por_tr: Diccionario de tags por traducción
Características de búsqueda:
Filtrado por fecha: Coincidencia exacta de fecha
Filtrado geográfico: País o continente (jerárquico)
Filtrado por categoría: Selección única
Búsqueda de texto:
Búsqueda full-text con PostgreSQL (websearch_to_tsquery)
Búsqueda ILIKE en múltiples campos
Incluye campos originales y traducidos
Paginación: Offset/Limit estándar
Traducciones: JOIN condicional con tabla traducciones
Optimización: Single query para contar y obtener datos
Consulta SQL principal (simplificada):
sql
-- Contar total
SELECT COUNT(DISTINCT n.id)
FROM noticias n
-- joins con categorias, paises, traducciones
WHERE [condiciones dinámicas]
-- Obtener datos paginados
SELECT
n.id, n.titulo, n.resumen, n.url, n.fecha,
n.imagen_url, n.fuente_nombre,
c.nombre AS categoria,
p.nombre AS pais,
t.id AS traduccion_id,
t.titulo_trad AS titulo_traducido,
t.resumen_trad AS resumen_traducido,
-- flag de traducción disponible
CASE WHEN t.id IS NOT NULL THEN TRUE ELSE FALSE END AS tiene_traduccion,
-- campos originales
n.titulo AS titulo_original,
n.resumen AS resumen_original
FROM noticias n
-- joins...
WHERE [condiciones dinámicas]
ORDER BY n.fecha DESC NULLS LAST, n.id DESC
LIMIT %s OFFSET %s
Campos retornados por noticia:
python
{
"id": 123,
"titulo": "Título original",
"resumen": "Resumen original",
"url": "https://ejemplo.com/noticia",
"fecha": datetime(...),
"imagen_url": "https://.../imagen.jpg",
"fuente_nombre": "BBC News",
"categoria": "Política",
"pais": "España",
"traduccion_id": 456, # o None
"titulo_traducido": "Título en español",
"resumen_traducido": "Resumen en español",
"tiene_traduccion": True, # o False
"titulo_original": "Original title",
"resumen_original": "Original summary"
}
Uso en la aplicación: Esta función es el corazón de la búsqueda en la web, utilizada por los blueprints de Flask.
paises.py
Propósito: Maneja operaciones relacionadas con países.
Funciones principales:
get_paises(conn) -> List[Dict]
Descripción: Obtiene todos los países ordenados alfabéticamente.
Parámetros:
conn: Conexión a PostgreSQL
Consulta SQL:
sql
SELECT id, nombre FROM paises ORDER BY nombre;
Retorna: Lista de diccionarios con id y nombre de cada país.
Uso típico: Para dropdowns de filtrado por país en la interfaz web.
traducciones.py
Propósito: Maneja operaciones relacionadas con traducciones específicas.
Funciones principales:
get_traduccion(conn, traduccion_id: int) -> Optional[Dict]
Descripción: Obtiene una traducción específica por su ID.
Parámetros:
conn: Conexión a PostgreSQL
traduccion_id: ID numérico de la traducción
Consulta SQL:
sql
SELECT * FROM traducciones WHERE id = %s;
Retorna: Diccionario con todos los campos de la traducción o None.
Campos incluidos: id, noticia_id, lang_from, lang_to, titulo_trad, resumen_trad, status, error, created_at, etc.
Uso típico: Para páginas de detalle de traducciones o debugging.
Patrones de Diseño Observados
1. Separación de Responsabilidades
Cada archivo maneja una entidad específica de la base de datos
Lógica de consultas separada de lógica de negocio
2. Interfaz Consistente
Todas las funciones reciben conn como primer parámetro
Retornan diccionarios (usando DictCursor)
Nombres descriptivos y consistentes
3. Optimización de Consultas
Uso de _extraer_tags_por_traduccion para evitar N+1 queries
Consultas COUNT y SELECT en la misma transacción
Índices implícitos en ORDER BY fecha DESC
4. Manejo de Traducciones
JOIN condicional con tabla traducciones
Flag tiene_traduccion para fácil verificación en frontend
Campos originales siempre disponibles como fallback
5. Seguridad
Uso de parámetros preparados (%s)
No concatenación directa de strings en SQL
Validación implícita de tipos
Flujo de Datos Típico
python
# En un blueprint de Flask
from db import get_conn
from models.noticias import buscar_noticias
def ruta_buscar():
conn = get_conn()
try:
noticias, total, paginas, tags = buscar_noticias(
conn=conn,
page=request.args.get('page', 1, type=int),
per_page=20,
q=request.args.get('q', ''),
categoria_id=request.args.get('categoria_id'),
pais_id=request.args.get('pais_id'),
lang='es'
)
# Procesar resultados...
finally:
conn.close()
Dependencias y Relaciones
Requisito: psycopg2.extras.DictCursor para retornar diccionarios
Usado por: Todos los blueprints en routers/
Base de datos: Asume estructura de tablas específica (feeds, noticias, traducciones, etc.)
Índices necesarios: Para optimizar búsquedas, se recomiendan índices en:
noticias(fecha DESC, id DESC)
traducciones(noticia_id, lang_to, status)
feeds(activo, fallos)

24
models/feeds.py Normal file
View file

@ -0,0 +1,24 @@
from psycopg2 import extras
from typing import List, Dict, Optional
def get_feed_by_id(conn, feed_id: int) -> Optional[Dict]:
with conn.cursor(cursor_factory=extras.DictCursor) as cur:
cur.execute("SELECT * FROM feeds WHERE id = %s;", (feed_id,))
return cur.fetchone()
def get_feeds_activos(conn) -> List[Dict]:
"""Feeds activos y no caídos, usados por el ingestor RSS."""
with conn.cursor(cursor_factory=extras.DictCursor) as cur:
cur.execute(
"""
SELECT id, nombre, url, categoria_id, pais_id, fallos, activo
FROM feeds
WHERE activo = TRUE
AND (fallos IS NULL OR fallos < 5)
ORDER BY id;
"""
)
return cur.fetchall()

282
models/noticias.py Normal file
View file

@ -0,0 +1,282 @@
from psycopg2 import extras
from typing import List, Dict, Optional, Tuple, Any
import os
import torch
from sentence_transformers import SentenceTransformer
def _extraer_tags_por_traduccion(cur, traduccion_ids: List[int]) -> Dict[int, List[tuple]]:
"""Obtiene tags agrupados por traducción."""
tags_por_tr = {}
if not traduccion_ids:
return tags_por_tr
cur.execute(
"""
SELECT tn.traduccion_id, tg.valor, tg.tipo
FROM tags_noticia tn
JOIN tags tg ON tg.id = tn.tag_id
WHERE tn.traduccion_id = ANY(%s);
""",
(traduccion_ids,),
)
rows = cur.fetchall()
for tr_id, valor, tipo in rows:
tags_por_tr.setdefault(tr_id, []).append((valor, tipo))
return tags_por_tr
def buscar_noticias(
conn,
page: int,
per_page: int,
q: str = "",
categoria_id: Optional[str] = None,
continente_id: Optional[str] = None,
pais_id: Optional[str] = None,
fecha: Optional[str] = None,
lang: str = "es",
use_tr: bool = True,
skip_count: bool = False,
) -> Tuple[List[Dict], int, int, Dict]:
"""
Búsqueda avanzada de noticias con filtros:
- fecha
- país / continente
- categoría
- búsqueda fulltext + ILIKE
- traducciones
- paginación
"""
offset = (page - 1) * per_page
where = ["1=1"]
params = []
# Filtro por fecha exacta
if fecha:
where.append("n.fecha::date = %s")
params.append(fecha)
# Categoría
if categoria_id:
where.append("n.categoria_id = %s")
params.append(int(categoria_id))
# País o continente
if pais_id:
where.append("n.pais_id = %s")
params.append(int(pais_id))
elif continente_id:
where.append("p.continente_id = %s")
params.append(int(continente_id))
# Búsqueda
if q:
search_like = f"%{q}%"
if use_tr:
where.append(
"""
(
n.tsv @@ websearch_to_tsquery('spanish', %s)
OR t.titulo_trad ILIKE %s
OR t.resumen_trad ILIKE %s
OR n.titulo ILIKE %s
OR n.resumen ILIKE %s
)
"""
)
params.extend([q, search_like, search_like, search_like, search_like])
else:
where.append(
"""
(
n.tsv @@ websearch_to_tsquery('spanish', %s)
OR n.titulo ILIKE %s
OR n.resumen ILIKE %s
)
"""
)
params.extend([q, search_like, search_like])
where_sql = " AND ".join(where)
with conn.cursor(cursor_factory=extras.DictCursor) as cur:
# =====================================================================
# TOTAL DE RESULTADOS (OPTIMIZADO)
# =====================================================================
total_results = 0
total_pages = 0
if not skip_count:
# Si no hay filtros de búsqueda de texto ni filtros complejos, usar estimación rápida
if not q and not categoria_id and not pais_id and not continente_id and not fecha:
cur.execute("SELECT reltuples::bigint FROM pg_class WHERE relname = 'noticias'")
row = cur.fetchone()
total_results = row[0] if row else 0
else:
# Conteo exacto si hay filtros (necesario para paginación filtrada)
cur.execute(
f"""
SELECT COUNT(n.id)
FROM noticias n
LEFT JOIN categorias c ON c.id = n.categoria_id
LEFT JOIN paises p ON p.id = n.pais_id
LEFT JOIN traducciones t
ON t.noticia_id = n.id
AND t.lang_to = %s
AND t.status = 'done'
WHERE {where_sql}
""",
[lang] + params,
)
total_results = cur.fetchone()[0]
total_pages = (total_results // per_page) + (1 if total_results % per_page else 0)
# =====================================================================
# LISTA DE NOTICIAS PAGINADAS
# =====================================================================
cur.execute(
f"""
SELECT
n.id,
n.titulo,
n.resumen,
n.url,
n.fecha,
n.imagen_url,
n.fuente_nombre,
c.nombre AS categoria,
p.nombre AS pais,
-- traducciones
t.id AS traduccion_id,
t.titulo_trad AS titulo_traducido,
t.resumen_trad AS resumen_traducido,
CASE WHEN t.id IS NOT NULL THEN TRUE ELSE FALSE END AS tiene_traduccion,
-- originales
n.titulo AS titulo_original,
n.resumen AS resumen_original
FROM noticias n
LEFT JOIN categorias c ON c.id = n.categoria_id
LEFT JOIN paises p ON p.id = n.pais_id
LEFT JOIN traducciones t
ON t.noticia_id = n.id
AND t.lang_to = %s
AND t.status = 'done'
WHERE {where_sql}
ORDER BY n.fecha DESC NULLS LAST, n.id DESC
LIMIT %s OFFSET %s
""",
[lang] + params + [per_page, offset],
)
noticias = cur.fetchall()
# =====================================================================
# TAGS POR TRADUCCIÓN
# =====================================================================
tr_ids = [n["traduccion_id"] for n in noticias if n["traduccion_id"]]
tags_por_tr = _extraer_tags_por_traduccion(cur, tr_ids)
return noticias, total_results, total_pages, tags_por_tr
# Cache del modelo para no cargarlo en cada petición
_model_cache = {}
def _get_emb_model():
model_name = os.environ.get("EMB_MODEL", "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2")
if model_name not in _model_cache:
device = "cuda" if torch.cuda.is_available() else "cpu"
_model_cache[model_name] = SentenceTransformer(model_name, device=device)
return _model_cache[model_name], model_name
def buscar_noticias_semantica(
conn,
page: int,
per_page: int,
q: str,
categoria_id: Optional[str] = None,
continente_id: Optional[str] = None,
pais_id: Optional[str] = None,
fecha: Optional[str] = None,
lang: str = "es",
) -> Tuple[List[Dict], int, int, Dict]:
"""
Búsqueda semántica usando embeddings y similitud coseno (vía producto punto si están normalizados).
"""
if not q.strip():
return buscar_noticias(conn, page, per_page, "", categoria_id, continente_id, pais_id, fecha, lang)
offset = (page - 1) * per_page
model, model_name = _get_emb_model()
# Generar embedding de la consulta
q_emb = model.encode([q], normalize_embeddings=True)[0].tolist()
where = ["t.status = 'done'", "t.lang_to = %s"]
params = [lang]
if fecha:
where.append("n.fecha::date = %s")
params.append(fecha)
if categoria_id:
where.append("n.categoria_id = %s")
params.append(int(categoria_id))
if pais_id:
where.append("n.pais_id = %s")
params.append(int(pais_id))
elif continente_id:
where.append("p.continente_id = %s")
params.append(int(continente_id))
where_sql = " AND ".join(where)
with conn.cursor(cursor_factory=extras.DictCursor) as cur:
# Consulta de búsqueda vectorial (usamos un array_agg o similar para el producto punto si no hay pgvector)
# Nota: Aquí asumo que usamos producto punto entre arrays de double precision
query_sql = f"""
WITH similarity AS (
SELECT
te.traduccion_id,
(
SELECT SUM(a*b)
FROM unnest(te.embedding, %s::double precision[]) AS t(a,b)
) AS score
FROM traduccion_embeddings te
WHERE te.model = %s
)
SELECT
n.id, n.titulo, n.resumen, n.url, n.fecha, n.imagen_url, n.fuente_nombre,
c.nombre AS categoria, p.nombre AS pais,
t.id AS traduccion_id, t.titulo_trad AS titulo_traducido, t.resumen_trad AS resumen_traducido,
TRUE AS tiene_traduccion, s.score
FROM similarity s
JOIN traducciones t ON t.id = s.traduccion_id
JOIN noticias n ON n.id = t.noticia_id
LEFT JOIN categorias c ON c.id = n.categoria_id
LEFT JOIN paises p ON p.id = n.pais_id
WHERE {where_sql}
ORDER BY n.fecha DESC NULLS LAST, s.score DESC
LIMIT %s OFFSET %s
"""
# Para el conteo total en semántica podemos simplificar o usar el mismo WHERE
cur.execute(f"SELECT COUNT(*) FROM traducciones t JOIN noticias n ON n.id = t.noticia_id LEFT JOIN paises p ON p.id = n.pais_id WHERE {where_sql}", params)
total_results = cur.fetchone()[0]
total_pages = (total_results // per_page) + (1 if total_results % per_page else 0)
cur.execute(query_sql, [q_emb, model_name] + params + [per_page, offset])
noticias = cur.fetchall()
tr_ids = [n["traduccion_id"] for n in noticias]
tags_por_tr = _extraer_tags_por_traduccion(cur, tr_ids)
return noticias, total_results, total_pages, tags_por_tr

9
models/paises.py Normal file
View file

@ -0,0 +1,9 @@
from typing import List, Dict
from psycopg2 import extras
def get_paises(conn) -> List[Dict]:
with conn.cursor(cursor_factory=extras.DictCursor) as cur:
cur.execute("SELECT id, nombre FROM paises ORDER BY nombre;")
return cur.fetchall()

16
models/traducciones.py Normal file
View file

@ -0,0 +1,16 @@
from psycopg2 import extras
from typing import Optional, Dict
def get_traduccion(conn, traduccion_id: int) -> Optional[Dict]:
with conn.cursor(cursor_factory=extras.DictCursor) as cur:
cur.execute(
"""
SELECT *
FROM traducciones
WHERE id = %s;
""",
(traduccion_id,),
)
return cur.fetchone()