File size: 14,405 Bytes
476c707 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 | """
utils.py β Funciones utilitarias reutilizables para el buscador de jurisprudencia.
Contiene toda la lΓ³gica de negocio desacoplada de la interfaz:
β’ Carga de datos (descarga HF, procesamiento de DataFrames, conexiΓ³n ChromaDB)
β’ BΓΊsqueda semΓ‘ntica genΓ©rica
β’ Renderizado de resultados en HTML (con tΓtulo y campos extra configurables)
"""
from __future__ import annotations
import os
from typing import Any
import chromadb
import pandas as pd
from huggingface_hub import snapshot_download
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# 1. CARGA DE DATOS
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
def load_database_assets(db_config: dict) -> dict:
"""
Descarga el dataset desde Hugging Face, procesa los DataFrames de textos
y metadata, y conecta con las colecciones de ChromaDB.
ParΓ‘metros
----------
db_config : dict
Diccionario de configuraciΓ³n de una base de datos individual
(una entrada del diccionario DATABASES de config.py).
Retorna
-------
dict con las claves:
- "metadata" : pd.DataFrame con la metadata indexada por ID compuesto.
- "collections" : lista de objetos chromadb.Collection.
- "field_map" : dict con el mapeo de campos internos β columnas reales.
"""
print(f" Descargando dataset: {db_config['repo_id']}...")
dataset_path = snapshot_download(
repo_id=db_config["repo_id"],
repo_type=db_config.get("repo_type", "dataset"),
)
# --- Cargar textos y metadata ---
print(f" Cargando archivos: {db_config['pickle_file']}, {db_config['csv_file']}...")
textos = pd.read_pickle(os.path.join(dataset_path, db_config["pickle_file"]))
metadata = pd.read_csv(os.path.join(dataset_path, db_config["csv_file"]))
# Eliminar columnas innecesarias (si las hay)
drop_cols = db_config.get("drop_columns", [])
existing_drop_cols = [c for c in drop_cols if c in metadata.columns]
if existing_drop_cols:
metadata = metadata.drop(columns=existing_drop_cols)
# Merge de textos con metadata
merge_key = db_config.get("merge_key", "ID")
metadata = pd.merge(textos, metadata, on=merge_key, how="inner")
# Crear ID compuesto: ID_Page
id_col = db_config.get("id_column", "ID")
page_col = db_config.get("page_column", "Page")
metadata[id_col] = metadata[id_col].astype(str) + "_" + textos[page_col].astype(str)
# Indexar para bΓΊsqueda rΓ‘pida
metadata.set_index(id_col, inplace=True)
# --- Conectar a ChromaDB ---
chroma_dir = os.path.join(dataset_path, db_config["chroma_subdir"])
print(f" Conectando a ChromaDB en: {chroma_dir}...")
client = chromadb.PersistentClient(path=chroma_dir)
collection_names = db_config.get("collection_names", [])
collections = [client.get_collection(name) for name in collection_names]
print(f" β Base de datos '{db_config.get('display_name', db_config['repo_id'])}' lista.")
return {
"metadata": metadata,
"collections": collections,
"field_map": db_config.get("field_map", {}),
}
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# 2. BΓSQUEDA SEMΓNTICA
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
def _safe_get(row: Any, column: str | None, default: str = "N/A") -> Any:
"""
Obtiene de forma segura el valor de una columna en una fila de DataFrame,
manejando tanto Series como filas individuales.
Retorna *default* si column es None o no existe.
"""
if column is None:
return default
try:
if hasattr(row, "get"):
return row.get(column, default)
return row[column]
except (KeyError, TypeError):
return default
def perform_search(
query_embedding: list[float],
collections: list,
metadata_df: pd.DataFrame,
field_map: dict,
n_results: int = 15,
) -> list[dict]:
"""
Ejecuta la bΓΊsqueda semΓ‘ntica en las colecciones de ChromaDB y enriquece
los resultados con la metadata del DataFrame.
ParΓ‘metros
----------
query_embedding : list[float]
Vector de embedding de la consulta del usuario.
collections : list
Lista de colecciones de ChromaDB donde buscar.
metadata_df : pd.DataFrame
DataFrame de metadata indexado por ID compuesto.
field_map : dict
Mapeo de campos internos estandarizados β nombres reales de columnas.
Claves esperadas:
"enlace" β columna con el link
"text" β columna con el texto principal
"date" β columna con la fecha
"page" β columna con la pΓ‘gina
"titulo" β columna con el tΓtulo (str o None)
"extra_fields" β lista de tuplas (etiqueta, columna)
n_results : int
NΓΊmero mΓ‘ximo de resultados a retornar.
Retorna
-------
list[dict]
Lista de diccionarios con los resultados ordenados por similitud
descendente. Cada diccionario contiene las claves estandarizadas:
id, similitud, enlace, text, date, page, titulo, extras.
"""
# Nombres reales de las columnas segΓΊn el field_map
col_enlace = field_map.get("enlace", "Enlace")
col_text = field_map.get("text", "Text")
col_date = field_map.get("date", "Date")
col_page = field_map.get("page", "Page")
col_titulo = field_map.get("titulo") # puede ser None
extra_fields = field_map.get("extra_fields", []) # lista de (etiqueta, columna)
all_results: list[dict] = []
for collection in collections:
results = collection.query(
query_embeddings=[query_embedding],
n_results=n_results,
include=["distances"],
)
cosine_similarities = [1 - dist for dist in results["distances"][0]]
for i, chroma_id in enumerate(results["ids"][0]):
try:
if chroma_id in metadata_df.index:
row = metadata_df.loc[chroma_id]
# Campos base
res: dict[str, Any] = {
"id": chroma_id,
"similitud": cosine_similarities[i],
"enlace": _safe_get(row, col_enlace, "#"),
"text": _safe_get(row, col_text, "N/A"),
"date": _safe_get(row, col_date, "N/A"),
"page": _safe_get(row, col_page, "N/A"),
}
# TΓtulo (puede no existir)
res["titulo"] = _safe_get(row, col_titulo, None)
# Campos extra configurables: lista de (etiqueta, valor)
extras: list[tuple[str, str]] = []
for label, col_name in extra_fields:
value = str(_safe_get(row, col_name, "N/A"))
extras.append((label, value))
res["extras"] = extras
all_results.append(res)
else:
print(f" Warning: ID {chroma_id} no encontrado en metadata.")
except Exception as e:
print(f" Error al recuperar metadata para ID {chroma_id}: {e}")
# Ordenar por similitud descendente y limitar resultados
all_results = sorted(
all_results, key=lambda x: x["similitud"], reverse=True
)[:n_results]
return all_results
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# 3. RENDERIZADO HTML
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
def render_results_html(search_results: list[dict]) -> str:
"""
Genera el HTML de las tarjetas (cards) de resultados a partir de una lista
de diccionarios estandarizados.
Estructura de cada card:
ββ card-header βββββββββββββββββββββββββββββββββββββββββββββββ
β PΓ‘gina Β· Fecha Β· Relevancia β
ββ card-title (solo si hay tΓtulo) βββββββββββββββββββββββββββ€
β π TΓtulo de la decisiΓ³n β
ββ card-extras (solo si hay extra_fields) βββββββββββββββββββ€
β Etiqueta1: valor1 | Etiqueta2: valor2 | ... β
ββ card-body βββββββββββββββββββββββββββββββββββββββββββββββββ€
β Texto principal del fragmento β
ββ card-footer βββββββββββββββββββββββββββββββββββββββββββββββ€
β π Ver Documento Completo β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
ParΓ‘metros
----------
search_results : list[dict]
Lista de resultados proveniente de `perform_search`.
Retorna
-------
str
Cadena HTML con las tarjetas de resultados, lista para inyectar
en un componente gr.HTML de Gradio.
"""
if not search_results:
return (
'<div style="text-align: center; color: #64748b; padding: 40px;">'
"No se encontraron resultados relevantes.</div>"
)
html = '<div class="results-container">'
for item in search_results:
similitud_pct = f"{item['similitud'] * 100:.1f}%"
enlace = item.get("enlace", "#")
fecha = item.get("date", "N/A")
texto = str(item.get("text", "N/A")).replace("\n", " ")
pagina = item.get("page", "N/A")
titulo = item.get("titulo")
extras = item.get("extras", [])
# ββ SecciΓ³n: tΓtulo (condicional) ββ
titulo_html = ""
if titulo:
titulo_safe = str(titulo).replace("\n", " ")
titulo_html = f"""
<div class="card-title">
<span class="titulo-text">π {titulo_safe}</span>
</div>"""
# ββ SecciΓ³n: campos extra concatenados (condicional) ββ
extras_html = ""
if extras:
items_html = " | ".join(
f"<strong>{label}:</strong> {value}" for label, value in extras
)
extras_html = f"""
<div class="card-extras">
{items_html}
</div>"""
# ββ Card completa ββ
html += f"""
<div class="legal-card">
<div class="card-header">
<span class="res-number">βοΈ PΓ‘gina: {pagina}</span>
<span class="res-date">π
{fecha}</span>
<span class="res-score">π― Relevancia: {similitud_pct}</span>
</div>{titulo_html}{extras_html}
<div class="card-body">
<p class="res-summary">{texto}</p>
</div>
<div class="card-footer">
<a href="{enlace}" target="_blank" class="view-link">π Ver Documento Completo</a>
</div>
</div>
"""
html += "</div>"
return html
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# 4. CSS (lectura desde archivo estΓ‘tico)
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
def load_css(css_path: str | None = None) -> str:
"""
Lee y retorna el contenido del archivo CSS.
Busca el archivo en el siguiente orden de prioridad:
1. La ruta explΓcita proporcionada en `css_path`.
2. `static/custom.css` (relativo al directorio de este mΓ³dulo).
3. `custom.css` en la raΓz del proyecto (mismo directorio que este mΓ³dulo).
ParΓ‘metros
----------
css_path : str | None
Ruta absoluta o relativa al archivo CSS. Si es None, se busca
automΓ‘ticamente en las ubicaciones por defecto.
Retorna
-------
str
Contenido del archivo CSS como cadena de texto.
Raises
------
FileNotFoundError
Si no se encuentra el archivo CSS en ninguna ubicaciΓ³n.
"""
base_dir = os.path.dirname(os.path.abspath(__file__))
# Lista de rutas candidatas en orden de prioridad
if css_path is not None:
candidates = [css_path]
else:
candidates = [
os.path.join(base_dir, "static", "custom.css"),
os.path.join(base_dir, "custom.css"),
]
for path in candidates:
if os.path.isfile(path):
with open(path, "r", encoding="utf-8") as f:
return f.read()
searched = ", ".join(candidates)
raise FileNotFoundError(
f"No se encontrΓ³ custom.css en ninguna de estas ubicaciones: {searched}"
)
|