import datetime import logging from fastapi import FastAPI, Depends, HTTPException, status from pydantic import BaseModel from sqlalchemy.orm import Session from database import ( get_db, DimEstudiante, DimDocente, DimModulo, DimTiempo, DimOrigenDocumental, Users, FactRendimientoAcademico, ) from ner_engine import ner_engine logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) app = FastAPI( title="GiraGroup BI Backend Cloud", description="API para Tecnologías Emergentes II con BETO y Supabase", version="1.0.0" ) # El nombre del comment en el esquema DDL de Supabase indica que el CHECK de # tipo_documento es: ('SHEET', 'FORM', 'MOODLE', 'XLSX') TIPO_DOC_VALIDO = "SHEET" class ProcessSheetPayload(BaseModel): texto_celda: str nota_detectada: float asistencia: float incumplimiento_tareas: float id_docente: int id_modulo: int id_tiempo: int id_documento: int id_usuario: int @app.get("/") def read_root(): return { "status": "healthy", "service": "GiraGroup BI Backend API Cloud", "ner_initialized": ner_engine._initialized or ner_engine.pipeline is not None } @app.post("/api/v1/ingesta/tabular", status_code=status.HTTP_201_CREATED) def procesar_registro_tabular(payload: ProcessSheetPayload, db: Session = Depends(get_db)): try: # 1. NLP con BETO entidades = ner_engine.extract_entities(payload.texto_celda) confianza_ia = sum([e["score"] for e in entidades]) / len(entidades) if entidades else 1.0 forzar_revision = confianza_ia < 0.60 # 2. Dimensión Estudiante nombre_resuelto = payload.texto_celda.strip() estudiante = db.query(DimEstudiante).filter( DimEstudiante.nombre_completo == nombre_resuelto ).first() if not estudiante: estudiante = DimEstudiante(nombre_completo=nombre_resuelto) db.add(estudiante) db.flush() # 3. Dimensión Docente — columnas reales: nombre_completo, area_especialidad if not db.query(DimDocente).filter(DimDocente.id_docente == payload.id_docente).first(): db.add(DimDocente( id_docente=payload.id_docente, nombre_completo="Docente Generico", area_especialidad="Generico" )) # 4. Dimensión Módulo — columnas reales: nombre_modulo, nombre_institucion, programa if not db.query(DimModulo).filter(DimModulo.id_modulo == payload.id_modulo).first(): db.add(DimModulo( id_modulo=payload.id_modulo, nombre_modulo="Modulo Generico", nombre_institucion="GiraGroup", programa="General" )) # 5. Dimensión Tiempo — columnas reales: gestion, semestre, mes if not db.query(DimTiempo).filter(DimTiempo.id_tiempo == payload.id_tiempo).first(): db.add(DimTiempo( id_tiempo=payload.id_tiempo, gestion=2026, semestre=1, mes="Mayo" )) # 6. Dimensión Origen Documental — tabla real: dim_origen_documental # CHECK: tipo_documento IN ('SHEET', 'FORM', 'MOODLE', 'XLSX') if not db.query(DimOrigenDocumental).filter( DimOrigenDocumental.id_documento == payload.id_documento ).first(): db.add(DimOrigenDocumental( id_documento=payload.id_documento, tipo_documento=TIPO_DOC_VALIDO, nombre_archivo="carga_automatica" )) # 7. Usuario — tabla real: users (id, username, hashed_password, role) if not db.query(Users).filter(Users.id == payload.id_usuario).first(): db.add(Users( id=payload.id_usuario, username=f"sistema_{payload.id_usuario}", hashed_password="$placeholder$", role="admin" )) db.flush() # 8. Alertas estratégicas alertas_disparadas = [] if payload.nota_detectada <= 70.0: alertas_disparadas.append("RIESGO_ACADEMICO_CRITICO") if payload.asistencia < 70.0 or payload.incumplimiento_tareas > 30.0: alertas_disparadas.append("RIESGO_DESERCION_ALTA") # 9. Insertar hecho con las FK correctas nuevo_hecho = FactRendimientoAcademico( id_estudiante=estudiante.id_estudiante, id_docente=payload.id_docente, id_modulo=payload.id_modulo, id_tiempo=payload.id_tiempo, id_documento=payload.id_documento, id_usuario_carga=payload.id_usuario, nota_final=payload.nota_detectada, asistencia_pct=payload.asistencia, incumplimiento_actividades_pct=payload.incumplimiento_tareas, nivel_confianza_ia=confianza_ia, requiere_revision=forzar_revision ) db.add(nuevo_hecho) db.commit() return { "status": "processed", "id_estudiante_asignado": estudiante.id_estudiante, "confianza_modelo_beto": round(confianza_ia, 4), "requiere_auditoria_humana": forzar_revision, "alertas_estrategicas": alertas_disparadas } except Exception as err: db.rollback() logger.error(f"Error crítico en backend 500: {err}") raise HTTPException(status_code=500, detail=str(err)) @app.get("/api/v1/riesgos/cruzado") def obtener_riesgos_cruzados(limite_nota: float = 70.0, min_cuotas: int = 2, db: Session = Depends(get_db)): try: resultados = db.query(DimEstudiante, FactRendimientoAcademico).\ join(FactRendimientoAcademico, DimEstudiante.id_estudiante == FactRendimientoAcademico.id_estudiante).\ filter(FactRendimientoAcademico.nota_final <= limite_nota).\ all() data = [] for est, fact in resultados: data.append({ "estudiante": est.nombre_completo, "codigo": f"EST-{est.id_estudiante:06d}", "rendimiento": { "nota_actual": float(fact.nota_final), "estado_academico": "CRÍTICO" if fact.nota_final <= 70 else "REGULAR" }, "finanzas": { "cuotas_mora": min_cuotas, "deuda_total": 350.0 * min_cuotas, "estado_cartera": "MORA" }, "nivel_riesgo_global": "ALTO - CRÍTICO" }) return {"status": "success", "data": data} except Exception as e: logger.error(f"Fallo en la resolución del query OLAP: {e}") raise HTTPException(status_code=500, detail=str(e))