-- ========================================================================= -- Esquema de base de datos: TEMARIOS HISTÓRICOS -- Buscador Multisearch — Intervención 4 (extracción del temario) -- ========================================================================= -- -- PostgreSQL 14+. Usa la extensión pgvector para almacenar los -- embeddings semánticos de cada tema (permite comparar temarios entre -- convocatorias del mismo cuerpo). -- -- Cargar con: psql -d buscador -f 04_db_temarios_schema.sql -- ========================================================================= -- Extensión para embeddings vectoriales CREATE EXTENSION IF NOT EXISTS vector; CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; -- ========================================================================= -- TABLA: temarios (cabecera de cada temario extraído de unas bases) -- ========================================================================= CREATE TABLE temarios ( id_temario UUID PRIMARY KEY DEFAULT uuid_generate_v4(), -- Vinculación con el proceso del que provienen id_proceso VARCHAR(200) REFERENCES procesos(id_proceso) ON DELETE SET NULL, id_evento UUID REFERENCES eventos_proceso(id_evento) ON DELETE SET NULL, -- Identificadores normalizados (replicados aquí para búsquedas rápidas -- sin tener que hacer join con procesos) administracion_codigo VARCHAR(80) NOT NULL, cuerpo_codigo VARCHAR(80) NOT NULL, cuerpo_codigo_oficial VARCHAR(10), anio_convocatoria INTEGER NOT NULL, turno VARCHAR(40) NOT NULL DEFAULT 'libre', -- Origen boletin VARCHAR(40) NOT NULL, fecha_publicacion DATE NOT NULL, url_pdf TEXT, paginas_temario INT4RANGE, -- rango de páginas del PDF donde está el temario -- Datos estructurados extraídos del PDF de bases num_plazas_total INTEGER, num_plazas_libre INTEGER, num_plazas_promo_int INTEGER, num_plazas_discap INTEGER, num_plazas_discap_int INTEGER, sistema_selectivo VARCHAR(40), -- 'oposicion', 'concurso_oposicion', 'concurso', 'concurso_meritos' num_ejercicios SMALLINT, descripcion_ejercicios JSONB, -- [{"numero": 1, "tipo": "test", "preguntas": 100, "duracion_min": 120, ...}] tasa_examen NUMERIC(8,2), moneda_tasa VARCHAR(5) DEFAULT 'EUR', requisitos_titulacion TEXT[], requisitos_otros TEXT, tribunal_presidente TEXT, tribunal_secretario TEXT, tribunal_vocales JSONB, criterios_valoracion JSONB, -- estructura libre con los baremos extraídos del documento -- Temario completo num_temas_total INTEGER, num_temas_parte_general INTEGER, num_temas_parte_especifica INTEGER, num_temas_otras_partes INTEGER, -- Hash de la estructura del temario, útil para detectar -- si dos temarios son sustancialmente iguales (mismo cuerpo, -- convocatorias consecutivas que reusan el temario tal cual) hash_estructura VARCHAR(64), -- Estado del parser parser_ok BOOLEAN NOT NULL DEFAULT FALSE, parser_errores TEXT[], parser_version VARCHAR(20), -- Metadatos fecha_extraccion TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- Búsqueda full-text sobre todo el contenido del temario tsv TSVECTOR ); -- Índices CREATE INDEX idx_temarios_proceso ON temarios(id_proceso); CREATE INDEX idx_temarios_admin ON temarios(administracion_codigo); CREATE INDEX idx_temarios_cuerpo ON temarios(cuerpo_codigo); CREATE INDEX idx_temarios_anio ON temarios(anio_convocatoria DESC); CREATE INDEX idx_temarios_hash ON temarios(hash_estructura); CREATE INDEX idx_temarios_tsv ON temarios USING GIN(tsv); CREATE INDEX idx_temarios_datos_ejerc ON temarios USING GIN(descripcion_ejercicios); -- Restricción: un mismo proceso solo puede tener un temario "vivo" -- (correcciones de errores se manejan como nuevas filas con flag) CREATE UNIQUE INDEX idx_temarios_proceso_unico ON temarios(id_proceso) WHERE parser_ok = TRUE AND id_proceso IS NOT NULL; -- ========================================================================= -- TABLA: temas (cada tema individual del temario) -- ========================================================================= CREATE TABLE temas ( id_tema UUID PRIMARY KEY DEFAULT uuid_generate_v4(), id_temario UUID NOT NULL REFERENCES temarios(id_temario) ON DELETE CASCADE, -- Estructura parte VARCHAR(40) NOT NULL DEFAULT 'general', -- 'general', 'especifica', 'idiomas', 'practica', -- 'derecho_constitucional', 'derecho_administrativo', etc. numero INTEGER NOT NULL, numero_completo VARCHAR(20), -- "1.3", "II.5", etc. (si hay subnumeración) -- Contenido titulo TEXT NOT NULL, texto_completo TEXT, -- algunas bases listan solo títulos; otras incluyen párrafo -- descriptivo del tema. Si está, se guarda. -- Embedding semántico para diff entre temarios -- (dimensión 384 = sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2) embedding VECTOR(384), -- Metadatos fecha_creacion TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- Búsqueda full-text del título tsv TSVECTOR ); -- Índices CREATE INDEX idx_temas_temario ON temas(id_temario); CREATE INDEX idx_temas_parte ON temas(id_temario, parte, numero); CREATE INDEX idx_temas_tsv ON temas USING GIN(tsv); -- Índice vectorial para búsqueda por similaridad (KNN) CREATE INDEX idx_temas_embedding ON temas USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100); -- Trigger para tsv CREATE OR REPLACE FUNCTION temas_update_trigger() RETURNS TRIGGER AS $$ BEGIN NEW.tsv := to_tsvector('spanish', coalesce(NEW.titulo, '') || ' ' || coalesce(NEW.texto_completo, '') ); RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER temas_update_tsv BEFORE INSERT OR UPDATE ON temas FOR EACH ROW EXECUTE FUNCTION temas_update_trigger(); -- ========================================================================= -- TABLA: diff_temarios (resultado de comparar dos temarios) -- ========================================================================= CREATE TABLE diff_temarios ( id_diff UUID PRIMARY KEY DEFAULT uuid_generate_v4(), -- Los dos temarios comparados (mismo cuerpo, distinto año) id_temario_nuevo UUID NOT NULL REFERENCES temarios(id_temario) ON DELETE CASCADE, id_temario_anterior UUID NOT NULL REFERENCES temarios(id_temario) ON DELETE CASCADE, -- Resumen del diff num_temas_nuevos INTEGER NOT NULL DEFAULT 0, num_temas_eliminados INTEGER NOT NULL DEFAULT 0, num_temas_reformulados INTEGER NOT NULL DEFAULT 0, num_temas_identicos INTEGER NOT NULL DEFAULT 0, -- Métricas porcentaje_cambio NUMERIC(5,2), -- (nuevos + eliminados + reformulados) / total_anterior * 100 es_reescritura_total BOOLEAN NOT NULL DEFAULT FALSE, -- TRUE si > 70% del temario cambió: probablemente un cuerpo nuevo -- Detalle por tema (JSONB para flexibilidad) detalle JSONB, -- { -- "nuevos": [{"numero": 23, "titulo": "...", "parte": "especifica"}, ...], -- "eliminados": [{"numero": 17, "titulo": "...", "parte": "especifica"}, ...], -- "reformulados": [ -- { -- "numero_anterior": 5, "titulo_anterior": "...", -- "numero_nuevo": 5, "titulo_nuevo": "...", -- "similaridad": 0.78 -- }, ... -- ], -- "identicos": [{"numero": 1, "titulo": "..."}, ...] -- } -- Configuración usada en el diff umbral_identico NUMERIC(3,2) DEFAULT 0.95, umbral_reformulado NUMERIC(3,2) DEFAULT 0.70, modelo_embeddings VARCHAR(80), fecha_calculo TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX idx_diff_nuevo ON diff_temarios(id_temario_nuevo); CREATE INDEX idx_diff_anterior ON diff_temarios(id_temario_anterior); CREATE UNIQUE INDEX idx_diff_par_unico ON diff_temarios(id_temario_nuevo, id_temario_anterior); -- ========================================================================= -- VISTA: ultimos_temarios_por_cuerpo -- Para cada cuerpo, los dos temarios más recientes (útil para diff -- automático cada vez que se extrae uno nuevo). -- ========================================================================= CREATE OR REPLACE VIEW ultimos_temarios_por_cuerpo AS SELECT cuerpo_codigo, administracion_codigo, id_temario, anio_convocatoria, fecha_publicacion, num_temas_total, ROW_NUMBER() OVER ( PARTITION BY cuerpo_codigo, administracion_codigo ORDER BY fecha_publicacion DESC ) AS rn FROM temarios WHERE parser_ok = TRUE; -- ========================================================================= -- VISTA: temarios_para_diff_pendiente -- Temarios recién extraídos que tienen un temario anterior del mismo -- cuerpo pero todavía no se ha calculado el diff. El job nocturno -- procesa esta cola. -- ========================================================================= CREATE OR REPLACE VIEW temarios_para_diff_pendiente AS SELECT t_nuevo.id_temario AS id_temario_nuevo, t_anterior.id_temario AS id_temario_anterior, t_nuevo.cuerpo_codigo, t_nuevo.administracion_codigo, t_nuevo.anio_convocatoria AS anio_nuevo, t_anterior.anio_convocatoria AS anio_anterior FROM temarios t_nuevo JOIN temarios t_anterior ON t_nuevo.cuerpo_codigo = t_anterior.cuerpo_codigo AND t_nuevo.administracion_codigo = t_anterior.administracion_codigo AND t_anterior.anio_convocatoria < t_nuevo.anio_convocatoria AND t_anterior.parser_ok = TRUE LEFT JOIN diff_temarios d ON d.id_temario_nuevo = t_nuevo.id_temario AND d.id_temario_anterior = t_anterior.id_temario WHERE t_nuevo.parser_ok = TRUE AND d.id_diff IS NULL -- diff no calculado todavía AND t_anterior.anio_convocatoria = ( -- Solo comparar con el inmediatamente anterior, no con todos SELECT MAX(t2.anio_convocatoria) FROM temarios t2 WHERE t2.cuerpo_codigo = t_nuevo.cuerpo_codigo AND t2.administracion_codigo = t_nuevo.administracion_codigo AND t2.anio_convocatoria < t_nuevo.anio_convocatoria AND t2.parser_ok = TRUE ); -- ========================================================================= -- FUNCIÓN: buscar_temas_similares -- Dado un texto, devolver los N temas más similares semánticamente. -- Útil para encontrar "este tema, ¿de qué cuerpos forma parte?" -- ========================================================================= CREATE OR REPLACE FUNCTION buscar_temas_similares( query_embedding VECTOR(384), n_resultados INTEGER DEFAULT 10 ) RETURNS TABLE ( id_tema UUID, id_temario UUID, cuerpo_codigo VARCHAR(80), anio INTEGER, titulo TEXT, similaridad NUMERIC ) AS $$ BEGIN RETURN QUERY SELECT t.id_tema, t.id_temario, tem.cuerpo_codigo, tem.anio_convocatoria, t.titulo, (1 - (t.embedding <=> query_embedding))::NUMERIC AS similaridad FROM temas t JOIN temarios tem ON tem.id_temario = t.id_temario WHERE t.embedding IS NOT NULL ORDER BY t.embedding <=> query_embedding LIMIT n_resultados; END; $$ LANGUAGE plpgsql; -- ========================================================================= -- COMENTARIOS DE USO -- ========================================================================= -- -- Flujo típico cuando se detecta una FASE 2 (bases) en el buscador: -- -- 1. El parser (`04_parser_bases.py`) procesa el PDF y devuelve un -- objeto con todos los datos extraídos. -- 2. INSERT INTO temarios con la cabecera y los datos estructurados. -- 3. Para cada tema extraído, INSERT INTO temas con su número, parte -- y título. Los embeddings se calculan en un paso posterior -- (batch) con sentence-transformers. -- 4. La vista `temarios_para_diff_pendiente` señala que hay un diff -- pendiente con el temario anterior del mismo cuerpo. -- 5. Un job ejecuta `04_diff_temarios.py` que calcula el diff -- comparando embeddings y persiste en `diff_temarios`. -- 6. El email del día siguiente incluye el botón "Ver cambios vs -- temario anterior" enlazando al diff. -- -- Ejemplos de queries útiles: -- -- -- Último temario de cada cuerpo: -- SELECT * FROM ultimos_temarios_por_cuerpo WHERE rn = 1; -- -- -- Diffs pendientes para procesar esta noche: -- SELECT * FROM temarios_para_diff_pendiente; -- -- -- Temarios sin embeddings calculados: -- SELECT t.id_tema, t.titulo -- FROM temas t WHERE t.embedding IS NULL -- LIMIT 1000; -- -- -- Buscar temas relacionados con "procedimiento administrativo": -- -- (primero hay que calcular el embedding del query con el modelo) -- SELECT * FROM buscar_temas_similares( -- (SELECT calcular_embedding('procedimiento administrativo')), -- 20 -- );