This commit is contained in:
jlimolina 2025-11-24 23:06:26 +01:00
parent 86ee083b90
commit e3a99d9604
8 changed files with 489 additions and 483 deletions

View file

@ -6,6 +6,7 @@ from typing import List, Dict, Any, Optional
import numpy as np
import psycopg2
import psycopg2.extras
from psycopg2.extras import Json
logging.basicConfig(
level=logging.INFO,
@ -43,29 +44,12 @@ def get_conn():
def ensure_schema(conn):
"""
Asegura que la tabla de eventos y las columnas necesarias existen.
Aquí se asume el esquema original de eventos con centroid JSONB.
Asumimos que las tablas y columnas (eventos, traducciones.evento_id,
eventos_noticias, función/trigger) ya existen por los scripts init-db.
Aquí solo nos aseguramos de que existan ciertos índices clave
(idempotente).
"""
with conn.cursor() as cur:
cur.execute(
"""
CREATE TABLE IF NOT EXISTS eventos (
id SERIAL PRIMARY KEY,
creado_en TIMESTAMP NOT NULL DEFAULT NOW(),
actualizado_en TIMESTAMP NOT NULL DEFAULT NOW(),
centroid JSONB NOT NULL,
total_traducciones INTEGER NOT NULL DEFAULT 1
);
"""
)
cur.execute(
"""
ALTER TABLE traducciones
ADD COLUMN IF NOT EXISTS evento_id INTEGER REFERENCES eventos(id);
"""
)
cur.execute(
"""
CREATE INDEX IF NOT EXISTS idx_traducciones_evento
@ -78,27 +62,6 @@ def ensure_schema(conn):
ON traducciones(evento_id, noticia_id);
"""
)
cur.execute(
"""
CREATE OR REPLACE FUNCTION actualizar_evento_modificado()
RETURNS TRIGGER AS $$
BEGIN
NEW.actualizado_en = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
"""
)
cur.execute("DROP TRIGGER IF EXISTS trg_evento_modificado ON eventos;")
cur.execute(
"""
CREATE TRIGGER trg_evento_modificado
BEFORE UPDATE ON eventos
FOR EACH ROW
EXECUTE FUNCTION actualizar_evento_modificado();
"""
)
conn.commit()
@ -161,6 +124,7 @@ def fetch_embeddings_for(conn, tr_ids: List[int]) -> Dict[int, np.ndarray]:
def fetch_centroids(conn) -> List[Dict[str, Any]]:
"""
Carga todos los centroides actuales desde eventos.
Solo usamos campos de clustering: id, centroid, total_traducciones.
"""
with conn.cursor(cursor_factory=psycopg2.extras.DictCursor) as cur:
cur.execute(
@ -178,6 +142,7 @@ def fetch_centroids(conn) -> List[Dict[str, Any]]:
raw = r["centroid"]
cnt = int(r["total_traducciones"] or 1)
if not isinstance(raw, (list, tuple)):
# centroid se almacena como JSONB array → en Python suele llegar como list
continue
arr = np.array([float(x or 0.0) for x in raw], dtype="float32")
if arr.size == 0:
@ -201,6 +166,54 @@ def cosine_distance(a: np.ndarray, b: np.ndarray) -> float:
return 1.0 - cos
def fetch_traduccion_info(conn, tr_id: int) -> Optional[Dict[str, Any]]:
"""
Devuelve info básica para un tr_id:
- noticia_id
- fecha de la noticia
- un título representativo para el evento (traducido u original).
"""
with conn.cursor(cursor_factory=psycopg2.extras.DictCursor) as cur:
cur.execute(
"""
SELECT
t.id AS traduccion_id,
t.noticia_id AS noticia_id,
n.fecha AS fecha,
COALESCE(NULLIF(t.titulo_trad, ''), n.titulo) AS titulo_evento
FROM traducciones t
JOIN noticias n ON n.id = t.noticia_id
WHERE t.id = %s;
""",
(tr_id,),
)
row = cur.fetchone()
if not row:
return None
return {
"traduccion_id": int(row["traduccion_id"]),
"noticia_id": row["noticia_id"],
"fecha": row["fecha"],
"titulo_evento": row["titulo_evento"],
}
def _insert_evento_noticia(cur, evento_id: int, info: Dict[str, Any]) -> None:
"""
Inserta relación en eventos_noticias (idempotente).
"""
if not info or not info.get("noticia_id"):
return
cur.execute(
"""
INSERT INTO eventos_noticias (evento_id, noticia_id, traduccion_id)
VALUES (%s, %s, %s)
ON CONFLICT (evento_id, traduccion_id) DO NOTHING;
""",
(evento_id, info["noticia_id"], info["traduccion_id"]),
)
def assign_to_event(
conn,
tr_id: int,
@ -210,31 +223,63 @@ def assign_to_event(
"""
Asigna una traducción a un evento existente (si distancia <= umbral)
o crea un evento nuevo con este vector como centroide.
"""
from psycopg2.extras import Json
Además:
- Actualiza fecha_inicio, fecha_fin, n_noticias del evento.
- Rellena eventos_noticias (evento_id, noticia_id, traduccion_id).
"""
if vec is None or vec.size == 0:
return
info = fetch_traduccion_info(conn, tr_id)
# Si no hay centroides todavía → primer evento
if not centroids:
centroid_list = [float(x) for x in vec.tolist()]
with conn.cursor() as cur:
cur.execute(
"""
INSERT INTO eventos (centroid, total_traducciones)
VALUES (%s, %s)
RETURNING id;
""",
(Json(centroid_list), 1),
)
if info and info.get("fecha"):
cur.execute(
"""
INSERT INTO eventos (centroid, total_traducciones,
fecha_inicio, fecha_fin, n_noticias, titulo)
VALUES (%s, %s, %s, %s, %s, %s)
RETURNING id;
""",
(
Json(centroid_list),
1,
info["fecha"],
info["fecha"],
1,
info.get("titulo_evento"),
),
)
else:
# Fallback mínimo si no hay info de noticia
cur.execute(
"""
INSERT INTO eventos (centroid, total_traducciones)
VALUES (%s, %s)
RETURNING id;
""",
(Json(centroid_list), 1),
)
new_id = cur.fetchone()[0]
# Vincular traducción al evento
cur.execute(
"UPDATE traducciones SET evento_id = %s WHERE id = %s;",
(new_id, tr_id),
)
# Rellenar tabla de relación
_insert_evento_noticia(cur, new_id, info or {})
centroids.append({"id": new_id, "vec": vec.copy(), "n": 1})
return
# Buscar el centroide más cercano
best_idx: Optional[int] = None
best_dist: float = 1.0
@ -244,47 +289,98 @@ def assign_to_event(
best_dist = d
best_idx = i
if best_idx is not None and best_dist <= EVENT_DIST_THRESHOLD:
c = centroids[best_idx]
n_old = c["n"]
new_n = n_old + 1
new_vec = (c["vec"] * n_old + vec) / float(new_n)
with conn.cursor() as cur:
# Asignar a evento existente si está por debajo del umbral
if best_idx is not None and best_dist <= EVENT_DIST_THRESHOLD:
c = centroids[best_idx]
n_old = c["n"]
new_n = n_old + 1
new_vec = (c["vec"] * n_old + vec) / float(new_n)
c["vec"] = new_vec
c["n"] = new_n
c["vec"] = new_vec
c["n"] = new_n
centroid_list = [float(x) for x in new_vec.tolist()]
with conn.cursor() as cur:
cur.execute(
"""
UPDATE eventos
SET centroid = %s,
total_traducciones = total_traducciones + 1
WHERE id = %s;
""",
(Json(centroid_list), c["id"]),
)
centroid_list = [float(x) for x in new_vec.tolist()]
if info and info.get("fecha"):
cur.execute(
"""
UPDATE eventos
SET centroid = %s,
total_traducciones = total_traducciones + 1,
fecha_inicio = COALESCE(LEAST(fecha_inicio, %s), %s),
fecha_fin = COALESCE(GREATEST(fecha_fin, %s), %s),
n_noticias = n_noticias + 1
WHERE id = %s;
""",
(
Json(centroid_list),
info["fecha"],
info["fecha"],
info["fecha"],
info["fecha"],
c["id"],
),
)
else:
# Sin info de fecha: solo actualizamos centroid/contador
cur.execute(
"""
UPDATE eventos
SET centroid = %s,
total_traducciones = total_traducciones + 1
WHERE id = %s;
""",
(Json(centroid_list), c["id"]),
)
# Vincular traducción y relación
cur.execute(
"UPDATE traducciones SET evento_id = %s WHERE id = %s;",
(c["id"], tr_id),
)
return
_insert_evento_noticia(cur, c["id"], info or {})
return
# Si no hay evento adecuado → crear uno nuevo
centroid_list = [float(x) for x in vec.tolist()]
if info and info.get("fecha"):
cur.execute(
"""
INSERT INTO eventos (centroid, total_traducciones,
fecha_inicio, fecha_fin, n_noticias, titulo)
VALUES (%s, %s, %s, %s, %s, %s)
RETURNING id;
""",
(
Json(centroid_list),
1,
info["fecha"],
info["fecha"],
1,
info.get("titulo_evento"),
),
)
else:
cur.execute(
"""
INSERT INTO eventos (centroid, total_traducciones)
VALUES (%s, %s)
RETURNING id;
""",
(Json(centroid_list), 1),
)
centroid_list = [float(x) for x in vec.tolist()]
with conn.cursor() as cur:
cur.execute(
"""
INSERT INTO eventos (centroid, total_traducciones)
VALUES (%s, %s)
RETURNING id;
""",
(Json(centroid_list), 1),
)
new_id = cur.fetchone()[0]
cur.execute(
"UPDATE traducciones SET evento_id = %s WHERE id = %s;",
(new_id, tr_id),
)
_insert_evento_noticia(cur, new_id, info or {})
centroids.append({"id": new_id, "vec": vec.copy(), "n": 1})
@ -309,11 +405,16 @@ def main():
time.sleep(EVENT_SLEEP_IDLE)
continue
log.info("Traducciones pendientes de asignar evento: %d", len(pending_ids))
log.info(
"Traducciones pendientes de asignar evento: %d",
len(pending_ids),
)
emb_by_tr = fetch_embeddings_for(conn, pending_ids)
if not emb_by_tr:
log.warning("No se encontraron embeddings para las traducciones pendientes.")
log.warning(
"No se encontraron embeddings para las traducciones pendientes."
)
time.sleep(EVENT_SLEEP_IDLE)
continue
@ -329,7 +430,10 @@ def main():
processed += 1
conn.commit()
log.info("Asignación de eventos completada. Traducciones procesadas: %d", processed)
log.info(
"Asignación de eventos completada. Traducciones procesadas: %d",
processed,
)
except Exception as e:
log.exception("Error en cluster_worker: %s", e)