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
Files changed (2) hide show
  1. app.py +181 -23
  2. 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 — strip + límite extra de seguridad
126
  nombre_resuelto = payload.texto_celda[:200].strip()
127
- estudiante = db.query(DimEstudiante).filter(
128
- DimEstudiante.nombre_completo == nombre_resuelto
129
- ).first()
 
 
 
 
 
 
 
 
130
  if not estudiante:
131
- estudiante = DimEstudiante(nombre_completo=nombre_resuelto)
 
 
 
 
 
 
 
 
 
 
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 = db.query(DimEstudiante).filter(DimEstudiante.nombre_completo == nombre_resuelto).first()
 
 
 
 
 
 
260
  if not estudiante:
261
- estudiante = DimEstudiante(nombre_completo=nombre_resuelto)
 
 
 
 
 
 
 
 
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
- # Join implícito en el WHERE (filter): compatible sin relationship() en el ORM
312
- resultados = db.query(DimEstudiante, FactRendimientoAcademico).\
313
- filter(DimEstudiante.id_estudiante == FactRendimientoAcademico.id_estudiante).\
314
- filter(FactRendimientoAcademico.nota_final <= limite_nota).\
315
- all()
 
 
 
 
 
316
 
317
  data = []
318
- for est, fact in resultados:
 
 
 
 
 
 
 
319
  data.append({
320
  "estudiante": est.nombre_completo,
321
- "codigo": f"EST-{est.id_estudiante:06d}",
322
  "rendimiento": {
323
- "nota_actual": float(fact.nota_final), # Casteo explícito: Decimal de Postgres → float JSON
324
  "estado_academico": "CRÍTICO"
325
  },
326
  "finanzas": {
327
- "cuotas_mora": min_cuotas,
328
- "deuda_total": 350.0 * min_cuotas,
329
- "estado_cartera": "MORA"
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