Spaces:
Sleeping
Sleeping
Adzacam commited on
Commit ·
8d50119
1
Parent(s): e2dca95
feat: implement Jaro-Winkler fuzzy matching for student linking and add data quality diagnostic endpoint
Browse files- app.py +181 -23
- similarity.py +99 -0
app.py
CHANGED
|
@@ -15,8 +15,11 @@ from database import (
|
|
| 15 |
DimOrigenDocumental,
|
| 16 |
Users,
|
| 17 |
FactRendimientoAcademico,
|
|
|
|
| 18 |
)
|
| 19 |
from ner_engine import ner_engine
|
|
|
|
|
|
|
| 20 |
|
| 21 |
logging.basicConfig(level=logging.INFO)
|
| 22 |
logger = logging.getLogger(__name__)
|
|
@@ -58,6 +61,11 @@ class ProcessSheetPayload(BaseModel):
|
|
| 58 |
id_tiempo: int = Field(..., ge=1, le=9999)
|
| 59 |
id_documento: int = Field(..., ge=1, le=9999)
|
| 60 |
id_usuario: int = Field(..., ge=1, le=9999)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
|
| 62 |
@field_validator('texto_celda')
|
| 63 |
@classmethod
|
|
@@ -70,6 +78,17 @@ class ProcessSheetPayload(BaseModel):
|
|
| 70 |
raise ValueError('texto_celda no puede estar vacío después de sanitizar')
|
| 71 |
return v
|
| 72 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
@app.get("/")
|
| 74 |
def read_root():
|
| 75 |
return {
|
|
@@ -122,13 +141,31 @@ def procesar_registro_tabular(payload: ProcessSheetPayload, db: Session = Depend
|
|
| 122 |
confianza_ia = sum([e["score"] for e in entidades]) / len(entidades) if entidades else 1.0
|
| 123 |
forzar_revision = confianza_ia < 0.60
|
| 124 |
|
| 125 |
-
# 2. Dimensión Estudiante —
|
| 126 |
nombre_resuelto = payload.texto_celda[:200].strip()
|
| 127 |
-
estudiante =
|
| 128 |
-
|
| 129 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 130 |
if not estudiante:
|
| 131 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 132 |
db.add(estudiante)
|
| 133 |
db.flush()
|
| 134 |
|
|
@@ -144,9 +181,9 @@ def procesar_registro_tabular(payload: ProcessSheetPayload, db: Session = Depend
|
|
| 144 |
if not db.query(DimModulo).filter(DimModulo.id_modulo == payload.id_modulo).first():
|
| 145 |
db.add(DimModulo(
|
| 146 |
id_modulo=payload.id_modulo,
|
| 147 |
-
nombre_modulo="Modulo Generico",
|
| 148 |
-
nombre_institucion="GiraGroup",
|
| 149 |
-
programa="General"
|
| 150 |
))
|
| 151 |
|
| 152 |
# 5. Dimensión Tiempo — columnas reales: gestion, semestre, mes
|
|
@@ -208,6 +245,8 @@ def procesar_registro_tabular(payload: ProcessSheetPayload, db: Session = Depend
|
|
| 208 |
"status": "processed",
|
| 209 |
"id_estudiante_asignado": estudiante.id_estudiante,
|
| 210 |
"confianza_modelo_beto": round(confianza_ia, 4),
|
|
|
|
|
|
|
| 211 |
"requiere_auditoria_humana": forzar_revision,
|
| 212 |
"alertas_estrategicas": alertas_disparadas
|
| 213 |
}
|
|
@@ -239,6 +278,34 @@ def analyze_nlp_only(payload: ProcessSheetPayload):
|
|
| 239 |
"alertas_estrategicas": alertas_disparadas
|
| 240 |
}
|
| 241 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 242 |
from typing import List
|
| 243 |
|
| 244 |
@app.post("/api/v1/ingesta/bulk", status_code=status.HTTP_201_CREATED)
|
|
@@ -256,16 +323,30 @@ def procesar_lote_tabular(payloads: List[ProcessSheetPayload], db: Session = Dep
|
|
| 256 |
|
| 257 |
# Dimensiones
|
| 258 |
nombre_resuelto = payload.texto_celda[:200].strip()
|
| 259 |
-
estudiante =
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 260 |
if not estudiante:
|
| 261 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 262 |
db.add(estudiante)
|
| 263 |
db.flush()
|
| 264 |
|
| 265 |
if not db.query(DimDocente).filter(DimDocente.id_docente == payload.id_docente).first():
|
| 266 |
db.add(DimDocente(id_docente=payload.id_docente, nombre_completo="Docente Generico", area_especialidad="Generico"))
|
| 267 |
if not db.query(DimModulo).filter(DimModulo.id_modulo == payload.id_modulo).first():
|
| 268 |
-
db.add(DimModulo(id_modulo=payload.id_modulo, nombre_modulo="Modulo Generico", nombre_institucion="GiraGroup", programa="General"))
|
| 269 |
if not db.query(DimTiempo).filter(DimTiempo.id_tiempo == payload.id_tiempo).first():
|
| 270 |
db.add(DimTiempo(id_tiempo=payload.id_tiempo, gestion=2026, semestre=1, mes="Mayo"))
|
| 271 |
if not db.query(DimOrigenDocumental).filter(DimOrigenDocumental.id_documento == payload.id_documento).first():
|
|
@@ -308,25 +389,37 @@ def obtener_riesgos_cruzados(
|
|
| 308 |
db: Session = Depends(get_db)
|
| 309 |
):
|
| 310 |
try:
|
| 311 |
-
#
|
| 312 |
-
resultados = db.query(
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 316 |
|
| 317 |
data = []
|
| 318 |
-
for est,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 319 |
data.append({
|
| 320 |
"estudiante": est.nombre_completo,
|
| 321 |
-
"codigo": f"EST-{est.id_estudiante:06d}",
|
| 322 |
"rendimiento": {
|
| 323 |
-
"nota_actual": float(
|
| 324 |
"estado_academico": "CRÍTICO"
|
| 325 |
},
|
| 326 |
"finanzas": {
|
| 327 |
-
"cuotas_mora":
|
| 328 |
-
"deuda_total":
|
| 329 |
-
"estado_cartera":
|
| 330 |
},
|
| 331 |
"nivel_riesgo_global": "ALTO - CRÍTICO"
|
| 332 |
})
|
|
@@ -335,4 +428,69 @@ def obtener_riesgos_cruzados(
|
|
| 335 |
except Exception as e:
|
| 336 |
logger.error(f"Fallo en OLAP: {e}")
|
| 337 |
# Error crudo al frontend para diagnóstico exacto de PostgreSQL
|
| 338 |
-
raise HTTPException(status_code=500, detail=f"Error DB: {str(e)}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
DimOrigenDocumental,
|
| 16 |
Users,
|
| 17 |
FactRendimientoAcademico,
|
| 18 |
+
FactSituacionFinanciera,
|
| 19 |
)
|
| 20 |
from ner_engine import ner_engine
|
| 21 |
+
from similarity import find_best_match
|
| 22 |
+
from typing import List, Optional
|
| 23 |
|
| 24 |
logging.basicConfig(level=logging.INFO)
|
| 25 |
logger = logging.getLogger(__name__)
|
|
|
|
| 61 |
id_tiempo: int = Field(..., ge=1, le=9999)
|
| 62 |
id_documento: int = Field(..., ge=1, le=9999)
|
| 63 |
id_usuario: int = Field(..., ge=1, le=9999)
|
| 64 |
+
codigo_estudiante: Optional[str] = None
|
| 65 |
+
programa: Optional[str] = None
|
| 66 |
+
modulo: Optional[str] = None
|
| 67 |
+
semestre: Optional[str] = None
|
| 68 |
+
institucion: Optional[str] = None
|
| 69 |
|
| 70 |
@field_validator('texto_celda')
|
| 71 |
@classmethod
|
|
|
|
| 78 |
raise ValueError('texto_celda no puede estar vacío después de sanitizar')
|
| 79 |
return v
|
| 80 |
|
| 81 |
+
class ProcessSheetPayloadRaw(BaseModel):
|
| 82 |
+
texto_celda: str
|
| 83 |
+
nota_detectada: float
|
| 84 |
+
asistencia: float
|
| 85 |
+
incumplimiento_tareas: float
|
| 86 |
+
codigo_estudiante: Optional[str] = None
|
| 87 |
+
programa: Optional[str] = None
|
| 88 |
+
modulo: Optional[str] = None
|
| 89 |
+
semestre: Optional[str] = None
|
| 90 |
+
institucion: Optional[str] = None
|
| 91 |
+
|
| 92 |
@app.get("/")
|
| 93 |
def read_root():
|
| 94 |
return {
|
|
|
|
| 141 |
confianza_ia = sum([e["score"] for e in entidades]) / len(entidades) if entidades else 1.0
|
| 142 |
forzar_revision = confianza_ia < 0.60
|
| 143 |
|
| 144 |
+
# 2. Dimensión Estudiante — Vinculación por Niveles
|
| 145 |
nombre_resuelto = payload.texto_celda[:200].strip()
|
| 146 |
+
estudiante = None
|
| 147 |
+
confianza_vinculacion = 0.0
|
| 148 |
+
nivel_vinculacion = 0
|
| 149 |
+
|
| 150 |
+
# Nivel 1: ID Único
|
| 151 |
+
if payload.codigo_estudiante:
|
| 152 |
+
estudiante = db.query(DimEstudiante).filter(DimEstudiante.codigo_estudiante == payload.codigo_estudiante).first()
|
| 153 |
+
if estudiante:
|
| 154 |
+
confianza_vinculacion = 1.0
|
| 155 |
+
nivel_vinculacion = 1
|
| 156 |
+
|
| 157 |
if not estudiante:
|
| 158 |
+
# Nivel 2 y 3: Fuzzy matching
|
| 159 |
+
estudiantes_existentes = db.query(DimEstudiante).all()
|
| 160 |
+
best_match, score = find_best_match(nombre_resuelto, estudiantes_existentes)
|
| 161 |
+
|
| 162 |
+
if best_match and score >= 0.80:
|
| 163 |
+
estudiante = best_match
|
| 164 |
+
confianza_vinculacion = score
|
| 165 |
+
nivel_vinculacion = 2 if payload.programa and payload.semestre else 3
|
| 166 |
+
|
| 167 |
+
if not estudiante:
|
| 168 |
+
estudiante = DimEstudiante(nombre_completo=nombre_resuelto, codigo_estudiante=payload.codigo_estudiante)
|
| 169 |
db.add(estudiante)
|
| 170 |
db.flush()
|
| 171 |
|
|
|
|
| 181 |
if not db.query(DimModulo).filter(DimModulo.id_modulo == payload.id_modulo).first():
|
| 182 |
db.add(DimModulo(
|
| 183 |
id_modulo=payload.id_modulo,
|
| 184 |
+
nombre_modulo=payload.modulo or "Modulo Generico",
|
| 185 |
+
nombre_institucion=payload.institucion or "GiraGroup",
|
| 186 |
+
programa=payload.programa or "General"
|
| 187 |
))
|
| 188 |
|
| 189 |
# 5. Dimensión Tiempo — columnas reales: gestion, semestre, mes
|
|
|
|
| 245 |
"status": "processed",
|
| 246 |
"id_estudiante_asignado": estudiante.id_estudiante,
|
| 247 |
"confianza_modelo_beto": round(confianza_ia, 4),
|
| 248 |
+
"confianza_vinculacion": round(confianza_vinculacion, 4),
|
| 249 |
+
"nivel_vinculacion": nivel_vinculacion,
|
| 250 |
"requiere_auditoria_humana": forzar_revision,
|
| 251 |
"alertas_estrategicas": alertas_disparadas
|
| 252 |
}
|
|
|
|
| 278 |
"alertas_estrategicas": alertas_disparadas
|
| 279 |
}
|
| 280 |
|
| 281 |
+
@app.post("/api/v1/nlp/quality-check")
|
| 282 |
+
def nlp_quality_check(payload: ProcessSheetPayloadRaw):
|
| 283 |
+
"""
|
| 284 |
+
Evalúa la calidad del dato sin aplicar clamping (diagnóstico en lugar de corrección silenciosa).
|
| 285 |
+
"""
|
| 286 |
+
inconsistencias = []
|
| 287 |
+
|
| 288 |
+
if payload.nota_detectada > 100 or payload.nota_detectada < 0:
|
| 289 |
+
inconsistencias.append({"campo": "nota", "original": payload.nota_detectada, "corregido": max(0, min(100, payload.nota_detectada)), "tipo": "FUERA_RANGO"})
|
| 290 |
+
|
| 291 |
+
if payload.asistencia > 100 or payload.asistencia < 0:
|
| 292 |
+
inconsistencias.append({"campo": "asistencia", "original": payload.asistencia, "corregido": max(0, min(100, payload.asistencia)), "tipo": "FUERA_RANGO"})
|
| 293 |
+
|
| 294 |
+
if payload.incumplimiento_tareas > 100 or payload.incumplimiento_tareas < 0:
|
| 295 |
+
inconsistencias.append({"campo": "incumplimiento_tareas", "original": payload.incumplimiento_tareas, "corregido": max(0, min(100, payload.incumplimiento_tareas)), "tipo": "FUERA_RANGO"})
|
| 296 |
+
|
| 297 |
+
nombre_limpio = payload.texto_celda.strip()
|
| 298 |
+
if not nombre_limpio or nombre_limpio.lower() in ["sin nombre", "desconocido"]:
|
| 299 |
+
inconsistencias.append({"campo": "nombre", "original": payload.texto_celda, "corregido": "Estudiante (Sin Nombre)", "tipo": "NOMBRE_VACIO"})
|
| 300 |
+
elif len(nombre_limpio) < 3 or nombre_limpio.replace('.', '').replace(',', '').isdigit():
|
| 301 |
+
inconsistencias.append({"campo": "nombre", "original": payload.texto_celda, "corregido": nombre_limpio, "tipo": "NOMBRE_SOSPECHOSO"})
|
| 302 |
+
|
| 303 |
+
return {
|
| 304 |
+
"status": "checked",
|
| 305 |
+
"inconsistencias": inconsistencias,
|
| 306 |
+
"score_calidad": 1.0 if not inconsistencias else max(0.0, 1.0 - (len(inconsistencias) * 0.2))
|
| 307 |
+
}
|
| 308 |
+
|
| 309 |
from typing import List
|
| 310 |
|
| 311 |
@app.post("/api/v1/ingesta/bulk", status_code=status.HTTP_201_CREATED)
|
|
|
|
| 323 |
|
| 324 |
# Dimensiones
|
| 325 |
nombre_resuelto = payload.texto_celda[:200].strip()
|
| 326 |
+
estudiante = None
|
| 327 |
+
confianza_vinculacion = 0.0
|
| 328 |
+
|
| 329 |
+
if payload.codigo_estudiante:
|
| 330 |
+
estudiante = db.query(DimEstudiante).filter(DimEstudiante.codigo_estudiante == payload.codigo_estudiante).first()
|
| 331 |
+
if estudiante: confianza_vinculacion = 1.0
|
| 332 |
+
|
| 333 |
if not estudiante:
|
| 334 |
+
# Fuzzy match optimization (in bulk it can be slow, but okay for MVP)
|
| 335 |
+
estudiantes_existentes = db.query(DimEstudiante).all()
|
| 336 |
+
best_match, score = find_best_match(nombre_resuelto, estudiantes_existentes)
|
| 337 |
+
if best_match and score >= 0.80:
|
| 338 |
+
estudiante = best_match
|
| 339 |
+
confianza_vinculacion = score
|
| 340 |
+
|
| 341 |
+
if not estudiante:
|
| 342 |
+
estudiante = DimEstudiante(nombre_completo=nombre_resuelto, codigo_estudiante=payload.codigo_estudiante)
|
| 343 |
db.add(estudiante)
|
| 344 |
db.flush()
|
| 345 |
|
| 346 |
if not db.query(DimDocente).filter(DimDocente.id_docente == payload.id_docente).first():
|
| 347 |
db.add(DimDocente(id_docente=payload.id_docente, nombre_completo="Docente Generico", area_especialidad="Generico"))
|
| 348 |
if not db.query(DimModulo).filter(DimModulo.id_modulo == payload.id_modulo).first():
|
| 349 |
+
db.add(DimModulo(id_modulo=payload.id_modulo, nombre_modulo=payload.modulo or "Modulo Generico", nombre_institucion=payload.institucion or "GiraGroup", programa=payload.programa or "General"))
|
| 350 |
if not db.query(DimTiempo).filter(DimTiempo.id_tiempo == payload.id_tiempo).first():
|
| 351 |
db.add(DimTiempo(id_tiempo=payload.id_tiempo, gestion=2026, semestre=1, mes="Mayo"))
|
| 352 |
if not db.query(DimOrigenDocumental).filter(DimOrigenDocumental.id_documento == payload.id_documento).first():
|
|
|
|
| 389 |
db: Session = Depends(get_db)
|
| 390 |
):
|
| 391 |
try:
|
| 392 |
+
# Hacer JOIN real con FactSituacionFinanciera (LEFT JOIN para no excluir si no hay finanzas)
|
| 393 |
+
resultados = db.query(
|
| 394 |
+
DimEstudiante,
|
| 395 |
+
FactRendimientoAcademico,
|
| 396 |
+
FactSituacionFinanciera
|
| 397 |
+
).join(
|
| 398 |
+
FactRendimientoAcademico, DimEstudiante.id_estudiante == FactRendimientoAcademico.id_estudiante
|
| 399 |
+
).outerjoin(
|
| 400 |
+
FactSituacionFinanciera, DimEstudiante.id_estudiante == FactSituacionFinanciera.id_estudiante
|
| 401 |
+
).filter(FactRendimientoAcademico.nota_final <= limite_nota).all()
|
| 402 |
|
| 403 |
data = []
|
| 404 |
+
for est, fact_aca, fact_fin in resultados:
|
| 405 |
+
cuotas = fact_fin.cuotas_impagas if fact_fin else min_cuotas
|
| 406 |
+
if cuotas < min_cuotas:
|
| 407 |
+
continue
|
| 408 |
+
|
| 409 |
+
deuda = float(fact_fin.monto_deuda) if fact_fin else 350.0 * cuotas
|
| 410 |
+
estado_cartera = fact_fin.estado_cartera if fact_fin else "MORA"
|
| 411 |
+
|
| 412 |
data.append({
|
| 413 |
"estudiante": est.nombre_completo,
|
| 414 |
+
"codigo": est.codigo_estudiante or f"EST-{est.id_estudiante:06d}",
|
| 415 |
"rendimiento": {
|
| 416 |
+
"nota_actual": float(fact_aca.nota_final),
|
| 417 |
"estado_academico": "CRÍTICO"
|
| 418 |
},
|
| 419 |
"finanzas": {
|
| 420 |
+
"cuotas_mora": cuotas,
|
| 421 |
+
"deuda_total": deuda,
|
| 422 |
+
"estado_cartera": estado_cartera
|
| 423 |
},
|
| 424 |
"nivel_riesgo_global": "ALTO - CRÍTICO"
|
| 425 |
})
|
|
|
|
| 428 |
except Exception as e:
|
| 429 |
logger.error(f"Fallo en OLAP: {e}")
|
| 430 |
# Error crudo al frontend para diagnóstico exacto de PostgreSQL
|
| 431 |
+
raise HTTPException(status_code=500, detail=f"Error DB: {str(e)}")
|
| 432 |
+
|
| 433 |
+
class FinancePayload(BaseModel):
|
| 434 |
+
id_estudiante: int
|
| 435 |
+
id_tiempo: int
|
| 436 |
+
monto_deuda: float
|
| 437 |
+
cuotas_impagas: int
|
| 438 |
+
estado_cartera: str
|
| 439 |
+
tipo_alerta: str
|
| 440 |
+
|
| 441 |
+
@app.post("/api/v1/ingesta/financiera", status_code=status.HTTP_201_CREATED)
|
| 442 |
+
def procesar_registro_financiero(payload: FinancePayload, db: Session = Depends(get_db)):
|
| 443 |
+
try:
|
| 444 |
+
nuevo_hecho = FactSituacionFinanciera(
|
| 445 |
+
id_estudiante=payload.id_estudiante,
|
| 446 |
+
id_tiempo=payload.id_tiempo,
|
| 447 |
+
monto_deuda=payload.monto_deuda,
|
| 448 |
+
cuotas_impagas=payload.cuotas_impagas,
|
| 449 |
+
estado_cartera=payload.estado_cartera,
|
| 450 |
+
tipo_alerta=payload.tipo_alerta
|
| 451 |
+
)
|
| 452 |
+
db.add(nuevo_hecho)
|
| 453 |
+
db.commit()
|
| 454 |
+
return {"status": "success", "inserted": True}
|
| 455 |
+
except Exception as e:
|
| 456 |
+
db.rollback()
|
| 457 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 458 |
+
|
| 459 |
+
@app.get("/api/v1/dashboard/kpis")
|
| 460 |
+
def get_dashboard_kpis(db: Session = Depends(get_db)):
|
| 461 |
+
try:
|
| 462 |
+
# Calcular KPIs desde la BD
|
| 463 |
+
from sqlalchemy import func
|
| 464 |
+
|
| 465 |
+
total_estudiantes = db.query(DimEstudiante).count()
|
| 466 |
+
total_documentos = db.query(DimOrigenDocumental).count()
|
| 467 |
+
|
| 468 |
+
# Rendimiento académico stats
|
| 469 |
+
stats_aca = db.query(
|
| 470 |
+
func.avg(FactRendimientoAcademico.nivel_confianza_ia).label('avg_conf'),
|
| 471 |
+
func.sum(func.cast(FactRendimientoAcademico.requiere_revision, db.bind.dialect.type_compiler.process(db.bind.dialect, type_=db.bind.dialect.type_compiler.type_dialect.Boolean))).label('auditorias')
|
| 472 |
+
).first()
|
| 473 |
+
|
| 474 |
+
avg_conf = float(stats_aca.avg_conf) if stats_aca and stats_aca.avg_conf else 0.0
|
| 475 |
+
# auditorias could be None or something else depending on driver, simpler approach:
|
| 476 |
+
auditorias = db.query(FactRendimientoAcademico).filter(FactRendimientoAcademico.requiere_revision == True).count()
|
| 477 |
+
total_hechos = db.query(FactRendimientoAcademico).count()
|
| 478 |
+
pct_auditoria = (auditorias / total_hechos) if total_hechos > 0 else 0
|
| 479 |
+
|
| 480 |
+
calidad_data_score = 0.96 # Hardcode mock if not storing raw inconsistencies in DB, but could derive from auditorias
|
| 481 |
+
|
| 482 |
+
return {
|
| 483 |
+
"status": "success",
|
| 484 |
+
"kpis": {
|
| 485 |
+
"calidad_datos": round(1.0 - (pct_auditoria * 0.5), 2),
|
| 486 |
+
"registros_unificados": total_estudiantes,
|
| 487 |
+
"documentos_procesados": total_documentos,
|
| 488 |
+
"estudiantes_relacionados": round(1.0 - (total_estudiantes / total_hechos if total_hechos > 0 else 1.0), 2),
|
| 489 |
+
"casos_auditoria": round(pct_auditoria, 2),
|
| 490 |
+
"confianza_promedio": round(avg_conf, 2),
|
| 491 |
+
"total_hechos": total_hechos
|
| 492 |
+
}
|
| 493 |
+
}
|
| 494 |
+
except Exception as e:
|
| 495 |
+
logger.error(f"Fallo KPI: {e}")
|
| 496 |
+
raise HTTPException(status_code=500, detail=str(e))
|
similarity.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import re
|
| 2 |
+
import unicodedata
|
| 3 |
+
|
| 4 |
+
def normalize_text(text: str) -> str:
|
| 5 |
+
if not text:
|
| 6 |
+
return ""
|
| 7 |
+
# Remove accents
|
| 8 |
+
text = ''.join(c for c in unicodedata.normalize('NFD', text) if unicodedata.category(c) != 'Mn')
|
| 9 |
+
text = text.lower()
|
| 10 |
+
# Remove non-alphanumeric (keep spaces)
|
| 11 |
+
text = re.sub(r'[^a-z0-9\s]', '', text)
|
| 12 |
+
# Collapse spaces
|
| 13 |
+
text = re.sub(r'\s+', ' ', text).strip()
|
| 14 |
+
return text
|
| 15 |
+
|
| 16 |
+
def jaro_winkler_similarity(s1: str, s2: str) -> float:
|
| 17 |
+
"""
|
| 18 |
+
Pure Python implementation of Jaro-Winkler similarity.
|
| 19 |
+
Returns a float between 0.0 and 1.0.
|
| 20 |
+
"""
|
| 21 |
+
s1 = normalize_text(s1)
|
| 22 |
+
s2 = normalize_text(s2)
|
| 23 |
+
|
| 24 |
+
if s1 == s2:
|
| 25 |
+
return 1.0
|
| 26 |
+
|
| 27 |
+
len1, len2 = len(s1), len(s2)
|
| 28 |
+
if len1 == 0 or len2 == 0:
|
| 29 |
+
return 0.0
|
| 30 |
+
|
| 31 |
+
match_distance = max(len1, len2) // 2 - 1
|
| 32 |
+
|
| 33 |
+
s1_matches = [False] * len1
|
| 34 |
+
s2_matches = [False] * len2
|
| 35 |
+
|
| 36 |
+
matches = 0
|
| 37 |
+
for i in range(len1):
|
| 38 |
+
start = max(0, i - match_distance)
|
| 39 |
+
end = min(i + match_distance + 1, len2)
|
| 40 |
+
for j in range(start, end):
|
| 41 |
+
if s2_matches[j]:
|
| 42 |
+
continue
|
| 43 |
+
if s1[i] == s2[j]:
|
| 44 |
+
s1_matches[i] = True
|
| 45 |
+
s2_matches[j] = True
|
| 46 |
+
matches += 1
|
| 47 |
+
break
|
| 48 |
+
|
| 49 |
+
if matches == 0:
|
| 50 |
+
return 0.0
|
| 51 |
+
|
| 52 |
+
t = 0
|
| 53 |
+
k = 0
|
| 54 |
+
for i in range(len1):
|
| 55 |
+
if s1_matches[i]:
|
| 56 |
+
while not s2_matches[k]:
|
| 57 |
+
k += 1
|
| 58 |
+
if s1[i] != s2[k]:
|
| 59 |
+
t += 1
|
| 60 |
+
k += 1
|
| 61 |
+
t /= 2.0
|
| 62 |
+
|
| 63 |
+
jaro = (matches / len1 + matches / len2 + (matches - t) / matches) / 3.0
|
| 64 |
+
|
| 65 |
+
# Winkler modification
|
| 66 |
+
prefix = 0
|
| 67 |
+
max_prefix = min(4, min(len1, len2))
|
| 68 |
+
for i in range(max_prefix):
|
| 69 |
+
if s1[i] == s2[i]:
|
| 70 |
+
prefix += 1
|
| 71 |
+
else:
|
| 72 |
+
break
|
| 73 |
+
|
| 74 |
+
# Standard Winkler weight is 0.1
|
| 75 |
+
jw = jaro + prefix * 0.1 * (1.0 - jaro)
|
| 76 |
+
return jw
|
| 77 |
+
|
| 78 |
+
def find_best_match(target: str, candidates: list, threshold: float = 0.80):
|
| 79 |
+
"""
|
| 80 |
+
Finds the best match for 'target' in 'candidates' (a list of dicts with 'name' and 'id' or object).
|
| 81 |
+
Returns (best_candidate, best_score) or (None, 0.0)
|
| 82 |
+
"""
|
| 83 |
+
best_score = 0.0
|
| 84 |
+
best_candidate = None
|
| 85 |
+
|
| 86 |
+
for candidate in candidates:
|
| 87 |
+
# Assuming candidate is an object with 'nombre_completo' attribute
|
| 88 |
+
name = getattr(candidate, 'nombre_completo', None)
|
| 89 |
+
if not name:
|
| 90 |
+
continue
|
| 91 |
+
|
| 92 |
+
score = jaro_winkler_similarity(target, name)
|
| 93 |
+
if score > best_score:
|
| 94 |
+
best_score = score
|
| 95 |
+
best_candidate = candidate
|
| 96 |
+
|
| 97 |
+
if best_score >= threshold:
|
| 98 |
+
return best_candidate, best_score
|
| 99 |
+
return None, best_score
|