Adzacam commited on
Commit
0d70e89
·
1 Parent(s): 5addcc3

Fix: Alinear modelos SQLAlchemy con DDL real de Supabase

Browse files
Files changed (2) hide show
  1. app.py +78 -34
  2. database.py +74 -44
app.py CHANGED
@@ -1,10 +1,18 @@
1
  import datetime
2
  import logging
3
- from typing import List
4
  from fastapi import FastAPI, Depends, HTTPException, status
5
  from pydantic import BaseModel
6
  from sqlalchemy.orm import Session
7
- from database import get_db, DimEstudiante, DimDocente, DimModulo, DimTiempo, DimDocumento, DimUsuario, FactRendimientoAcademico
 
 
 
 
 
 
 
 
 
8
  from ner_engine import ner_engine
9
 
10
  logging.basicConfig(level=logging.INFO)
@@ -16,6 +24,10 @@ app = FastAPI(
16
  version="1.0.0"
17
  )
18
 
 
 
 
 
19
  class ProcessSheetPayload(BaseModel):
20
  texto_celda: str
21
  nota_detectada: float
@@ -37,42 +49,78 @@ def read_root():
37
 
38
  @app.post("/api/v1/ingesta/tabular", status_code=status.HTTP_201_CREATED)
39
  def procesar_registro_tabular(payload: ProcessSheetPayload, db: Session = Depends(get_db)):
40
- try: # Todo envuelto en un try-except
 
41
  entidades = ner_engine.extract_entities(payload.texto_celda)
42
  confianza_ia = sum([e["score"] for e in entidades]) / len(entidades) if entidades else 1.0
43
  forzar_revision = confianza_ia < 0.60
44
 
 
45
  nombre_resuelto = payload.texto_celda.strip()
46
- estudiante = db.query(DimEstudiante).filter(DimEstudiante.nombre_completo == nombre_resuelto).first()
47
-
 
48
  if not estudiante:
49
  estudiante = DimEstudiante(nombre_completo=nombre_resuelto)
50
  db.add(estudiante)
51
- db.flush() # Importante: Usar flush para obtener el ID sin confirmar transacción final
52
 
 
53
  if not db.query(DimDocente).filter(DimDocente.id_docente == payload.id_docente).first():
54
- db.add(DimDocente(id_docente=payload.id_docente, nombre="Docente Generico", especialidad="Generico"))
55
-
 
 
 
 
 
56
  if not db.query(DimModulo).filter(DimModulo.id_modulo == payload.id_modulo).first():
57
- db.add(DimModulo(id_modulo=payload.id_modulo, nombre_modulo="Modulo Generico", version="1.0"))
58
-
 
 
 
 
 
 
59
  if not db.query(DimTiempo).filter(DimTiempo.id_tiempo == payload.id_tiempo).first():
60
- db.add(DimTiempo(id_tiempo=payload.id_tiempo, anio=2026, mes=5, dia=30, trimestre=2))
61
-
62
- if not db.query(DimDocumento).filter(DimDocumento.id_documento == payload.id_documento).first():
63
- db.add(DimDocumento(id_documento=payload.id_documento, tipo_documento="Consolidado Generico"))
64
-
65
- if not db.query(DimUsuario).filter(DimUsuario.id_usuario == payload.id_usuario).first():
66
- db.add(DimUsuario(id_usuario=payload.id_usuario, rol="Sistema", nombre="Admin"))
67
-
68
- db.flush() # Confirmamos la creación de las dimensiones
 
 
 
 
 
 
 
 
69
 
 
 
 
 
 
 
 
 
 
 
 
 
70
  alertas_disparadas = []
71
  if payload.nota_detectada <= 70.0:
72
  alertas_disparadas.append("RIESGO_ACADEMICO_CRITICO")
73
  if payload.asistencia < 70.0 or payload.incumplimiento_tareas > 30.0:
74
  alertas_disparadas.append("RIESGO_DESERCION_ALTA")
75
 
 
76
  nuevo_hecho = FactRendimientoAcademico(
77
  id_estudiante=estudiante.id_estudiante,
78
  id_docente=payload.id_docente,
@@ -87,10 +135,8 @@ def procesar_registro_tabular(payload: ProcessSheetPayload, db: Session = Depend
87
  requiere_revision=forzar_revision
88
  )
89
  db.add(nuevo_hecho)
90
-
91
- # Una vez que todo está correcto, hacemos el commit final
92
- db.commit()
93
-
94
  return {
95
  "status": "processed",
96
  "id_estudiante_asignado": estudiante.id_estudiante,
@@ -99,39 +145,37 @@ def procesar_registro_tabular(payload: ProcessSheetPayload, db: Session = Depend
99
  "alertas_estrategicas": alertas_disparadas
100
  }
101
  except Exception as err:
102
- db.rollback() # Si falla, deshacemos todo para mantener la integridad
103
  logger.error(f"Error crítico en backend 500: {err}")
104
- # Enviar el error real al frontend para depurar
105
  raise HTTPException(status_code=500, detail=str(err))
106
 
 
107
  @app.get("/api/v1/riesgos/cruzado")
108
  def obtener_riesgos_cruzados(limite_nota: float = 70.0, min_cuotas: int = 2, db: Session = Depends(get_db)):
109
  try:
110
- # Inner Join vectorial entre la dimensión y la tabla de hechos
111
  resultados = db.query(DimEstudiante, FactRendimientoAcademico).\
112
  join(FactRendimientoAcademico, DimEstudiante.id_estudiante == FactRendimientoAcademico.id_estudiante).\
113
  filter(FactRendimientoAcademico.nota_final <= limite_nota).\
114
  all()
115
-
116
  data = []
117
  for est, fact in resultados:
118
- # Serialización estricta bajo el contrato JSON esperado por AlertDashboard.jsx
119
  data.append({
120
  "estudiante": est.nombre_completo,
121
  "codigo": f"EST-{est.id_estudiante:06d}",
122
  "rendimiento": {
123
- "nota_actual": fact.nota_final,
124
- "estado_academico": "CRÍTICO"
125
  },
126
  "finanzas": {
127
- "cuotas_mora": min_cuotas,
128
- "deuda_total": 350.0 * min_cuotas, # Proyección estática hasta integrar fact_situacion_financiera
129
  "estado_cartera": "MORA"
130
  },
131
  "nivel_riesgo_global": "ALTO - CRÍTICO"
132
  })
133
-
134
  return {"status": "success", "data": data}
135
  except Exception as e:
136
  logger.error(f"Fallo en la resolución del query OLAP: {e}")
137
- raise HTTPException(status_code=500, detail="Error de lectura en el esquema estrella.")
 
1
  import datetime
2
  import logging
 
3
  from fastapi import FastAPI, Depends, HTTPException, status
4
  from pydantic import BaseModel
5
  from sqlalchemy.orm import Session
6
+ from database import (
7
+ get_db,
8
+ DimEstudiante,
9
+ DimDocente,
10
+ DimModulo,
11
+ DimTiempo,
12
+ DimOrigenDocumental,
13
+ Users,
14
+ FactRendimientoAcademico,
15
+ )
16
  from ner_engine import ner_engine
17
 
18
  logging.basicConfig(level=logging.INFO)
 
24
  version="1.0.0"
25
  )
26
 
27
+ # El nombre del comment en el esquema DDL de Supabase indica que el CHECK de
28
+ # tipo_documento es: ('SHEET', 'FORM', 'MOODLE', 'XLSX')
29
+ TIPO_DOC_VALIDO = "SHEET"
30
+
31
  class ProcessSheetPayload(BaseModel):
32
  texto_celda: str
33
  nota_detectada: float
 
49
 
50
  @app.post("/api/v1/ingesta/tabular", status_code=status.HTTP_201_CREATED)
51
  def procesar_registro_tabular(payload: ProcessSheetPayload, db: Session = Depends(get_db)):
52
+ try:
53
+ # 1. NLP con BETO
54
  entidades = ner_engine.extract_entities(payload.texto_celda)
55
  confianza_ia = sum([e["score"] for e in entidades]) / len(entidades) if entidades else 1.0
56
  forzar_revision = confianza_ia < 0.60
57
 
58
+ # 2. Dimensión Estudiante
59
  nombre_resuelto = payload.texto_celda.strip()
60
+ estudiante = db.query(DimEstudiante).filter(
61
+ DimEstudiante.nombre_completo == nombre_resuelto
62
+ ).first()
63
  if not estudiante:
64
  estudiante = DimEstudiante(nombre_completo=nombre_resuelto)
65
  db.add(estudiante)
66
+ db.flush()
67
 
68
+ # 3. Dimensión Docente — columnas reales: nombre_completo, area_especialidad
69
  if not db.query(DimDocente).filter(DimDocente.id_docente == payload.id_docente).first():
70
+ db.add(DimDocente(
71
+ id_docente=payload.id_docente,
72
+ nombre_completo="Docente Generico",
73
+ area_especialidad="Generico"
74
+ ))
75
+
76
+ # 4. Dimensión Módulo — columnas reales: nombre_modulo, nombre_institucion, programa
77
  if not db.query(DimModulo).filter(DimModulo.id_modulo == payload.id_modulo).first():
78
+ db.add(DimModulo(
79
+ id_modulo=payload.id_modulo,
80
+ nombre_modulo="Modulo Generico",
81
+ nombre_institucion="GiraGroup",
82
+ programa="General"
83
+ ))
84
+
85
+ # 5. Dimensión Tiempo — columnas reales: gestion, semestre, mes
86
  if not db.query(DimTiempo).filter(DimTiempo.id_tiempo == payload.id_tiempo).first():
87
+ db.add(DimTiempo(
88
+ id_tiempo=payload.id_tiempo,
89
+ gestion=2026,
90
+ semestre=1,
91
+ mes="Mayo"
92
+ ))
93
+
94
+ # 6. Dimensión Origen Documental — tabla real: dim_origen_documental
95
+ # CHECK: tipo_documento IN ('SHEET', 'FORM', 'MOODLE', 'XLSX')
96
+ if not db.query(DimOrigenDocumental).filter(
97
+ DimOrigenDocumental.id_documento == payload.id_documento
98
+ ).first():
99
+ db.add(DimOrigenDocumental(
100
+ id_documento=payload.id_documento,
101
+ tipo_documento=TIPO_DOC_VALIDO,
102
+ nombre_archivo="carga_automatica"
103
+ ))
104
 
105
+ # 7. Usuario — tabla real: users (id, username, hashed_password, role)
106
+ if not db.query(Users).filter(Users.id == payload.id_usuario).first():
107
+ db.add(Users(
108
+ id=payload.id_usuario,
109
+ username=f"sistema_{payload.id_usuario}",
110
+ hashed_password="$placeholder$",
111
+ role="admin"
112
+ ))
113
+
114
+ db.flush()
115
+
116
+ # 8. Alertas estratégicas
117
  alertas_disparadas = []
118
  if payload.nota_detectada <= 70.0:
119
  alertas_disparadas.append("RIESGO_ACADEMICO_CRITICO")
120
  if payload.asistencia < 70.0 or payload.incumplimiento_tareas > 30.0:
121
  alertas_disparadas.append("RIESGO_DESERCION_ALTA")
122
 
123
+ # 9. Insertar hecho con las FK correctas
124
  nuevo_hecho = FactRendimientoAcademico(
125
  id_estudiante=estudiante.id_estudiante,
126
  id_docente=payload.id_docente,
 
135
  requiere_revision=forzar_revision
136
  )
137
  db.add(nuevo_hecho)
138
+ db.commit()
139
+
 
 
140
  return {
141
  "status": "processed",
142
  "id_estudiante_asignado": estudiante.id_estudiante,
 
145
  "alertas_estrategicas": alertas_disparadas
146
  }
147
  except Exception as err:
148
+ db.rollback()
149
  logger.error(f"Error crítico en backend 500: {err}")
 
150
  raise HTTPException(status_code=500, detail=str(err))
151
 
152
+
153
  @app.get("/api/v1/riesgos/cruzado")
154
  def obtener_riesgos_cruzados(limite_nota: float = 70.0, min_cuotas: int = 2, db: Session = Depends(get_db)):
155
  try:
 
156
  resultados = db.query(DimEstudiante, FactRendimientoAcademico).\
157
  join(FactRendimientoAcademico, DimEstudiante.id_estudiante == FactRendimientoAcademico.id_estudiante).\
158
  filter(FactRendimientoAcademico.nota_final <= limite_nota).\
159
  all()
160
+
161
  data = []
162
  for est, fact in resultados:
 
163
  data.append({
164
  "estudiante": est.nombre_completo,
165
  "codigo": f"EST-{est.id_estudiante:06d}",
166
  "rendimiento": {
167
+ "nota_actual": float(fact.nota_final),
168
+ "estado_academico": "CRÍTICO" if fact.nota_final <= 70 else "REGULAR"
169
  },
170
  "finanzas": {
171
+ "cuotas_mora": min_cuotas,
172
+ "deuda_total": 350.0 * min_cuotas,
173
  "estado_cartera": "MORA"
174
  },
175
  "nivel_riesgo_global": "ALTO - CRÍTICO"
176
  })
177
+
178
  return {"status": "success", "data": data}
179
  except Exception as e:
180
  logger.error(f"Fallo en la resolución del query OLAP: {e}")
181
+ raise HTTPException(status_code=500, detail=str(e))
database.py CHANGED
@@ -1,6 +1,7 @@
1
  import os
 
2
  from dotenv import load_dotenv
3
- from sqlalchemy import create_engine
4
  from sqlalchemy.orm import declarative_base, sessionmaker
5
 
6
  # Load environment variables from .env file
@@ -9,7 +10,6 @@ load_dotenv()
9
  DATABASE_URL = os.getenv("DATABASE_URL")
10
 
11
  if not DATABASE_URL:
12
- # Fallback to local postgres if not set for safety, or raise a warning
13
  DATABASE_URL = "postgresql://postgres:postgres@localhost:5432/postgres"
14
 
15
  # For Supabase, pool_pre_ping=True is highly recommended to handle stale connections
@@ -21,63 +21,93 @@ engine = create_engine(
21
 
22
  SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
23
 
24
- from sqlalchemy import Column, Integer, String, Numeric, Boolean, DateTime, ForeignKey
25
- import datetime
26
-
27
  Base = declarative_base()
28
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
  class DimEstudiante(Base):
30
  __tablename__ = "dim_estudiante"
31
- id_estudiante = Column(Integer, primary_key=True, index=True)
32
- nombre_completo = Column(String(200), nullable=False)
33
  codigo_estudiante = Column(String(50))
34
 
 
35
  class DimDocente(Base):
36
  __tablename__ = "dim_docente"
37
- id_docente = Column(Integer, primary_key=True)
38
- nombre = Column(String(200), nullable=False, default="Docente Generico")
39
- especialidad = Column(String(100), default="Generico")
 
40
 
41
  class DimModulo(Base):
42
  __tablename__ = "dim_modulo"
43
- id_modulo = Column(Integer, primary_key=True)
44
- nombre_modulo = Column(String(200), nullable=False, default="Modulo Generico")
45
- version = Column(String(50), default="1.0")
 
46
 
47
- class DimTiempo(Base):
48
- __tablename__ = "dim_tiempo"
49
- id_tiempo = Column(Integer, primary_key=True)
50
- anio = Column(Integer, nullable=False, default=2026)
51
- mes = Column(Integer, nullable=False, default=5)
52
- dia = Column(Integer, nullable=False, default=30)
53
- trimestre = Column(Integer, nullable=False, default=2)
54
-
55
- class DimDocumento(Base):
56
- __tablename__ = "dim_documento"
57
- id_documento = Column(Integer, primary_key=True)
58
- tipo_documento = Column(String(100), nullable=False, default="Consolidado Generico")
59
-
60
- class DimUsuario(Base):
61
- __tablename__ = "dim_usuario"
62
- id_usuario = Column(Integer, primary_key=True)
63
- rol = Column(String(50), nullable=False, default="Sistema")
64
- nombre = Column(String(100), nullable=False, default="Admin")
 
 
 
 
 
65
 
66
  class FactRendimientoAcademico(Base):
67
  __tablename__ = "fact_rendimiento_academico"
68
- id_hecho_aca = Column(Integer, primary_key=True, index=True)
69
- id_estudiante = Column(Integer, ForeignKey("dim_estudiante.id_estudiante"))
70
- id_docente = Column(Integer)
71
- id_modulo = Column(Integer)
72
- id_tiempo = Column(Integer)
73
- id_documento = Column(Integer)
74
- id_usuario_carga = Column(Integer)
75
- nota_final = Column(Numeric(5,2))
76
- asistencia_pct = Column(Numeric(5,2))
77
- incumplimiento_actividades_pct = Column(Numeric(5,2))
78
- nivel_confianza_ia = Column(Numeric(5,4))
79
- requiere_revision = Column(Boolean, default=False)
80
- created_at = Column(DateTime, default=datetime.datetime.utcnow)
 
 
 
 
 
 
 
 
 
 
 
 
81
 
82
 
83
  def get_db():
 
1
  import os
2
+ import datetime
3
  from dotenv import load_dotenv
4
+ from sqlalchemy import create_engine, Column, Integer, String, Numeric, Boolean, DateTime, ForeignKey, CheckConstraint
5
  from sqlalchemy.orm import declarative_base, sessionmaker
6
 
7
  # Load environment variables from .env file
 
10
  DATABASE_URL = os.getenv("DATABASE_URL")
11
 
12
  if not DATABASE_URL:
 
13
  DATABASE_URL = "postgresql://postgres:postgres@localhost:5432/postgres"
14
 
15
  # For Supabase, pool_pre_ping=True is highly recommended to handle stale connections
 
21
 
22
  SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
23
 
 
 
 
24
  Base = declarative_base()
25
 
26
+
27
+ # =============================================================================
28
+ # DIMENSIONES — mapeadas exactamente al DDL de Supabase
29
+ # =============================================================================
30
+
31
+ class DimTiempo(Base):
32
+ __tablename__ = "dim_tiempo"
33
+ id_tiempo = Column(Integer, primary_key=True)
34
+ gestion = Column(Integer, nullable=False, default=2026)
35
+ semestre = Column(Integer, default=1)
36
+ mes = Column(String(20), default="Mayo")
37
+
38
+
39
  class DimEstudiante(Base):
40
  __tablename__ = "dim_estudiante"
41
+ id_estudiante = Column(Integer, primary_key=True, index=True)
42
+ nombre_completo = Column(String(200), nullable=False)
43
  codigo_estudiante = Column(String(50))
44
 
45
+
46
  class DimDocente(Base):
47
  __tablename__ = "dim_docente"
48
+ id_docente = Column(Integer, primary_key=True)
49
+ nombre_completo = Column(String(200), nullable=False, default="Docente Generico")
50
+ area_especialidad = Column(String(200), default="Generico")
51
+
52
 
53
  class DimModulo(Base):
54
  __tablename__ = "dim_modulo"
55
+ id_modulo = Column(Integer, primary_key=True)
56
+ nombre_modulo = Column(String(200), nullable=False, default="Modulo Generico")
57
+ nombre_institucion = Column(String(200), nullable=False, default="GiraGroup")
58
+ programa = Column(String(200), default="General")
59
 
60
+
61
+ class DimOrigenDocumental(Base):
62
+ """Mapeada a la tabla dim_origen_documental en Supabase."""
63
+ __tablename__ = "dim_origen_documental"
64
+ id_documento = Column(Integer, primary_key=True)
65
+ tipo_documento = Column(String(10), default="SHEET")
66
+ nombre_archivo = Column(String(500), default="archivo_generico")
67
+ fecha_procesamiento = Column(DateTime, default=datetime.datetime.utcnow)
68
+
69
+
70
+ class Users(Base):
71
+ """Tabla de usuarios/autenticación en Supabase."""
72
+ __tablename__ = "users"
73
+ id = Column(Integer, primary_key=True)
74
+ username = Column(String(100), unique=True, nullable=False, default="sistema")
75
+ hashed_password = Column(String(255), nullable=False, default="$placeholder$")
76
+ role = Column(String(20), default="admin")
77
+ created_at = Column(DateTime, default=datetime.datetime.utcnow)
78
+
79
+
80
+ # =============================================================================
81
+ # TABLAS DE HECHOS
82
+ # =============================================================================
83
 
84
  class FactRendimientoAcademico(Base):
85
  __tablename__ = "fact_rendimiento_academico"
86
+ id_hecho_aca = Column(Integer, primary_key=True, index=True)
87
+ id_estudiante = Column(Integer, ForeignKey("dim_estudiante.id_estudiante"))
88
+ id_docente = Column(Integer, ForeignKey("dim_docente.id_docente"))
89
+ id_modulo = Column(Integer, ForeignKey("dim_modulo.id_modulo"))
90
+ id_tiempo = Column(Integer, ForeignKey("dim_tiempo.id_tiempo"))
91
+ id_documento = Column(Integer, ForeignKey("dim_origen_documental.id_documento"))
92
+ id_usuario_carga = Column(Integer, ForeignKey("users.id"))
93
+ nota_final = Column(Numeric(5, 2))
94
+ asistencia_pct = Column(Numeric(5, 2))
95
+ incumplimiento_actividades_pct = Column(Numeric(5, 2), default=0.00)
96
+ nivel_confianza_ia = Column(Numeric(5, 4))
97
+ requiere_revision = Column(Boolean, default=False)
98
+ created_at = Column(DateTime, default=datetime.datetime.utcnow)
99
+
100
+
101
+ class FactSituacionFinanciera(Base):
102
+ __tablename__ = "fact_situacion_financiera"
103
+ id_hecho_fin = Column(Integer, primary_key=True)
104
+ id_estudiante = Column(Integer, ForeignKey("dim_estudiante.id_estudiante"))
105
+ id_tiempo = Column(Integer, ForeignKey("dim_tiempo.id_tiempo"))
106
+ monto_deuda = Column(Numeric(10, 2))
107
+ cuotas_impagas = Column(Integer)
108
+ estado_cartera = Column(String(20))
109
+ tipo_alerta = Column(String(20))
110
+ fecha_registro = Column(DateTime, default=datetime.datetime.utcnow)
111
 
112
 
113
  def get_db():