Spaces:
Sleeping
Sleeping
Adzacam commited on
Commit ·
0dee76d
1
Parent(s): 04b8e02
feat: expand data ingestion models to support marketing and survey pipelines and centralize fact insertion logic
Browse files- app.py +110 -58
- database.py +2 -2
app.py
CHANGED
|
@@ -124,6 +124,20 @@ class ProcessSheetPayload(BaseModel):
|
|
| 124 |
tipo_fuente: Optional[str] = None
|
| 125 |
genero: Optional[str] = None
|
| 126 |
ciudad: Optional[str] = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 127 |
|
| 128 |
@field_validator('texto_celda')
|
| 129 |
@classmethod
|
|
@@ -152,6 +166,20 @@ class ProcessSheetPayloadRaw(BaseModel):
|
|
| 152 |
tipo_fuente: Optional[str] = None
|
| 153 |
genero: Optional[str] = None
|
| 154 |
ciudad: Optional[str] = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 155 |
|
| 156 |
from typing import Union
|
| 157 |
|
|
@@ -519,20 +547,47 @@ def procesar_lote_tabular(payloads: List[ProcessSheetPayload], db: Session = Dep
|
|
| 519 |
db.flush() # Importante: flush para poder usar los IDs recién creados
|
| 520 |
|
| 521 |
# Hecho (La data de payload ya viene corregida por ti desde el frontend)
|
| 522 |
-
|
| 523 |
-
|
| 524 |
-
|
| 525 |
-
|
| 526 |
-
|
| 527 |
-
|
| 528 |
-
|
| 529 |
-
|
| 530 |
-
|
| 531 |
-
|
| 532 |
-
|
| 533 |
-
|
| 534 |
-
|
| 535 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 536 |
|
| 537 |
# Al final, guardamos todo junto
|
| 538 |
db.commit()
|
|
@@ -1199,9 +1254,18 @@ def batch_analyze_nlp(
|
|
| 1199 |
existing_users = {u.id for u in db.query(Users).all()}
|
| 1200 |
|
| 1201 |
for record in records:
|
| 1202 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1203 |
entidades = ner_engine.extract_entities(record.texto_celda)
|
| 1204 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1205 |
nombre_resuelto = record.texto_celda[:200].strip()
|
| 1206 |
|
| 1207 |
# Consultar log_auditoria_nlp primero
|
|
@@ -1224,25 +1288,35 @@ def batch_analyze_nlp(
|
|
| 1224 |
db.flush()
|
| 1225 |
estudiantes_existentes.append(estudiante)
|
| 1226 |
else:
|
| 1227 |
-
|
| 1228 |
-
if
|
| 1229 |
-
|
| 1230 |
-
if score
|
| 1231 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1232 |
else:
|
| 1233 |
-
|
| 1234 |
-
|
| 1235 |
-
|
| 1236 |
-
|
|
|
|
|
|
|
| 1237 |
|
| 1238 |
-
candidatos_difusos = get_top_matches(nombre_resuelto, estudiantes_existentes, top_k=5) if requiere_revision or confianza_ia < 0.60 else []
|
| 1239 |
|
| 1240 |
# Calculo de alertas
|
| 1241 |
alertas = []
|
| 1242 |
-
if
|
| 1243 |
-
|
| 1244 |
-
|
| 1245 |
-
|
|
|
|
| 1246 |
|
| 1247 |
# Ensure dimensions exist using in-memory cache to prevent lock contention
|
| 1248 |
docente_name = getattr(record, 'docente', None) or "Docente Generico"
|
|
@@ -1281,7 +1355,7 @@ def batch_analyze_nlp(
|
|
| 1281 |
db.flush()
|
| 1282 |
|
| 1283 |
requiere_revision = False
|
| 1284 |
-
if confianza_ia < 0.60:
|
| 1285 |
log = LogAuditoriaNlp(
|
| 1286 |
texto_original=nombre_resuelto,
|
| 1287 |
prediccion_beto=nombre_resuelto,
|
|
@@ -1293,32 +1367,9 @@ def batch_analyze_nlp(
|
|
| 1293 |
db.flush()
|
| 1294 |
requiere_revision = True
|
| 1295 |
|
| 1296 |
-
# Insert into Constellation Schema ALWAYS
|
| 1297 |
-
|
| 1298 |
-
|
| 1299 |
-
id_estudiante=estudiante.id_estudiante,
|
| 1300 |
-
id_tiempo=id_tiempo_val,
|
| 1301 |
-
monto_deuda=getattr(record, 'monto_deuda', 0),
|
| 1302 |
-
cuotas_impagas=getattr(record, 'cuotas_impagas', 0),
|
| 1303 |
-
estado_cartera="AL_DIA",
|
| 1304 |
-
tipo_alerta="NINGUNA"
|
| 1305 |
-
)
|
| 1306 |
-
db.add(fact)
|
| 1307 |
-
else:
|
| 1308 |
-
fact = FactRendimientoAcademico(
|
| 1309 |
-
id_estudiante=estudiante.id_estudiante,
|
| 1310 |
-
id_docente=id_docente_val,
|
| 1311 |
-
id_modulo=id_modulo_val,
|
| 1312 |
-
id_tiempo=id_tiempo_val,
|
| 1313 |
-
id_documento=id_documento_val,
|
| 1314 |
-
id_usuario_carga=id_usuario_val,
|
| 1315 |
-
nota_final=record.nota_detectada,
|
| 1316 |
-
asistencia_pct=record.asistencia,
|
| 1317 |
-
incumplimiento_actividades_pct=record.incumplimiento_tareas,
|
| 1318 |
-
nivel_confianza_ia=confianza_ia,
|
| 1319 |
-
requiere_revision=requiere_revision
|
| 1320 |
-
)
|
| 1321 |
-
db.add(fact)
|
| 1322 |
|
| 1323 |
results.append({
|
| 1324 |
"anonymized_name": anonymize_name(nombre_resuelto),
|
|
@@ -1327,7 +1378,8 @@ def batch_analyze_nlp(
|
|
| 1327 |
"alertas": alertas,
|
| 1328 |
"requiere_revision": requiere_revision,
|
| 1329 |
"status": "pending_human_review" if requiere_revision else "inserted",
|
| 1330 |
-
"candidatos_difusos": candidatos_difusos
|
|
|
|
| 1331 |
})
|
| 1332 |
|
| 1333 |
try:
|
|
|
|
| 124 |
tipo_fuente: Optional[str] = None
|
| 125 |
genero: Optional[str] = None
|
| 126 |
ciudad: Optional[str] = None
|
| 127 |
+
|
| 128 |
+
# Financial fields
|
| 129 |
+
monto_deuda: Optional[float] = 0.0
|
| 130 |
+
cuotas_impagas: Optional[int] = 0
|
| 131 |
+
|
| 132 |
+
# Marketing fields
|
| 133 |
+
leads: Optional[int] = 0
|
| 134 |
+
reservas: Optional[int] = 0
|
| 135 |
+
inscritos: Optional[int] = 0
|
| 136 |
+
costo: Optional[float] = 0.0
|
| 137 |
+
|
| 138 |
+
# Survey fields
|
| 139 |
+
pregunta: Optional[str] = None
|
| 140 |
+
puntuacion: Optional[float] = 0.0
|
| 141 |
|
| 142 |
@field_validator('texto_celda')
|
| 143 |
@classmethod
|
|
|
|
| 166 |
tipo_fuente: Optional[str] = None
|
| 167 |
genero: Optional[str] = None
|
| 168 |
ciudad: Optional[str] = None
|
| 169 |
+
|
| 170 |
+
# Financial fields
|
| 171 |
+
monto_deuda: Optional[float] = 0.0
|
| 172 |
+
cuotas_impagas: Optional[int] = 0
|
| 173 |
+
|
| 174 |
+
# Marketing fields
|
| 175 |
+
leads: Optional[int] = 0
|
| 176 |
+
reservas: Optional[int] = 0
|
| 177 |
+
inscritos: Optional[int] = 0
|
| 178 |
+
costo: Optional[float] = 0.0
|
| 179 |
+
|
| 180 |
+
# Survey fields
|
| 181 |
+
pregunta: Optional[str] = None
|
| 182 |
+
puntuacion: Optional[float] = 0.0
|
| 183 |
|
| 184 |
from typing import Union
|
| 185 |
|
|
|
|
| 547 |
db.flush() # Importante: flush para poder usar los IDs recién creados
|
| 548 |
|
| 549 |
# Hecho (La data de payload ya viene corregida por ti desde el frontend)
|
| 550 |
+
# Determinar area (Fallback a ACADEMIC si no se provee)
|
| 551 |
+
area = getattr(payload, 'tipo_fuente', 'ACADEMIC')
|
| 552 |
+
if not area:
|
| 553 |
+
area = 'ACADEMIC'
|
| 554 |
+
|
| 555 |
+
if area == "MARKETING":
|
| 556 |
+
nuevo_hecho = FactMarketingInscripciones(
|
| 557 |
+
id_modulo=payload.id_modulo,
|
| 558 |
+
id_tiempo=payload.id_tiempo,
|
| 559 |
+
leads=getattr(payload, 'leads', 1),
|
| 560 |
+
reservas=getattr(payload, 'reservas', 0),
|
| 561 |
+
inscritos=getattr(payload, 'inscritos', 0),
|
| 562 |
+
costo_programa=getattr(payload, 'costo', 0)
|
| 563 |
+
)
|
| 564 |
+
db.add(nuevo_hecho)
|
| 565 |
+
elif area == "SURVEYS":
|
| 566 |
+
nuevo_hecho = FactEvaluacionDocente(
|
| 567 |
+
id_docente=payload.id_docente,
|
| 568 |
+
id_modulo=payload.id_modulo,
|
| 569 |
+
id_estudiante=estudiante.id_estudiante,
|
| 570 |
+
id_tiempo=payload.id_tiempo,
|
| 571 |
+
pregunta_bloque=getattr(payload, 'pregunta', 'General'),
|
| 572 |
+
puntuacion=getattr(payload, 'puntuacion', 5.0),
|
| 573 |
+
comentario=nombre_resuelto
|
| 574 |
+
)
|
| 575 |
+
db.add(nuevo_hecho)
|
| 576 |
+
else: # ACADEMIC
|
| 577 |
+
nuevo_hecho = FactRendimientoAcademico(
|
| 578 |
+
id_estudiante=estudiante.id_estudiante,
|
| 579 |
+
id_docente=payload.id_docente,
|
| 580 |
+
id_modulo=payload.id_modulo,
|
| 581 |
+
id_tiempo=payload.id_tiempo,
|
| 582 |
+
id_documento=payload.id_documento,
|
| 583 |
+
id_usuario_carga=payload.id_usuario,
|
| 584 |
+
nota_final=payload.nota_detectada,
|
| 585 |
+
asistencia_pct=payload.asistencia,
|
| 586 |
+
incumplimiento_actividades_pct=payload.incumplimiento_tareas,
|
| 587 |
+
nivel_confianza_ia=confianza_ia,
|
| 588 |
+
requiere_revision=forzar_revision
|
| 589 |
+
)
|
| 590 |
+
db.add(nuevo_hecho)
|
| 591 |
|
| 592 |
# Al final, guardamos todo junto
|
| 593 |
db.commit()
|
|
|
|
| 1254 |
existing_users = {u.id for u in db.query(Users).all()}
|
| 1255 |
|
| 1256 |
for record in records:
|
| 1257 |
+
# 1. Determinar el área y calcular confianza ajustada
|
| 1258 |
+
area = getattr(record, 'tipo_fuente', 'ACADEMIC')
|
| 1259 |
+
if not area:
|
| 1260 |
+
area = 'ACADEMIC'
|
| 1261 |
+
|
| 1262 |
entidades = ner_engine.extract_entities(record.texto_celda)
|
| 1263 |
+
if entidades:
|
| 1264 |
+
confianza_ia = sum([e["score"] for e in entidades]) / len(entidades)
|
| 1265 |
+
else:
|
| 1266 |
+
# Para áreas no académicas, la falta de entidades persona no es penalizable severamente
|
| 1267 |
+
confianza_ia = 0.85 if area != 'ACADEMIC' else 0.40
|
| 1268 |
+
|
| 1269 |
nombre_resuelto = record.texto_celda[:200].strip()
|
| 1270 |
|
| 1271 |
# Consultar log_auditoria_nlp primero
|
|
|
|
| 1288 |
db.flush()
|
| 1289 |
estudiantes_existentes.append(estudiante)
|
| 1290 |
else:
|
| 1291 |
+
# Skip fuzzy match for non-academic/non-finance areas where "student" name isn't critical
|
| 1292 |
+
if area in ['ACADEMIC', 'FINANCE']:
|
| 1293 |
+
best_match, score = find_best_match(nombre_resuelto, estudiantes_existentes)
|
| 1294 |
+
if best_match and score >= 0.8:
|
| 1295 |
+
estudiante = best_match
|
| 1296 |
+
if score < confianza_ia:
|
| 1297 |
+
confianza_ia = score
|
| 1298 |
+
else:
|
| 1299 |
+
estudiante = DimEstudiante(nombre_completo=nombre_resuelto, codigo_estudiante=record.codigo_estudiante, genero=record.genero, ciudad=record.ciudad)
|
| 1300 |
+
db.add(estudiante)
|
| 1301 |
+
db.flush()
|
| 1302 |
+
estudiantes_existentes.append(estudiante)
|
| 1303 |
else:
|
| 1304 |
+
# Mock student for MARKETING/SURVEYS if none exists to satisfy foreign keys
|
| 1305 |
+
estudiante = estudiantes_existentes[0] if estudiantes_existentes else DimEstudiante(nombre_completo="Anonimo")
|
| 1306 |
+
if not estudiantes_existentes:
|
| 1307 |
+
db.add(estudiante)
|
| 1308 |
+
db.flush()
|
| 1309 |
+
estudiantes_existentes.append(estudiante)
|
| 1310 |
|
| 1311 |
+
candidatos_difusos = get_top_matches(nombre_resuelto, estudiantes_existentes, top_k=5) if requiere_revision or (confianza_ia < 0.60 and area in ['ACADEMIC', 'FINANCE']) else []
|
| 1312 |
|
| 1313 |
# Calculo de alertas
|
| 1314 |
alertas = []
|
| 1315 |
+
if area == 'ACADEMIC':
|
| 1316 |
+
if getattr(record, 'nota_detectada', 100) <= 70.0:
|
| 1317 |
+
alertas.append("RIESGO_ACADEMICO_CRITICO")
|
| 1318 |
+
if getattr(record, 'asistencia', 100) < 70.0 or getattr(record, 'incumplimiento_tareas', 0) > 30.0:
|
| 1319 |
+
alertas.append("RIESGO_DESERCION_ALTA")
|
| 1320 |
|
| 1321 |
# Ensure dimensions exist using in-memory cache to prevent lock contention
|
| 1322 |
docente_name = getattr(record, 'docente', None) or "Docente Generico"
|
|
|
|
| 1355 |
db.flush()
|
| 1356 |
|
| 1357 |
requiere_revision = False
|
| 1358 |
+
if confianza_ia < 0.60 and area in ['ACADEMIC', 'FINANCE']:
|
| 1359 |
log = LogAuditoriaNlp(
|
| 1360 |
texto_original=nombre_resuelto,
|
| 1361 |
prediccion_beto=nombre_resuelto,
|
|
|
|
| 1367 |
db.flush()
|
| 1368 |
requiere_revision = True
|
| 1369 |
|
| 1370 |
+
# Insert into Constellation Schema ALWAYS based on area
|
| 1371 |
+
# ELIMINADO: La inserción a las tablas de hechos ahora OCURRE ÚNICAMENTE en /api/v1/ingesta/bulk
|
| 1372 |
+
# para evitar duplicación de datos entre el análisis y la confirmación final.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1373 |
|
| 1374 |
results.append({
|
| 1375 |
"anonymized_name": anonymize_name(nombre_resuelto),
|
|
|
|
| 1378 |
"alertas": alertas,
|
| 1379 |
"requiere_revision": requiere_revision,
|
| 1380 |
"status": "pending_human_review" if requiere_revision else "inserted",
|
| 1381 |
+
"candidatos_difusos": candidatos_difusos,
|
| 1382 |
+
"area_asignada": area
|
| 1383 |
})
|
| 1384 |
|
| 1385 |
try:
|
database.py
CHANGED
|
@@ -171,7 +171,7 @@ class FactEvaluacionDocente(Base):
|
|
| 171 |
|
| 172 |
class FactMarketingInscripciones(Base):
|
| 173 |
"""Métricas de OKRs de Marketing y ventas."""
|
| 174 |
-
__tablename__ = "
|
| 175 |
id_hecho_mkt = Column(Integer, primary_key=True)
|
| 176 |
id_modulo = Column(Integer, ForeignKey("dim_modulo.id_modulo"))
|
| 177 |
id_tiempo = Column(Integer, ForeignKey("dim_tiempo.id_tiempo"))
|
|
@@ -182,7 +182,7 @@ class FactMarketingInscripciones(Base):
|
|
| 182 |
|
| 183 |
class FactRentabilidadPresupuesto(Base):
|
| 184 |
"""Indicadores de Rentabilidad, Egresos y EBITDA (Ejecutado vs Meta)."""
|
| 185 |
-
__tablename__ = "
|
| 186 |
id_hecho_rent = Column(Integer, primary_key=True)
|
| 187 |
id_modulo = Column(Integer, ForeignKey("dim_modulo.id_modulo"))
|
| 188 |
id_tiempo = Column(Integer, ForeignKey("dim_tiempo.id_tiempo"))
|
|
|
|
| 171 |
|
| 172 |
class FactMarketingInscripciones(Base):
|
| 173 |
"""Métricas de OKRs de Marketing y ventas."""
|
| 174 |
+
__tablename__ = "fact_marketing"
|
| 175 |
id_hecho_mkt = Column(Integer, primary_key=True)
|
| 176 |
id_modulo = Column(Integer, ForeignKey("dim_modulo.id_modulo"))
|
| 177 |
id_tiempo = Column(Integer, ForeignKey("dim_tiempo.id_tiempo"))
|
|
|
|
| 182 |
|
| 183 |
class FactRentabilidadPresupuesto(Base):
|
| 184 |
"""Indicadores de Rentabilidad, Egresos y EBITDA (Ejecutado vs Meta)."""
|
| 185 |
+
__tablename__ = "fact_rentabilidad"
|
| 186 |
id_hecho_rent = Column(Integer, primary_key=True)
|
| 187 |
id_modulo = Column(Integer, ForeignKey("dim_modulo.id_modulo"))
|
| 188 |
id_tiempo = Column(Integer, ForeignKey("dim_tiempo.id_tiempo"))
|