Diiegoal commited on
Commit
d623240
·
1 Parent(s): e85d90a

Actualizo la app

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
.dockerignore ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .git
2
+ .gitignore
3
+ .pytest_cache
4
+ __pycache__/
5
+ **/__pycache__/
6
+ *.pyc
7
+ *.pyo
8
+ *.pyd
9
+ *.db
10
+ venv/
11
+ .venv/
12
+ .env
13
+ alembic_smoke.db
14
+ EDA_For_All_Tree.ipynb
15
+ EDA_For_All_Tree_clean.ipynb
.env.example ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ORACULO_APP_NAME=Oraculo Adult Income API
2
+ ORACULO_APP_VERSION=2.0.0
3
+ ORACULO_ENVIRONMENT=development
4
+ ORACULO_DEBUG=false
5
+
6
+ ORACULO_DATABASE_URL=sqlite:///./oraculo.db
7
+ ORACULO_DATABASE_ECHO=false
8
+ ORACULO_AUTO_CREATE_TABLES=true
9
+ ORACULO_AUTO_SEED_ADMIN=true
10
+ ORACULO_SEED_ADMIN_EMAIL=admin@example.com
11
+ ORACULO_SEED_ADMIN_PASSWORD=ChangeMe!12345
12
+ ORACULO_SEED_ADMIN_NAME=Administrator
13
+
14
+ ORACULO_MODEL_PATH=app/ml/pipeline_produccion.pkl
15
+
16
+ ORACULO_JWT_SECRET_KEY=replace-this-with-a-long-random-secret-at-least-32-chars
17
+ ORACULO_JWT_ALGORITHM=HS256
18
+ ORACULO_ACCESS_TOKEN_EXPIRE_MINUTES=60
19
+
20
+ ORACULO_ALLOWED_HOSTS=localhost,127.0.0.1,*.hf.space,*.huggingface.co
21
+ ORACULO_CORS_ALLOW_ORIGINS=http://localhost:3000,http://127.0.0.1:3000
22
+
23
+ ORACULO_MAX_REQUEST_SIZE_BYTES=32768
24
+ ORACULO_RATE_LIMIT_ENABLED=true
25
+ ORACULO_RATE_LIMIT_REQUESTS=60
26
+ ORACULO_RATE_LIMIT_WINDOW_SECONDS=60
27
+
28
+ ORACULO_DOCS_ENABLED=true
.gitignore ADDED
@@ -0,0 +1,124 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ==========================================
2
+ # OS / File Explorer
3
+ # ==========================================
4
+ .DS_Store
5
+ Thumbs.db
6
+ Desktop.ini
7
+ $RECYCLE.BIN/
8
+
9
+ # ==========================================
10
+ # Editors / IDEs
11
+ # ==========================================
12
+ .idea/
13
+ .vscode/
14
+ *.code-workspace
15
+
16
+ # ==========================================
17
+ # Python
18
+ # ==========================================
19
+ __pycache__/
20
+ *.py[cod]
21
+ *$py.class
22
+ *.so
23
+ .Python
24
+ .python-version
25
+
26
+ # ==========================================
27
+ # Virtual Environments
28
+ # ==========================================
29
+ venv/
30
+ .venv/
31
+ env/
32
+ ENV/
33
+
34
+ # ==========================================
35
+ # Environment / Secrets
36
+ # ==========================================
37
+ .env
38
+ .env.local
39
+ .env.*.local
40
+ !.env.example
41
+
42
+ # ==========================================
43
+ # Packaging / Build
44
+ # ==========================================
45
+ build/
46
+ dist/
47
+ site/
48
+ .eggs/
49
+ *.egg
50
+ *.egg-info/
51
+ pip-wheel-metadata/
52
+
53
+ # ==========================================
54
+ # Testing / Coverage / Type Checking
55
+ # ==========================================
56
+ .pytest_cache/
57
+ .coverage
58
+ .coverage.*
59
+ htmlcov/
60
+ .hypothesis/
61
+ .tox/
62
+ .nox/
63
+ .mypy_cache/
64
+ .pyre/
65
+ .ruff_cache/
66
+
67
+ # ==========================================
68
+ # Jupyter
69
+ # ==========================================
70
+ .ipynb_checkpoints/
71
+
72
+ # ==========================================
73
+ # Logs / Runtime Files
74
+ # ==========================================
75
+ *.log
76
+ logs/
77
+ *.pid
78
+ *.pid.lock
79
+ *.out
80
+ *.err
81
+
82
+ # ==========================================
83
+ # Local Databases / Storage
84
+ # ==========================================
85
+ *.db
86
+ *.db-shm
87
+ *.db-wal
88
+ *.sqlite
89
+ *.sqlite3
90
+ instance/
91
+
92
+ # ==========================================
93
+ # Alembic / Migration Local Noise
94
+ # ==========================================
95
+ alembic_smoke.db
96
+
97
+ # ==========================================
98
+ # Local ML / Notebook Outputs
99
+ # Keep the canonical model artifact tracked if needed.
100
+ # ==========================================
101
+ mlops_activos/
102
+ artifacts/
103
+ outputs/
104
+ reports/
105
+ tmp/
106
+ temp/
107
+ *.tmp
108
+ *.bak
109
+ *.orig
110
+
111
+ # ==========================================
112
+ # Hugging Face / Cache / Misc
113
+ # ==========================================
114
+ .cache/
115
+ .huggingface/
116
+
117
+ # ==========================================
118
+ # Frontend / Node (safe to keep even if unused)
119
+ # ==========================================
120
+ node_modules/
121
+ npm-debug.log*
122
+ yarn-debug.log*
123
+ yarn-error.log*
124
+ pnpm-debug.log*
Dockerfile ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ ENV PYTHONDONTWRITEBYTECODE=1 \
4
+ PYTHONUNBUFFERED=1 \
5
+ PIP_NO_CACHE_DIR=1 \
6
+ ORACULO_ENVIRONMENT=staging \
7
+ ORACULO_DOCS_ENABLED=true
8
+
9
+ RUN apt-get update \
10
+ && apt-get install -y --no-install-recommends libgomp1 \
11
+ && rm -rf /var/lib/apt/lists/*
12
+
13
+ RUN useradd --create-home --uid 1000 user
14
+
15
+ WORKDIR /app
16
+
17
+ COPY requirements.txt /app/requirements.txt
18
+
19
+ RUN pip install --upgrade pip \
20
+ && pip install --no-cache-dir -r /app/requirements.txt
21
+
22
+ COPY . /app
23
+
24
+ RUN mkdir -p /data \
25
+ && chown -R user:user /app /data
26
+
27
+ USER user
28
+
29
+ EXPOSE 7860
30
+
31
+ CMD ["sh", "-c", "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 7860"]
EDA_For_All_Tree.ipynb ADDED
The diff for this file is too large to render. See raw diff
 
EDA_For_All_Tree_clean.ipynb ADDED
The diff for this file is too large to render. See raw diff
 
README.md CHANGED
@@ -1,11 +1,330 @@
1
  ---
2
- title: Oraculo Api
3
- emoji: 🏆
4
- colorFrom: indigo
5
- colorTo: gray
6
  sdk: docker
 
 
7
  pinned: false
8
- license: mit
9
  ---
10
 
11
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: Oraculo Adult Income API
3
+ emoji: 🚀
4
+ colorFrom: green
5
+ colorTo: blue
6
  sdk: docker
7
+ app_port: 7860
8
+ base_path: /docs
9
  pinned: false
 
10
  ---
11
 
12
+ # Oraculo Adult Income API
13
+
14
+ API REST profesional para inferencia del dataset Adult Census Income, reconstruida con enfoque de clean code, seguridad por capas, pruebas agresivas y contrato estable entre notebook y producción.
15
+
16
+ ## Objetivo
17
+
18
+ Esta API resuelve tres problemas reales del proyecto:
19
+
20
+ 1. Exponer inferencia de modelo con un contrato HTTP limpio, autenticado y auditable.
21
+ 2. Blindar el salto entre `EDA_For_All_Tree_clean.ipynb` y el artefacto `pipeline_produccion.pkl`.
22
+ 3. Dejar una base escalable para crecer a más endpoints, más usuarios y despliegue en Render.
23
+
24
+ ## Stack elegido
25
+
26
+ Tecnologías aplicadas en la implementación final:
27
+
28
+ - `FastAPI`: framework principal, OpenAPI/Swagger, validación HTTP y alto rendimiento.
29
+ - `Python`: lenguaje base del servicio, notebook y pipeline.
30
+ - `Pydantic v2`: DTOs, validaciones estrictas, aliases y contratos de entrada/salida.
31
+ - `SQLAlchemy 2.0`: ORM principal y capa de persistencia.
32
+ - `Alembic`: migraciones versionadas de base de datos.
33
+ - `SQLite` por defecto y `PostgreSQL` listo por `DATABASE_URL`: desarrollo local y despliegue escalable.
34
+ - `JWT + bcrypt`: autenticación stateless y hashing de contraseñas.
35
+ - `Swagger/OpenAPI`: documentación viva de endpoints.
36
+ - `Pytest + TestClient`: pruebas HTTP, seguridad, errores y dominio.
37
+ - `Uvicorn`: servidor ASGI para local y producción.
38
+ - `Starlette middlewares`: CORS, GZip, Trusted Hosts, request id, límites de payload, rate limiting básico.
39
+ - `joblib + LightGBM/sklearn pipeline`: artefacto de inferencia.
40
+
41
+ Tecnología no seleccionada deliberadamente:
42
+
43
+ - `SQLModel`: no se usó en esta versión porque superpone responsabilidades con SQLAlchemy + Pydantic. Para este nivel de control y separación entre ORM y DTOs, SQLAlchemy 2.0 fue una mejor decisión.
44
+
45
+ Tecnologías adicionales que faltaban en la lista original y sí son importantes:
46
+
47
+ - `pydantic-settings` para configuración por entorno.
48
+ - `bcrypt` para hashing directo y estable.
49
+ - `httpx/TestClient` para pruebas HTTP.
50
+ - `Request ID / security headers / rate limiting` para endurecimiento operativo.
51
+
52
+ ## Arquitectura
53
+
54
+ La API quedó organizada por capas:
55
+
56
+ - `app/main.py`: app factory, lifespan, middlewares y bootstrap.
57
+ - `app/api/`: routers, versionado y dependencias.
58
+ - `app/core/`: configuración, seguridad, middleware, logging, errores.
59
+ - `app/db/`: base ORM, sesión, modelos, repositorios, seeds.
60
+ - `app/services/`: reglas de negocio.
61
+ - `app/ml/`: carga del artefacto y contrato con el pipeline.
62
+ - `app/schemas/`: DTOs HTTP.
63
+ - `alembic/`: migraciones.
64
+ - `tests/`: pruebas HTTP, seguridad, esquemas y modelo.
65
+
66
+ ## Funcionalidades incluidas
67
+
68
+ - Registro y login con JWT.
69
+ - Endpoint autenticado de predicción.
70
+ - Historial de predicciones por usuario.
71
+ - Consulta puntual por `prediction_id`.
72
+ - Health checks `live` y `ready`.
73
+ - Seeds de administrador por variables de entorno.
74
+ - Manejador de errores unificado.
75
+ - Headers de seguridad y request id.
76
+ - Protección por tamaño máximo de payload.
77
+ - Rate limiting in-memory.
78
+ - Compatibilidad con el artefacto actual del modelo.
79
+
80
+ ## Endpoints
81
+
82
+ ### Salud
83
+
84
+ - `GET /`
85
+ - `GET /api/v1/health/live`
86
+ - `GET /api/v1/health/ready`
87
+
88
+ ### Autenticación
89
+
90
+ - `POST /api/v1/auth/register`
91
+ - `POST /api/v1/auth/login`
92
+ - `GET /api/v1/auth/me`
93
+
94
+ ### Predicciones
95
+
96
+ - `POST /api/v1/predictions`
97
+ - `GET /api/v1/predictions`
98
+ - `GET /api/v1/predictions/{prediction_id}`
99
+
100
+ ## Seguridad aplicada
101
+
102
+ ### OWASP / API hardening
103
+
104
+ - JWT firmado y validado.
105
+ - Contraseñas hasheadas con `bcrypt`.
106
+ - DTOs con `extra="forbid"` para bloquear campos sorpresa.
107
+ - Validación fuerte de tipos, rangos y longitudes.
108
+ - `TrustedHostMiddleware` para rechazar hosts no permitidos.
109
+ - Headers de seguridad (`CSP`, `X-Frame-Options`, `nosniff`, `Cache-Control`).
110
+ - Límite de tamaño de payload.
111
+ - Rate limiting básico por IP.
112
+ - Errores controlados sin exponer stacktrace al cliente.
113
+ - Persistencia auditada de cada predicción.
114
+
115
+ ### Vulnerabilidades orientadas a LLM
116
+
117
+ Tu lista incluía amenazas como `many-shot jailbreaking`, `indirect prompt injection`, `context hijacking`, `context poisoning`, `lost in the middle` y `context overflow`.
118
+
119
+ Punto importante:
120
+
121
+ - Esta API no expone un endpoint LLM conversacional, así que esas amenazas no aplican de forma directa al plano HTTP actual.
122
+ - Sí aplican al notebook y a cualquier automatización futura que use prompts, agentes o generación asistida.
123
+
124
+ Mitigaciones prácticas adoptadas o recomendadas:
125
+
126
+ - Tratar todo texto externo como entrada no confiable.
127
+ - No ejecutar prompts del usuario dentro del backend de inferencia.
128
+ - Mantener separación entre features del modelo y texto libre.
129
+ - Exportar el artefacto desde el notebook con validación previa.
130
+ - Generar `model_manifest.json` junto con el `.pkl` para trazabilidad.
131
+ - Evitar que la API acepte instrucciones ejecutables o plantillas arbitrarias.
132
+
133
+ ## Contrato Notebook -> API
134
+
135
+ El notebook limpio `EDA_For_All_Tree_clean.ipynb` quedó orientado a producción:
136
+
137
+ - Exporta `pipeline_produccion.pkl`.
138
+ - Valida el pipeline con una muestra real antes de serializar.
139
+ - Genera `model_manifest.json`.
140
+ - Reúne artefactos serializables y modelos de forma explícita.
141
+
142
+ El backend, a través de `ModelManager` y `PipelineProduccionMLOps`, puede:
143
+
144
+ - Cargar el artefacto.
145
+ - Reconstruir artefactos faltantes si el notebook exportó algo incompleto.
146
+ - Leer el `model_manifest.json` cuando exista.
147
+
148
+ ## Base de datos
149
+
150
+ Entidades incluidas:
151
+
152
+ - `users`
153
+ - `prediction_logs`
154
+
155
+ Persistencia incluida:
156
+
157
+ - usuarios autenticados
158
+ - historial de predicciones
159
+ - payload original
160
+ - payload normalizado
161
+ - request id
162
+ - latencia
163
+ - versión del modelo
164
+ - hash del payload
165
+
166
+ ## Migraciones Alembic
167
+
168
+ Inicialización incluida:
169
+
170
+ - `alembic.ini`
171
+ - `alembic/env.py`
172
+ - migración inicial `initial_api_schema`
173
+
174
+ Comandos útiles:
175
+
176
+ ```bash
177
+ alembic upgrade head
178
+ alembic revision --autogenerate -m "descripcion"
179
+ alembic downgrade -1
180
+ ```
181
+
182
+ ## Seeds
183
+
184
+ Si defines:
185
+
186
+ - `ORACULO_SEED_ADMIN_EMAIL`
187
+ - `ORACULO_SEED_ADMIN_PASSWORD`
188
+ - `ORACULO_AUTO_SEED_ADMIN=true`
189
+
190
+ la aplicación crea un administrador por bootstrap si no existe.
191
+
192
+ ## Configuración
193
+
194
+ Variables principales:
195
+
196
+ - `ORACULO_DATABASE_URL`
197
+ - `ORACULO_MODEL_PATH`
198
+ - `ORACULO_JWT_SECRET_KEY`
199
+ - `ORACULO_ALLOWED_HOSTS`
200
+ - `ORACULO_CORS_ALLOW_ORIGINS`
201
+ - `ORACULO_RATE_LIMIT_REQUESTS`
202
+ - `ORACULO_RATE_LIMIT_WINDOW_SECONDS`
203
+ - `ORACULO_MAX_REQUEST_SIZE_BYTES`
204
+
205
+ Toma como base el archivo `.env.example`.
206
+
207
+ ## Ejecución local
208
+
209
+ ```bash
210
+ python -m venv venv
211
+ venv\Scripts\activate
212
+ pip install -r requirements.txt
213
+ alembic upgrade head
214
+ uvicorn app.main:app --reload
215
+ ```
216
+
217
+ Swagger:
218
+
219
+ - `http://127.0.0.1:8000/docs`
220
+
221
+ ## Tests
222
+
223
+ La suite prueba:
224
+
225
+ - esquemas
226
+ - autenticación
227
+ - autorización
228
+ - predicción
229
+ - historial
230
+ - aislamiento de datos entre usuarios
231
+ - health checks
232
+ - middlewares de seguridad
233
+ - payload demasiado grande
234
+ - rate limit
235
+ - modelo real (`pipeline_produccion.pkl`)
236
+
237
+ Ejecución:
238
+
239
+ ```bash
240
+ venv\Scripts\pytest -q
241
+ ```
242
+
243
+ Estado actual de la suite:
244
+
245
+ - `33 passed`
246
+
247
+ ## Despliegue en Render
248
+
249
+ Recomendación:
250
+
251
+ 1. Subir el proyecto con `requirements.txt`.
252
+ 2. Configurar `Start Command`:
253
+
254
+ ```bash
255
+ alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port $PORT
256
+ ```
257
+
258
+ 3. Definir variables de entorno:
259
+
260
+ - `ORACULO_ENVIRONMENT=production`
261
+ - `ORACULO_DATABASE_URL=<postgres-url>`
262
+ - `ORACULO_JWT_SECRET_KEY=<secret-largo>`
263
+ - `ORACULO_ALLOWED_HOSTS=<tu-dominio-onrender>`
264
+ - `ORACULO_DOCS_ENABLED=false`
265
+
266
+ 4. Subir `pipeline_produccion.pkl` y, cuando exista, `model_manifest.json`.
267
+
268
+ ## Despliegue en Hugging Face Spaces
269
+
270
+ Este repositorio ya quedó preparado para un `Docker Space`.
271
+
272
+ Archivos listos para eso:
273
+
274
+ - `Dockerfile`
275
+ - `.dockerignore`
276
+ - front matter de Spaces al inicio de este `README.md`
277
+
278
+ Pasos:
279
+
280
+ 1. Crea un nuevo Space en Hugging Face.
281
+ 2. Selecciona `Docker` como SDK.
282
+ 3. Sube este proyecto completo.
283
+ 4. En `Settings > Variables and secrets`, configura como mínimo:
284
+
285
+ - `ORACULO_JWT_SECRET_KEY`
286
+ - `ORACULO_SEED_ADMIN_EMAIL`
287
+ - `ORACULO_SEED_ADMIN_PASSWORD`
288
+ - `ORACULO_ALLOWED_HOSTS`
289
+
290
+ 5. Si quieres persistencia real para SQLite, usa almacenamiento persistente y define:
291
+
292
+ ```bash
293
+ ORACULO_DATABASE_URL=sqlite:////data/oraculo.db
294
+ ```
295
+
296
+ Si no activas almacenamiento persistente, la base será efímera y se reiniciará con el Space.
297
+
298
+ Notas importantes para Spaces:
299
+
300
+ - Swagger abrirá en `/docs` porque el Space usa `base_path: /docs`.
301
+ - El contenedor escucha en `7860`, que es el puerto esperado por el Space.
302
+ - Si quieres ocultar Swagger más adelante, cambia `ORACULO_DOCS_ENABLED=false`.
303
+ - El modelo `pipeline_produccion.pkl`, `adult.csv` y el código backend deben permanecer en el repositorio o en el contexto del contenedor.
304
+
305
+ ## Qué falta para una versión todavía más dura
306
+
307
+ Si quieres llevarla más arriba todavía, las siguientes mejoras son naturales:
308
+
309
+ - rate limiting distribuido con Redis
310
+ - refresh tokens
311
+ - roles más finos (`admin`, `analyst`, `service`)
312
+ - observabilidad con Prometheus / OpenTelemetry
313
+ - CI con lint, type-check y cobertura
314
+ - separación formal entre API pública e interna
315
+ - Postgres nativo en desarrollo
316
+
317
+ ## Resumen ejecutivo
318
+
319
+ Esta versión ya no es una API improvisada alrededor de un notebook. Ahora tienes una base con:
320
+
321
+ - arquitectura limpia
322
+ - autenticación
323
+ - persistencia
324
+ - auditoría
325
+ - migraciones
326
+ - seguridad razonable
327
+ - tests HTTP exhaustivos
328
+ - contrato más sano entre notebook y producción
329
+
330
+ Es una base seria para seguir construyendo.
adult.csv ADDED
The diff for this file is too large to render. See raw diff
 
alembic.ini ADDED
@@ -0,0 +1,149 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # A generic, single database configuration.
2
+
3
+ [alembic]
4
+ # path to migration scripts.
5
+ # this is typically a path given in POSIX (e.g. forward slashes)
6
+ # format, relative to the token %(here)s which refers to the location of this
7
+ # ini file
8
+ script_location = %(here)s/alembic
9
+
10
+ # template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
11
+ # Uncomment the line below if you want the files to be prepended with date and time
12
+ # see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
13
+ # for all available tokens
14
+ # file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
15
+ # Or organize into date-based subdirectories (requires recursive_version_locations = true)
16
+ # file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s
17
+
18
+ # sys.path path, will be prepended to sys.path if present.
19
+ # defaults to the current working directory. for multiple paths, the path separator
20
+ # is defined by "path_separator" below.
21
+ prepend_sys_path = .
22
+
23
+
24
+ # timezone to use when rendering the date within the migration file
25
+ # as well as the filename.
26
+ # If specified, requires the tzdata library which can be installed by adding
27
+ # `alembic[tz]` to the pip requirements.
28
+ # string value is passed to ZoneInfo()
29
+ # leave blank for localtime
30
+ # timezone =
31
+
32
+ # max length of characters to apply to the "slug" field
33
+ # truncate_slug_length = 40
34
+
35
+ # set to 'true' to run the environment during
36
+ # the 'revision' command, regardless of autogenerate
37
+ # revision_environment = false
38
+
39
+ # set to 'true' to allow .pyc and .pyo files without
40
+ # a source .py file to be detected as revisions in the
41
+ # versions/ directory
42
+ # sourceless = false
43
+
44
+ # version location specification; This defaults
45
+ # to <script_location>/versions. When using multiple version
46
+ # directories, initial revisions must be specified with --version-path.
47
+ # The path separator used here should be the separator specified by "path_separator"
48
+ # below.
49
+ # version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
50
+
51
+ # path_separator; This indicates what character is used to split lists of file
52
+ # paths, including version_locations and prepend_sys_path within configparser
53
+ # files such as alembic.ini.
54
+ # The default rendered in new alembic.ini files is "os", which uses os.pathsep
55
+ # to provide os-dependent path splitting.
56
+ #
57
+ # Note that in order to support legacy alembic.ini files, this default does NOT
58
+ # take place if path_separator is not present in alembic.ini. If this
59
+ # option is omitted entirely, fallback logic is as follows:
60
+ #
61
+ # 1. Parsing of the version_locations option falls back to using the legacy
62
+ # "version_path_separator" key, which if absent then falls back to the legacy
63
+ # behavior of splitting on spaces and/or commas.
64
+ # 2. Parsing of the prepend_sys_path option falls back to the legacy
65
+ # behavior of splitting on spaces, commas, or colons.
66
+ #
67
+ # Valid values for path_separator are:
68
+ #
69
+ # path_separator = :
70
+ # path_separator = ;
71
+ # path_separator = space
72
+ # path_separator = newline
73
+ #
74
+ # Use os.pathsep. Default configuration used for new projects.
75
+ path_separator = os
76
+
77
+ # set to 'true' to search source files recursively
78
+ # in each "version_locations" directory
79
+ # new in Alembic version 1.10
80
+ # recursive_version_locations = false
81
+
82
+ # the output encoding used when revision files
83
+ # are written from script.py.mako
84
+ # output_encoding = utf-8
85
+
86
+ # database URL. This is consumed by the user-maintained env.py script only.
87
+ # other means of configuring database URLs may be customized within the env.py
88
+ # file.
89
+ sqlalchemy.url = sqlite:///./oraculo.db
90
+
91
+
92
+ [post_write_hooks]
93
+ # post_write_hooks defines scripts or Python functions that are run
94
+ # on newly generated revision scripts. See the documentation for further
95
+ # detail and examples
96
+
97
+ # format using "black" - use the console_scripts runner, against the "black" entrypoint
98
+ # hooks = black
99
+ # black.type = console_scripts
100
+ # black.entrypoint = black
101
+ # black.options = -l 79 REVISION_SCRIPT_FILENAME
102
+
103
+ # lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
104
+ # hooks = ruff
105
+ # ruff.type = module
106
+ # ruff.module = ruff
107
+ # ruff.options = check --fix REVISION_SCRIPT_FILENAME
108
+
109
+ # Alternatively, use the exec runner to execute a binary found on your PATH
110
+ # hooks = ruff
111
+ # ruff.type = exec
112
+ # ruff.executable = ruff
113
+ # ruff.options = check --fix REVISION_SCRIPT_FILENAME
114
+
115
+ # Logging configuration. This is also consumed by the user-maintained
116
+ # env.py script only.
117
+ [loggers]
118
+ keys = root,sqlalchemy,alembic
119
+
120
+ [handlers]
121
+ keys = console
122
+
123
+ [formatters]
124
+ keys = generic
125
+
126
+ [logger_root]
127
+ level = WARNING
128
+ handlers = console
129
+ qualname =
130
+
131
+ [logger_sqlalchemy]
132
+ level = WARNING
133
+ handlers =
134
+ qualname = sqlalchemy.engine
135
+
136
+ [logger_alembic]
137
+ level = INFO
138
+ handlers =
139
+ qualname = alembic
140
+
141
+ [handler_console]
142
+ class = StreamHandler
143
+ args = (sys.stderr,)
144
+ level = NOTSET
145
+ formatter = generic
146
+
147
+ [formatter_generic]
148
+ format = %(levelname)-5.5s [%(name)s] %(message)s
149
+ datefmt = %H:%M:%S
alembic/README ADDED
@@ -0,0 +1 @@
 
 
1
+ Generic single-database configuration.
alembic/env.py ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from logging.config import fileConfig
4
+
5
+ from alembic import context
6
+ from sqlalchemy import engine_from_config, pool
7
+
8
+ from app.core.config import get_settings
9
+ from app.db import models # noqa: F401
10
+ from app.db.base import Base
11
+
12
+ config = context.config
13
+ settings = get_settings()
14
+ config.set_main_option("sqlalchemy.url", settings.database_url)
15
+
16
+ if config.config_file_name is not None:
17
+ fileConfig(config.config_file_name)
18
+
19
+ target_metadata = Base.metadata
20
+
21
+
22
+ def run_migrations_offline() -> None:
23
+ url = config.get_main_option("sqlalchemy.url")
24
+ context.configure(
25
+ url=url,
26
+ target_metadata=target_metadata,
27
+ literal_binds=True,
28
+ dialect_opts={"paramstyle": "named"},
29
+ compare_type=True,
30
+ )
31
+
32
+ with context.begin_transaction():
33
+ context.run_migrations()
34
+
35
+
36
+ def run_migrations_online() -> None:
37
+ connectable = engine_from_config(
38
+ config.get_section(config.config_ini_section, {}),
39
+ prefix="sqlalchemy.",
40
+ poolclass=pool.NullPool,
41
+ )
42
+
43
+ with connectable.connect() as connection:
44
+ context.configure(
45
+ connection=connection,
46
+ target_metadata=target_metadata,
47
+ compare_type=True,
48
+ )
49
+
50
+ with context.begin_transaction():
51
+ context.run_migrations()
52
+
53
+
54
+ if context.is_offline_mode():
55
+ run_migrations_offline()
56
+ else:
57
+ run_migrations_online()
alembic/script.py.mako ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """${message}
2
+
3
+ Revision ID: ${up_revision}
4
+ Revises: ${down_revision | comma,n}
5
+ Create Date: ${create_date}
6
+
7
+ """
8
+ from typing import Sequence, Union
9
+
10
+ from alembic import op
11
+ import sqlalchemy as sa
12
+ ${imports if imports else ""}
13
+
14
+ # revision identifiers, used by Alembic.
15
+ revision: str = ${repr(up_revision)}
16
+ down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
17
+ branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
18
+ depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
19
+
20
+
21
+ def upgrade() -> None:
22
+ """Upgrade schema."""
23
+ ${upgrades if upgrades else "pass"}
24
+
25
+
26
+ def downgrade() -> None:
27
+ """Downgrade schema."""
28
+ ${downgrades if downgrades else "pass"}
alembic/versions/33e682013d1c_initial_api_schema.py ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """initial_api_schema
2
+
3
+ Revision ID: 33e682013d1c
4
+ Revises:
5
+ Create Date: 2026-04-11 14:36:06.659737
6
+
7
+ """
8
+ from typing import Sequence, Union
9
+
10
+ from alembic import op
11
+ import sqlalchemy as sa
12
+
13
+
14
+ # revision identifiers, used by Alembic.
15
+ revision: str = '33e682013d1c'
16
+ down_revision: Union[str, Sequence[str], None] = None
17
+ branch_labels: Union[str, Sequence[str], None] = None
18
+ depends_on: Union[str, Sequence[str], None] = None
19
+
20
+
21
+ def upgrade() -> None:
22
+ """Upgrade schema."""
23
+ # ### commands auto generated by Alembic - please adjust! ###
24
+ op.create_table('users',
25
+ sa.Column('id', sa.String(length=36), nullable=False),
26
+ sa.Column('email', sa.String(length=255), nullable=False),
27
+ sa.Column('full_name', sa.String(length=255), nullable=False),
28
+ sa.Column('password_hash', sa.String(length=255), nullable=False),
29
+ sa.Column('role', sa.String(length=32), nullable=False),
30
+ sa.Column('is_active', sa.Boolean(), nullable=False),
31
+ sa.Column('created_at', sa.DateTime(), nullable=False),
32
+ sa.Column('updated_at', sa.DateTime(), nullable=False),
33
+ sa.PrimaryKeyConstraint('id', name=op.f('pk_users'))
34
+ )
35
+ op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True)
36
+ op.create_table('prediction_logs',
37
+ sa.Column('id', sa.String(length=36), nullable=False),
38
+ sa.Column('user_id', sa.String(length=36), nullable=False),
39
+ sa.Column('request_id', sa.String(length=64), nullable=False),
40
+ sa.Column('ip_address', sa.String(length=64), nullable=False),
41
+ sa.Column('label', sa.String(length=16), nullable=False),
42
+ sa.Column('probability', sa.Float(), nullable=False),
43
+ sa.Column('latency_ms', sa.Float(), nullable=False),
44
+ sa.Column('model_version', sa.String(length=64), nullable=False),
45
+ sa.Column('payload_hash', sa.String(length=64), nullable=False),
46
+ sa.Column('input_payload', sa.JSON(), nullable=False),
47
+ sa.Column('normalized_payload', sa.JSON(), nullable=False),
48
+ sa.Column('notes', sa.Text(), nullable=True),
49
+ sa.Column('created_at', sa.DateTime(), nullable=False),
50
+ sa.Column('updated_at', sa.DateTime(), nullable=False),
51
+ sa.ForeignKeyConstraint(['user_id'], ['users.id'], name=op.f('fk_prediction_logs_user_id_users')),
52
+ sa.PrimaryKeyConstraint('id', name=op.f('pk_prediction_logs'))
53
+ )
54
+ op.create_index(op.f('ix_prediction_logs_label'), 'prediction_logs', ['label'], unique=False)
55
+ op.create_index(op.f('ix_prediction_logs_payload_hash'), 'prediction_logs', ['payload_hash'], unique=False)
56
+ op.create_index(op.f('ix_prediction_logs_request_id'), 'prediction_logs', ['request_id'], unique=False)
57
+ op.create_index(op.f('ix_prediction_logs_user_id'), 'prediction_logs', ['user_id'], unique=False)
58
+ # ### end Alembic commands ###
59
+
60
+
61
+ def downgrade() -> None:
62
+ """Downgrade schema."""
63
+ # ### commands auto generated by Alembic - please adjust! ###
64
+ op.drop_index(op.f('ix_prediction_logs_user_id'), table_name='prediction_logs')
65
+ op.drop_index(op.f('ix_prediction_logs_request_id'), table_name='prediction_logs')
66
+ op.drop_index(op.f('ix_prediction_logs_payload_hash'), table_name='prediction_logs')
67
+ op.drop_index(op.f('ix_prediction_logs_label'), table_name='prediction_logs')
68
+ op.drop_table('prediction_logs')
69
+ op.drop_index(op.f('ix_users_email'), table_name='users')
70
+ op.drop_table('users')
71
+ # ### end Alembic commands ###
app/__init__.py ADDED
File without changes
app/api/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ from app.api.router import router
2
+
3
+ __all__ = ["router"]
app/api/dependencies.py ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Generator
4
+
5
+ from fastapi import Depends, Request
6
+ from sqlalchemy.orm import Session
7
+
8
+ from app.core.config import Settings
9
+ from app.core.exceptions import AuthorizationError
10
+ from app.core.security import bearer_scheme, decode_access_token, extract_bearer_token
11
+ from app.db.models import User
12
+ from app.db.repositories import PredictionRepository, UserRepository
13
+ from app.db.session import yield_session
14
+ from app.ml.model_manager import ModelManager
15
+ from app.services import AuthService, HealthService, PredictionService
16
+
17
+
18
+ def get_settings(request: Request) -> Settings:
19
+ return request.app.state.settings
20
+
21
+
22
+ def get_model_manager(request: Request) -> ModelManager:
23
+ return request.app.state.model_manager
24
+
25
+
26
+ def get_db_session(request: Request) -> Generator[Session, None, None]:
27
+ yield from yield_session(request.app.state.session_factory)
28
+
29
+
30
+ def get_user_repository(session: Session = Depends(get_db_session)) -> UserRepository:
31
+ return UserRepository(session)
32
+
33
+
34
+ def get_prediction_repository(session: Session = Depends(get_db_session)) -> PredictionRepository:
35
+ return PredictionRepository(session)
36
+
37
+
38
+ def get_auth_service(
39
+ settings: Settings = Depends(get_settings),
40
+ user_repository: UserRepository = Depends(get_user_repository),
41
+ ) -> AuthService:
42
+ return AuthService(user_repository=user_repository, settings=settings)
43
+
44
+
45
+ def get_prediction_service(
46
+ model_manager: ModelManager = Depends(get_model_manager),
47
+ prediction_repository: PredictionRepository = Depends(get_prediction_repository),
48
+ ) -> PredictionService:
49
+ return PredictionService(
50
+ model_manager=model_manager,
51
+ prediction_repository=prediction_repository,
52
+ )
53
+
54
+
55
+ def get_health_service(
56
+ settings: Settings = Depends(get_settings),
57
+ model_manager: ModelManager = Depends(get_model_manager),
58
+ ) -> HealthService:
59
+ return HealthService(settings=settings, model_manager=model_manager)
60
+
61
+
62
+ def get_current_user(
63
+ credentials=Depends(bearer_scheme),
64
+ settings: Settings = Depends(get_settings),
65
+ auth_service: AuthService = Depends(get_auth_service),
66
+ ) -> User:
67
+ token = extract_bearer_token(credentials)
68
+ token_payload = decode_access_token(token, settings)
69
+ user = auth_service.get_user(token_payload.sub)
70
+ if not user.is_active:
71
+ raise AuthorizationError("Inactive users cannot access this resource.")
72
+ return user
73
+
74
+
75
+ def get_current_admin_user(current_user: User = Depends(get_current_user)) -> User:
76
+ if current_user.role != "admin":
77
+ raise AuthorizationError("Administrator privileges are required.")
78
+ return current_user
app/api/router.py ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter
2
+
3
+ from app.api.v1.router import router as v1_router
4
+
5
+ router = APIRouter()
6
+ router.include_router(v1_router)
app/api/v1/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ from app.api.v1.router import router
2
+
3
+ __all__ = ["router"]
app/api/v1/endpoints/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # Package marker for API endpoints.
app/api/v1/endpoints/auth.py ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, Depends, status
2
+
3
+ from app.api.dependencies import get_auth_service, get_current_user
4
+ from app.db.models import User
5
+ from app.schemas.auth import LoginRequest, RegisterRequest, TokenResponse, UserResponse
6
+ from app.services.auth import AuthService
7
+
8
+ router = APIRouter(prefix="/auth", tags=["Authentication"])
9
+
10
+
11
+ @router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
12
+ def register(
13
+ payload: RegisterRequest,
14
+ auth_service: AuthService = Depends(get_auth_service),
15
+ ) -> UserResponse:
16
+ return auth_service.register(payload)
17
+
18
+
19
+ @router.post("/login", response_model=TokenResponse)
20
+ def login(
21
+ payload: LoginRequest,
22
+ auth_service: AuthService = Depends(get_auth_service),
23
+ ) -> TokenResponse:
24
+ return auth_service.login(payload)
25
+
26
+
27
+ @router.get("/me", response_model=UserResponse)
28
+ def me(current_user: User = Depends(get_current_user)) -> UserResponse:
29
+ return UserResponse.model_validate(current_user)
app/api/v1/endpoints/health.py ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, Depends
2
+ from sqlalchemy.orm import Session
3
+
4
+ from app.api.dependencies import get_db_session, get_health_service
5
+ from app.schemas.health import LiveHealthResponse, ReadyHealthResponse
6
+ from app.services.health import HealthService
7
+
8
+ router = APIRouter(prefix="/health", tags=["Health"])
9
+
10
+
11
+ @router.get("/live", response_model=LiveHealthResponse)
12
+ def live(service: HealthService = Depends(get_health_service)) -> LiveHealthResponse:
13
+ return service.live()
14
+
15
+
16
+ @router.get("/ready", response_model=ReadyHealthResponse)
17
+ def ready(
18
+ service: HealthService = Depends(get_health_service),
19
+ session: Session = Depends(get_db_session),
20
+ ) -> ReadyHealthResponse:
21
+ return service.ready(session)
app/api/v1/endpoints/predictions.py ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, Depends, Query, Request, status
2
+
3
+ from app.api.dependencies import get_current_user, get_prediction_service, get_settings
4
+ from app.core.config import Settings
5
+ from app.db.models import User
6
+ from app.schemas.prediction import (
7
+ PredictionDetailResponse,
8
+ PredictionInput,
9
+ PredictionListResponse,
10
+ PredictionLabel,
11
+ )
12
+ from app.services.prediction import PredictionService
13
+
14
+ router = APIRouter(prefix="/predictions", tags=["Predictions"])
15
+
16
+
17
+ @router.post("", response_model=PredictionDetailResponse, status_code=status.HTTP_201_CREATED)
18
+ def create_prediction(
19
+ payload: PredictionInput,
20
+ request: Request,
21
+ current_user: User = Depends(get_current_user),
22
+ prediction_service: PredictionService = Depends(get_prediction_service),
23
+ ) -> PredictionDetailResponse:
24
+ return prediction_service.predict(
25
+ payload=payload,
26
+ user=current_user,
27
+ request_id=request.state.request_id,
28
+ client_ip=request.state.client_ip,
29
+ )
30
+
31
+
32
+ @router.get("", response_model=PredictionListResponse)
33
+ def list_predictions(
34
+ skip: int = Query(default=0, ge=0),
35
+ limit: int = Query(default=20, ge=1, le=100),
36
+ label: PredictionLabel | None = Query(default=None),
37
+ min_probability: float | None = Query(default=None, ge=0.0, le=1.0),
38
+ current_user: User = Depends(get_current_user),
39
+ prediction_service: PredictionService = Depends(get_prediction_service),
40
+ settings: Settings = Depends(get_settings),
41
+ ) -> PredictionListResponse:
42
+ safe_limit = min(limit, settings.prediction_history_max_limit)
43
+ return prediction_service.list_predictions(
44
+ user=current_user,
45
+ skip=skip,
46
+ limit=safe_limit,
47
+ label=label,
48
+ min_probability=min_probability,
49
+ )
50
+
51
+
52
+ @router.get("/{prediction_id}", response_model=PredictionDetailResponse)
53
+ def get_prediction(
54
+ prediction_id: str,
55
+ current_user: User = Depends(get_current_user),
56
+ prediction_service: PredictionService = Depends(get_prediction_service),
57
+ ) -> PredictionDetailResponse:
58
+ return prediction_service.get_prediction(
59
+ prediction_id=prediction_id,
60
+ user=current_user,
61
+ )
app/api/v1/router.py ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter
2
+
3
+ from app.api.v1.endpoints.auth import router as auth_router
4
+ from app.api.v1.endpoints.health import router as health_router
5
+ from app.api.v1.endpoints.predictions import router as predictions_router
6
+
7
+ router = APIRouter(prefix="/api/v1")
8
+ router.include_router(health_router)
9
+ router.include_router(auth_router)
10
+ router.include_router(predictions_router)
app/core/__init__.py ADDED
File without changes
app/core/config.py ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from functools import lru_cache
4
+ from pathlib import Path
5
+ from typing import Literal
6
+
7
+ from pydantic import Field, field_validator
8
+ from pydantic_settings import BaseSettings, SettingsConfigDict
9
+
10
+
11
+ class Settings(BaseSettings):
12
+ model_config = SettingsConfigDict(
13
+ env_file=".env",
14
+ env_prefix="ORACULO_",
15
+ case_sensitive=False,
16
+ extra="ignore",
17
+ )
18
+
19
+ app_name: str = "Oraculo Adult Income API"
20
+ app_version: str = "2.0.0"
21
+ environment: Literal["local", "development", "test", "staging", "production"] = "development"
22
+ debug: bool = False
23
+
24
+ api_v1_prefix: str = "/api/v1"
25
+ docs_enabled: bool = True
26
+ openapi_url: str = "/openapi.json"
27
+ docs_url: str = "/docs"
28
+ redoc_url: str = "/redoc"
29
+
30
+ database_url: str = "sqlite:///./oraculo.db"
31
+ database_echo: bool = False
32
+ auto_create_tables: bool = True
33
+ auto_seed_admin: bool = True
34
+ seed_admin_email: str | None = None
35
+ seed_admin_password: str | None = None
36
+ seed_admin_name: str = "Administrator"
37
+
38
+ model_path: str = "app/ml/pipeline_produccion.pkl"
39
+
40
+ jwt_secret_key: str = "change-me-in-production"
41
+ jwt_algorithm: str = "HS256"
42
+ access_token_expire_minutes: int = 60
43
+
44
+ allowed_hosts: list[str] = Field(
45
+ default_factory=lambda: [
46
+ "localhost",
47
+ "127.0.0.1",
48
+ "testserver",
49
+ "*.hf.space",
50
+ "*.huggingface.co",
51
+ ]
52
+ )
53
+ cors_allow_origins: list[str] = Field(
54
+ default_factory=lambda: ["http://localhost:3000", "http://127.0.0.1:3000"]
55
+ )
56
+
57
+ max_request_size_bytes: int = 32_768
58
+ rate_limit_enabled: bool = True
59
+ rate_limit_requests: int = 60
60
+ rate_limit_window_seconds: int = 60
61
+ rate_limit_exempt_paths: list[str] = Field(
62
+ default_factory=lambda: [
63
+ "/",
64
+ "/docs",
65
+ "/redoc",
66
+ "/openapi.json",
67
+ "/api/v1/health/live",
68
+ "/api/v1/health/ready",
69
+ ]
70
+ )
71
+ security_headers_enabled: bool = True
72
+
73
+ prediction_history_default_limit: int = 20
74
+ prediction_history_max_limit: int = 100
75
+
76
+ @field_validator("allowed_hosts", "cors_allow_origins", "rate_limit_exempt_paths", mode="before")
77
+ @classmethod
78
+ def _split_csv_values(cls, value: str | list[str]) -> list[str]:
79
+ if isinstance(value, list):
80
+ return value
81
+ if not value:
82
+ return []
83
+ return [item.strip() for item in value.split(",") if item.strip()]
84
+
85
+ @property
86
+ def base_dir(self) -> Path:
87
+ return Path(__file__).resolve().parents[2]
88
+
89
+ @property
90
+ def resolved_model_path(self) -> Path:
91
+ model_path = Path(self.model_path)
92
+ if model_path.is_absolute():
93
+ return model_path
94
+ return self.base_dir / model_path
95
+
96
+ @property
97
+ def is_production(self) -> bool:
98
+ return self.environment == "production"
99
+
100
+
101
+ @lru_cache
102
+ def get_settings() -> Settings:
103
+ return Settings()
app/core/error_handlers.py ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from typing import Any
5
+
6
+ from fastapi import FastAPI, HTTPException, Request
7
+ from fastapi.exceptions import RequestValidationError
8
+ from fastapi.responses import JSONResponse
9
+
10
+ from app.core.exceptions import AppError
11
+
12
+ logger = logging.getLogger("oraculo_api.errors")
13
+
14
+
15
+ def build_error_payload(
16
+ request: Request,
17
+ *,
18
+ code: str,
19
+ message: str,
20
+ detail: dict[str, Any] | None = None,
21
+ ) -> dict[str, Any]:
22
+ request_id = getattr(request.state, "request_id", None)
23
+ return {
24
+ "error": {
25
+ "code": code,
26
+ "message": message,
27
+ "detail": detail or {},
28
+ "request_id": request_id,
29
+ }
30
+ }
31
+
32
+
33
+ def register_error_handlers(app: FastAPI) -> None:
34
+ @app.exception_handler(AppError)
35
+ async def handle_app_error(request: Request, exc: AppError) -> JSONResponse:
36
+ payload = build_error_payload(request, code=exc.code, message=exc.message, detail=exc.detail)
37
+ return JSONResponse(status_code=exc.status_code, content=payload)
38
+
39
+ @app.exception_handler(RequestValidationError)
40
+ async def handle_validation_error(request: Request, exc: RequestValidationError) -> JSONResponse:
41
+ payload = build_error_payload(
42
+ request,
43
+ code="validation_error",
44
+ message="Request validation failed.",
45
+ detail={"errors": exc.errors()},
46
+ )
47
+ return JSONResponse(status_code=422, content=payload)
48
+
49
+ @app.exception_handler(HTTPException)
50
+ async def handle_http_exception(request: Request, exc: HTTPException) -> JSONResponse:
51
+ detail = exc.detail if isinstance(exc.detail, dict) else {"reason": exc.detail}
52
+ payload = build_error_payload(
53
+ request,
54
+ code="http_error",
55
+ message="HTTP error.",
56
+ detail=detail,
57
+ )
58
+ return JSONResponse(status_code=exc.status_code, content=payload, headers=exc.headers)
59
+
60
+ @app.exception_handler(Exception)
61
+ async def handle_unexpected_error(request: Request, exc: Exception) -> JSONResponse:
62
+ logger.exception("Unhandled error: %s", exc)
63
+ payload = build_error_payload(
64
+ request,
65
+ code="internal_server_error",
66
+ message="Unexpected internal error.",
67
+ )
68
+ return JSONResponse(status_code=500, content=payload)
app/core/exceptions.py ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Any
5
+
6
+
7
+ @dataclass(slots=True)
8
+ class AppError(Exception):
9
+ message: str
10
+ status_code: int = 400
11
+ code: str = "app_error"
12
+ detail: dict[str, Any] = field(default_factory=dict)
13
+
14
+
15
+ class BadRequestError(AppError):
16
+ def __init__(self, message: str, detail: dict[str, Any] | None = None) -> None:
17
+ super().__init__(message=message, status_code=400, code="bad_request", detail=detail or {})
18
+
19
+
20
+ class AuthenticationError(AppError):
21
+ def __init__(self, message: str = "Authentication failed.") -> None:
22
+ super().__init__(message=message, status_code=401, code="authentication_error")
23
+
24
+
25
+ class AuthorizationError(AppError):
26
+ def __init__(self, message: str = "You are not allowed to access this resource.") -> None:
27
+ super().__init__(message=message, status_code=403, code="authorization_error")
28
+
29
+
30
+ class ResourceNotFoundError(AppError):
31
+ def __init__(self, resource_name: str, resource_id: str) -> None:
32
+ super().__init__(
33
+ message=f"{resource_name} '{resource_id}' was not found.",
34
+ status_code=404,
35
+ code="resource_not_found",
36
+ detail={"resource_name": resource_name, "resource_id": resource_id},
37
+ )
38
+
39
+
40
+ class ConflictError(AppError):
41
+ def __init__(self, message: str, detail: dict[str, Any] | None = None) -> None:
42
+ super().__init__(message=message, status_code=409, code="conflict", detail=detail or {})
43
+
44
+
45
+ class RateLimitExceededError(AppError):
46
+ def __init__(self, retry_after_seconds: int) -> None:
47
+ super().__init__(
48
+ message="Rate limit exceeded. Please retry later.",
49
+ status_code=429,
50
+ code="rate_limit_exceeded",
51
+ detail={"retry_after_seconds": retry_after_seconds},
52
+ )
53
+
54
+
55
+ class ServiceUnavailableError(AppError):
56
+ def __init__(self, message: str = "Service temporarily unavailable.") -> None:
57
+ super().__init__(message=message, status_code=503, code="service_unavailable")
58
+
59
+
60
+ class ModelInferenceError(AppError):
61
+ def __init__(self, message: str = "Model inference failed.") -> None:
62
+ super().__init__(message=message, status_code=500, code="model_inference_error")
app/core/logging.py ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import logging.config
5
+
6
+ from app.core.config import Settings
7
+
8
+
9
+ def configure_logging(settings: Settings) -> None:
10
+ level = "DEBUG" if settings.debug else "INFO"
11
+ logging.config.dictConfig(
12
+ {
13
+ "version": 1,
14
+ "disable_existing_loggers": False,
15
+ "formatters": {
16
+ "standard": {
17
+ "format": "%(asctime)s | %(levelname)s | %(name)s | %(message)s",
18
+ }
19
+ },
20
+ "handlers": {
21
+ "default": {
22
+ "class": "logging.StreamHandler",
23
+ "level": level,
24
+ "formatter": "standard",
25
+ }
26
+ },
27
+ "root": {"level": level, "handlers": ["default"]},
28
+ "loggers": {
29
+ "uvicorn": {"level": level, "handlers": ["default"], "propagate": False},
30
+ "uvicorn.access": {"level": level, "handlers": ["default"], "propagate": False},
31
+ "oraculo_api": {"level": level, "handlers": ["default"], "propagate": False},
32
+ },
33
+ }
34
+ )
app/core/middleware.py ADDED
@@ -0,0 +1,151 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import time
5
+ from collections import defaultdict, deque
6
+ from threading import Lock
7
+ from typing import Deque
8
+ from uuid import uuid4
9
+
10
+ from fastapi import Request
11
+ from fastapi.responses import JSONResponse
12
+ from starlette.middleware.base import BaseHTTPMiddleware
13
+
14
+ from app.core.config import Settings
15
+ from app.core.error_handlers import build_error_payload
16
+
17
+ logger = logging.getLogger("oraculo_api.middleware")
18
+
19
+
20
+ def resolve_client_ip(request: Request) -> str:
21
+ forwarded_for = request.headers.get("x-forwarded-for")
22
+ if forwarded_for:
23
+ return forwarded_for.split(",")[0].strip()
24
+ if request.client:
25
+ return request.client.host
26
+ return "unknown"
27
+
28
+
29
+ class RequestContextMiddleware(BaseHTTPMiddleware):
30
+ async def dispatch(self, request: Request, call_next):
31
+ request_id = request.headers.get("x-request-id", str(uuid4()))
32
+ request.state.request_id = request_id
33
+ request.state.started_at = time.perf_counter()
34
+ request.state.client_ip = resolve_client_ip(request)
35
+
36
+ response = await call_next(request)
37
+ duration_ms = (time.perf_counter() - request.state.started_at) * 1000
38
+ response.headers["X-Request-ID"] = request_id
39
+ response.headers["X-Process-Time-MS"] = f"{duration_ms:.2f}"
40
+ return response
41
+
42
+
43
+ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
44
+ def __init__(self, app, settings: Settings):
45
+ super().__init__(app)
46
+ self.settings = settings
47
+
48
+ def _content_security_policy_for_path(self, path: str) -> str:
49
+ docs_paths = {
50
+ self.settings.docs_url,
51
+ self.settings.redoc_url,
52
+ self.settings.openapi_url,
53
+ }
54
+ if path in {value for value in docs_paths if value}:
55
+ return (
56
+ "default-src 'self'; "
57
+ "script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; "
58
+ "style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; "
59
+ "img-src 'self' data: https://fastapi.tiangolo.com https://cdn.jsdelivr.net; "
60
+ "font-src 'self' https://cdn.jsdelivr.net; "
61
+ "connect-src 'self'; "
62
+ "frame-ancestors 'none'; "
63
+ "base-uri 'self';"
64
+ )
65
+
66
+ return "default-src 'none'; frame-ancestors 'none'; base-uri 'none';"
67
+
68
+ async def dispatch(self, request: Request, call_next):
69
+ response = await call_next(request)
70
+ if not self.settings.security_headers_enabled:
71
+ return response
72
+
73
+ response.headers["X-Content-Type-Options"] = "nosniff"
74
+ response.headers["X-Frame-Options"] = "DENY"
75
+ response.headers["Referrer-Policy"] = "no-referrer"
76
+ response.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()"
77
+ response.headers["Cache-Control"] = "no-store"
78
+ response.headers["Pragma"] = "no-cache"
79
+ response.headers["Content-Security-Policy"] = self._content_security_policy_for_path(request.url.path)
80
+ return response
81
+
82
+
83
+ class MaxRequestSizeMiddleware(BaseHTTPMiddleware):
84
+ def __init__(self, app, max_request_size_bytes: int):
85
+ super().__init__(app)
86
+ self.max_request_size_bytes = max_request_size_bytes
87
+
88
+ async def dispatch(self, request: Request, call_next):
89
+ content_length = request.headers.get("content-length")
90
+ if content_length and int(content_length) > self.max_request_size_bytes:
91
+ payload = build_error_payload(
92
+ request,
93
+ code="payload_too_large",
94
+ message="Payload exceeds the maximum allowed size.",
95
+ detail={"max_request_size_bytes": self.max_request_size_bytes},
96
+ )
97
+ return JSONResponse(status_code=413, content=payload)
98
+ return await call_next(request)
99
+
100
+
101
+ class SimpleInMemoryRateLimiter:
102
+ def __init__(self, max_requests: int, window_seconds: int):
103
+ self.max_requests = max_requests
104
+ self.window_seconds = window_seconds
105
+ self._storage: dict[str, Deque[float]] = defaultdict(deque)
106
+ self._lock = Lock()
107
+
108
+ def is_allowed(self, client_key: str) -> tuple[bool, int]:
109
+ now = time.time()
110
+ with self._lock:
111
+ bucket = self._storage[client_key]
112
+ while bucket and now - bucket[0] > self.window_seconds:
113
+ bucket.popleft()
114
+
115
+ if len(bucket) >= self.max_requests:
116
+ retry_after = max(1, int(self.window_seconds - (now - bucket[0])))
117
+ return False, retry_after
118
+
119
+ bucket.append(now)
120
+ return True, 0
121
+
122
+
123
+ class RateLimitMiddleware(BaseHTTPMiddleware):
124
+ def __init__(self, app, settings: Settings):
125
+ super().__init__(app)
126
+ self.settings = settings
127
+ self.limiter = SimpleInMemoryRateLimiter(
128
+ max_requests=settings.rate_limit_requests,
129
+ window_seconds=settings.rate_limit_window_seconds,
130
+ )
131
+
132
+ async def dispatch(self, request: Request, call_next):
133
+ if not self.settings.rate_limit_enabled or request.url.path in self.settings.rate_limit_exempt_paths:
134
+ return await call_next(request)
135
+
136
+ client_key = resolve_client_ip(request)
137
+ is_allowed, retry_after = self.limiter.is_allowed(client_key)
138
+ if not is_allowed:
139
+ payload = build_error_payload(
140
+ request,
141
+ code="rate_limit_exceeded",
142
+ message="Rate limit exceeded. Please retry later.",
143
+ detail={"retry_after_seconds": retry_after},
144
+ )
145
+ return JSONResponse(
146
+ status_code=429,
147
+ content=payload,
148
+ headers={"Retry-After": str(retry_after)},
149
+ )
150
+
151
+ return await call_next(request)
app/core/security.py ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime, timedelta, timezone
4
+ import hashlib
5
+ from typing import Any
6
+
7
+ import bcrypt
8
+ import jwt
9
+ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
10
+ from pydantic import BaseModel
11
+
12
+ from app.core.config import Settings
13
+ from app.core.exceptions import AuthenticationError
14
+
15
+
16
+ bearer_scheme = HTTPBearer(auto_error=False)
17
+
18
+
19
+ class TokenPayload(BaseModel):
20
+ sub: str
21
+ role: str = "user"
22
+ exp: int
23
+
24
+
25
+ def hash_password(password: str) -> str:
26
+ password_bytes = _password_to_bytes(password)
27
+ return bcrypt.hashpw(password_bytes, bcrypt.gensalt()).decode("utf-8")
28
+
29
+
30
+ def verify_password(plain_password: str, hashed_password: str) -> bool:
31
+ password_bytes = _password_to_bytes(plain_password)
32
+ return bcrypt.checkpw(password_bytes, hashed_password.encode("utf-8"))
33
+
34
+
35
+ def _password_to_bytes(password: str) -> bytes:
36
+ raw_bytes = password.encode("utf-8")
37
+ if len(raw_bytes) <= 72:
38
+ return raw_bytes
39
+ return hashlib.sha256(raw_bytes).hexdigest().encode("utf-8")
40
+
41
+
42
+ def create_access_token(
43
+ *,
44
+ subject: str,
45
+ role: str,
46
+ settings: Settings,
47
+ expires_delta: timedelta | None = None,
48
+ ) -> str:
49
+ expire_at = datetime.now(timezone.utc) + (
50
+ expires_delta or timedelta(minutes=settings.access_token_expire_minutes)
51
+ )
52
+ payload: dict[str, Any] = {
53
+ "sub": subject,
54
+ "role": role,
55
+ "exp": expire_at,
56
+ }
57
+ return jwt.encode(payload, settings.jwt_secret_key, algorithm=settings.jwt_algorithm)
58
+
59
+
60
+ def decode_access_token(token: str, settings: Settings) -> TokenPayload:
61
+ try:
62
+ payload = jwt.decode(token, settings.jwt_secret_key, algorithms=[settings.jwt_algorithm])
63
+ return TokenPayload(**payload)
64
+ except jwt.ExpiredSignatureError as exc:
65
+ raise AuthenticationError("Access token expired.") from exc
66
+ except jwt.PyJWTError as exc:
67
+ raise AuthenticationError("Invalid access token.") from exc
68
+
69
+
70
+ def extract_bearer_token(credentials: HTTPAuthorizationCredentials | None) -> str:
71
+ if credentials is None or not credentials.credentials:
72
+ raise AuthenticationError("Missing bearer token.")
73
+ return credentials.credentials
app/db/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ from app.db import models
2
+
3
+ __all__ = ["models"]
app/db/base.py ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime, timezone
4
+
5
+ from sqlalchemy import MetaData
6
+ from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
7
+
8
+
9
+ NAMING_CONVENTION = {
10
+ "ix": "ix_%(column_0_label)s",
11
+ "uq": "uq_%(table_name)s_%(column_0_name)s",
12
+ "ck": "ck_%(table_name)s_%(constraint_name)s",
13
+ "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
14
+ "pk": "pk_%(table_name)s",
15
+ }
16
+
17
+
18
+ class Base(DeclarativeBase):
19
+ metadata = MetaData(naming_convention=NAMING_CONVENTION)
20
+
21
+
22
+ class TimestampMixin:
23
+ created_at: Mapped[datetime] = mapped_column(default=lambda: datetime.now(timezone.utc))
24
+ updated_at: Mapped[datetime] = mapped_column(
25
+ default=lambda: datetime.now(timezone.utc),
26
+ onupdate=lambda: datetime.now(timezone.utc),
27
+ )
app/db/models/__init__.py ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ from app.db.models.prediction_log import PredictionLog
2
+ from app.db.models.user import User, UserRole
3
+
4
+ __all__ = ["PredictionLog", "User", "UserRole"]
app/db/models/prediction_log.py ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from uuid import uuid4
4
+
5
+ from sqlalchemy import Float, ForeignKey, JSON, String, Text
6
+ from sqlalchemy.orm import Mapped, mapped_column, relationship
7
+
8
+ from app.db.base import Base, TimestampMixin
9
+
10
+
11
+ class PredictionLog(TimestampMixin, Base):
12
+ __tablename__ = "prediction_logs"
13
+
14
+ id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
15
+ user_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id"), index=True)
16
+ request_id: Mapped[str] = mapped_column(String(64), index=True)
17
+ ip_address: Mapped[str] = mapped_column(String(64))
18
+ label: Mapped[str] = mapped_column(String(16), index=True)
19
+ probability: Mapped[float] = mapped_column(Float)
20
+ latency_ms: Mapped[float] = mapped_column(Float)
21
+ model_version: Mapped[str] = mapped_column(String(64))
22
+ payload_hash: Mapped[str] = mapped_column(String(64), index=True)
23
+ input_payload: Mapped[dict] = mapped_column(JSON)
24
+ normalized_payload: Mapped[dict] = mapped_column(JSON)
25
+ notes: Mapped[str | None] = mapped_column(Text, nullable=True)
26
+
27
+ user = relationship("User", back_populates="predictions")
app/db/models/user.py ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from enum import Enum
4
+ from uuid import uuid4
5
+
6
+ from sqlalchemy import Boolean, String
7
+ from sqlalchemy.orm import Mapped, mapped_column, relationship
8
+
9
+ from app.db.base import Base, TimestampMixin
10
+
11
+
12
+ class UserRole(str, Enum):
13
+ ADMIN = "admin"
14
+ USER = "user"
15
+
16
+
17
+ class User(TimestampMixin, Base):
18
+ __tablename__ = "users"
19
+
20
+ id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
21
+ email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
22
+ full_name: Mapped[str] = mapped_column(String(255))
23
+ password_hash: Mapped[str] = mapped_column(String(255))
24
+ role: Mapped[str] = mapped_column(String(32), default=UserRole.USER.value)
25
+ is_active: Mapped[bool] = mapped_column(Boolean, default=True)
26
+
27
+ predictions = relationship("PredictionLog", back_populates="user", cascade="all, delete-orphan")
app/db/repositories/__init__.py ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ from app.db.repositories.predictions import PredictionRepository
2
+ from app.db.repositories.users import UserRepository
3
+
4
+ __all__ = ["PredictionRepository", "UserRepository"]
app/db/repositories/predictions.py ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from sqlalchemy import Select, func, select
4
+ from sqlalchemy.orm import Session
5
+
6
+ from app.db.models import PredictionLog
7
+
8
+
9
+ class PredictionRepository:
10
+ def __init__(self, session: Session):
11
+ self.session = session
12
+
13
+ def create(
14
+ self,
15
+ *,
16
+ user_id: str,
17
+ request_id: str,
18
+ ip_address: str,
19
+ label: str,
20
+ probability: float,
21
+ latency_ms: float,
22
+ model_version: str,
23
+ payload_hash: str,
24
+ input_payload: dict,
25
+ normalized_payload: dict,
26
+ notes: str | None = None,
27
+ ) -> PredictionLog:
28
+ prediction_log = PredictionLog(
29
+ user_id=user_id,
30
+ request_id=request_id,
31
+ ip_address=ip_address,
32
+ label=label,
33
+ probability=probability,
34
+ latency_ms=latency_ms,
35
+ model_version=model_version,
36
+ payload_hash=payload_hash,
37
+ input_payload=input_payload,
38
+ normalized_payload=normalized_payload,
39
+ notes=notes,
40
+ )
41
+ self.session.add(prediction_log)
42
+ self.session.flush()
43
+ self.session.refresh(prediction_log)
44
+ return prediction_log
45
+
46
+ def list_for_user(
47
+ self,
48
+ *,
49
+ user_id: str,
50
+ skip: int,
51
+ limit: int,
52
+ label: str | None = None,
53
+ min_probability: float | None = None,
54
+ ) -> tuple[list[PredictionLog], int]:
55
+ statement: Select[tuple[PredictionLog]] = select(PredictionLog).where(PredictionLog.user_id == user_id)
56
+
57
+ if label:
58
+ statement = statement.where(PredictionLog.label == label)
59
+ if min_probability is not None:
60
+ statement = statement.where(PredictionLog.probability >= min_probability)
61
+
62
+ total = self.session.scalar(select(func.count()).select_from(statement.subquery())) or 0
63
+ rows = self.session.scalars(
64
+ statement.order_by(PredictionLog.created_at.desc()).offset(skip).limit(limit)
65
+ ).all()
66
+ return rows, total
67
+
68
+ def get_for_user(self, *, prediction_id: str, user_id: str) -> PredictionLog | None:
69
+ statement = select(PredictionLog).where(
70
+ PredictionLog.id == prediction_id,
71
+ PredictionLog.user_id == user_id,
72
+ )
73
+ return self.session.scalar(statement)
app/db/repositories/users.py ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from sqlalchemy import select
4
+ from sqlalchemy.orm import Session
5
+
6
+ from app.db.models import User
7
+
8
+
9
+ class UserRepository:
10
+ def __init__(self, session: Session):
11
+ self.session = session
12
+
13
+ def get_by_email(self, email: str) -> User | None:
14
+ statement = select(User).where(User.email == email.lower())
15
+ return self.session.scalar(statement)
16
+
17
+ def get_by_id(self, user_id: str) -> User | None:
18
+ statement = select(User).where(User.id == user_id)
19
+ return self.session.scalar(statement)
20
+
21
+ def create(self, *, email: str, full_name: str, password_hash: str, role: str = "user") -> User:
22
+ user = User(
23
+ email=email.lower(),
24
+ full_name=full_name,
25
+ password_hash=password_hash,
26
+ role=role,
27
+ )
28
+ self.session.add(user)
29
+ self.session.flush()
30
+ self.session.refresh(user)
31
+ return user
app/db/seeds.py ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+
5
+ from sqlalchemy.orm import Session
6
+
7
+ from app.core.config import Settings
8
+ from app.core.security import hash_password
9
+ from app.db.models import UserRole
10
+ from app.db.repositories import UserRepository
11
+
12
+ logger = logging.getLogger("oraculo_api.seeds")
13
+
14
+
15
+ def seed_admin_user(session: Session, settings: Settings) -> None:
16
+ if not settings.auto_seed_admin:
17
+ return
18
+ if not settings.seed_admin_email or not settings.seed_admin_password:
19
+ return
20
+
21
+ repository = UserRepository(session)
22
+ existing_user = repository.get_by_email(settings.seed_admin_email)
23
+ if existing_user:
24
+ return
25
+
26
+ repository.create(
27
+ email=settings.seed_admin_email,
28
+ full_name=settings.seed_admin_name,
29
+ password_hash=hash_password(settings.seed_admin_password),
30
+ role=UserRole.ADMIN.value,
31
+ )
32
+ logger.info("Default admin user created for bootstrap.")
app/db/session.py ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Generator
4
+
5
+ from sqlalchemy import create_engine, text
6
+ from sqlalchemy.engine import Engine
7
+ from sqlalchemy.orm import Session, sessionmaker
8
+ from sqlalchemy.pool import StaticPool
9
+
10
+ from app.core.config import Settings
11
+ from app.db.base import Base
12
+
13
+
14
+ def build_engine(settings: Settings) -> Engine:
15
+ connect_args = {"check_same_thread": False} if settings.database_url.startswith("sqlite") else {}
16
+ engine_kwargs = {
17
+ "echo": settings.database_echo,
18
+ "pool_pre_ping": True,
19
+ "future": True,
20
+ "connect_args": connect_args,
21
+ }
22
+ if settings.database_url.endswith(":memory:"):
23
+ engine_kwargs["poolclass"] = StaticPool
24
+
25
+ return create_engine(
26
+ settings.database_url,
27
+ **engine_kwargs,
28
+ )
29
+
30
+
31
+ def build_session_factory(engine: Engine) -> sessionmaker[Session]:
32
+ return sessionmaker(bind=engine, autoflush=False, autocommit=False, expire_on_commit=False)
33
+
34
+
35
+ def create_tables(engine: Engine) -> None:
36
+ Base.metadata.create_all(bind=engine)
37
+
38
+
39
+ def check_database_connection(session: Session) -> bool:
40
+ session.execute(text("SELECT 1"))
41
+ return True
42
+
43
+
44
+ def get_db_session_factory(request) -> sessionmaker[Session]:
45
+ return request.app.state.session_factory
46
+
47
+
48
+ def yield_session(session_factory: sessionmaker[Session]) -> Generator[Session, None, None]:
49
+ session = session_factory()
50
+ try:
51
+ yield session
52
+ session.commit()
53
+ except Exception:
54
+ session.rollback()
55
+ raise
56
+ finally:
57
+ session.close()
app/main.py ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from contextlib import asynccontextmanager
5
+
6
+ from fastapi import FastAPI
7
+ from fastapi.middleware.cors import CORSMiddleware
8
+ from fastapi.middleware.gzip import GZipMiddleware
9
+ from starlette.middleware.trustedhost import TrustedHostMiddleware
10
+
11
+ from app.api.router import router as api_router
12
+ from app.core.config import Settings, get_settings
13
+ from app.core.error_handlers import register_error_handlers
14
+ from app.core.logging import configure_logging
15
+ from app.core.middleware import (
16
+ MaxRequestSizeMiddleware,
17
+ RateLimitMiddleware,
18
+ RequestContextMiddleware,
19
+ SecurityHeadersMiddleware,
20
+ )
21
+ from app.db import models # noqa: F401
22
+ from app.db.seeds import seed_admin_user
23
+ from app.db.session import build_engine, build_session_factory, create_tables
24
+ from app.ml.model_manager import ModelManager
25
+
26
+ logger = logging.getLogger("oraculo_api")
27
+
28
+
29
+ def create_app(settings: Settings | None = None, model_manager: ModelManager | None = None) -> FastAPI:
30
+ app_settings = settings or get_settings()
31
+ configure_logging(app_settings)
32
+
33
+ @asynccontextmanager
34
+ async def lifespan(app: FastAPI):
35
+ engine = build_engine(app_settings)
36
+ session_factory = build_session_factory(engine)
37
+
38
+ app.state.settings = app_settings
39
+ app.state.engine = engine
40
+ app.state.session_factory = session_factory
41
+
42
+ if app_settings.auto_create_tables:
43
+ create_tables(engine)
44
+
45
+ with session_factory() as session:
46
+ seed_admin_user(session, app_settings)
47
+ session.commit()
48
+
49
+ active_model_manager = model_manager or ModelManager(app_settings.resolved_model_path)
50
+ active_model_manager.load_model()
51
+ app.state.model_manager = active_model_manager
52
+
53
+ logger.info("%s started in %s mode.", app_settings.app_name, app_settings.environment)
54
+ yield
55
+
56
+ if hasattr(app.state.model_manager, "unload_model"):
57
+ app.state.model_manager.unload_model()
58
+ app.state.engine.dispose()
59
+ logger.info("%s shutdown completed.", app_settings.app_name)
60
+
61
+ docs_enabled = app_settings.docs_enabled
62
+ application = FastAPI(
63
+ title=app_settings.app_name,
64
+ version=app_settings.app_version,
65
+ debug=app_settings.debug,
66
+ lifespan=lifespan,
67
+ docs_url=app_settings.docs_url if docs_enabled else None,
68
+ redoc_url=app_settings.redoc_url if docs_enabled else None,
69
+ openapi_url=app_settings.openapi_url if docs_enabled else None,
70
+ )
71
+
72
+ application.add_middleware(GZipMiddleware, minimum_size=1024)
73
+ application.add_middleware(
74
+ CORSMiddleware,
75
+ allow_origins=app_settings.cors_allow_origins,
76
+ allow_credentials=True,
77
+ allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE"],
78
+ allow_headers=["*"],
79
+ )
80
+ application.add_middleware(TrustedHostMiddleware, allowed_hosts=app_settings.allowed_hosts)
81
+ application.add_middleware(SecurityHeadersMiddleware, settings=app_settings)
82
+ application.add_middleware(MaxRequestSizeMiddleware, max_request_size_bytes=app_settings.max_request_size_bytes)
83
+ application.add_middleware(RateLimitMiddleware, settings=app_settings)
84
+ application.add_middleware(RequestContextMiddleware)
85
+
86
+ register_error_handlers(application)
87
+ application.include_router(api_router)
88
+
89
+ @application.get("/", tags=["Root"])
90
+ def root() -> dict[str, str]:
91
+ return {
92
+ "service": app_settings.app_name,
93
+ "version": app_settings.app_version,
94
+ "environment": app_settings.environment,
95
+ }
96
+
97
+ return application
98
+
99
+
100
+ app = create_app()
app/ml/__init__.py ADDED
File without changes
app/ml/custom_transformers.py ADDED
@@ -0,0 +1,479 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ import re
3
+ import time
4
+ import warnings
5
+ from pathlib import Path
6
+ from typing import Any, Dict, Iterable, Optional
7
+
8
+ import numpy as np
9
+ import pandas as pd
10
+
11
+ logger = logging.getLogger("api_logger")
12
+
13
+ _COLUMN_SANITIZER = re.compile(r"[^a-z0-9_]")
14
+ _MULTI_UNDERSCORE = re.compile(r"_+")
15
+ _MASK_PATTERN = re.compile(r"(?i)^(unknown|n/?a|null|nan|missing|none|-1|)$|^[^a-zA-Z0-9]+$")
16
+ _LLM_OPERATORS = {"_+_": "+", "_-_": "-", "_*_": "*"}
17
+ _RARE_LABEL = "Rare"
18
+
19
+
20
+ class PipelineProduccionMLOps:
21
+ """
22
+ Standalone production pipeline compatible with the notebook artifact.
23
+
24
+ The current notebook exports a partially broken pickle: several feature
25
+ engineering recipes are not serialized, and the API receives raw JSON with
26
+ snake_case / dotted-name mismatches. This class heals that gap at runtime.
27
+ """
28
+
29
+ def __init__(self, rutas: Dict, artefactos: Dict, modelos: Dict):
30
+ self.rutas = rutas or {}
31
+ self.artefactos = artefactos or {}
32
+ self.modelos = modelos or {}
33
+ self.umbral_oro = float(
34
+ self.artefactos.get(
35
+ "umbral_decision",
36
+ self.rutas.get("umbral_decision_optimo", 0.50),
37
+ )
38
+ )
39
+ self.version = "1.0.0"
40
+ self.fecha_ensamblaje = time.strftime("%Y-%m-%d %H:%M:%S")
41
+
42
+ def _ensure_runtime_state(self) -> None:
43
+ if not hasattr(self, "rutas") or self.rutas is None:
44
+ self.rutas = {}
45
+ if not hasattr(self, "artefactos") or self.artefactos is None:
46
+ self.artefactos = {}
47
+ if not hasattr(self, "modelos") or self.modelos is None:
48
+ self.modelos = {}
49
+ if not hasattr(self, "umbral_oro"):
50
+ self.umbral_oro = float(
51
+ self.artefactos.get(
52
+ "umbral_decision",
53
+ self.rutas.get("umbral_decision_optimo", 0.50),
54
+ )
55
+ )
56
+ if not hasattr(self, "_reference_dataset"):
57
+ self._reference_dataset = None
58
+ if not hasattr(self, "_did_infer_missing_artefacts"):
59
+ self._did_infer_missing_artefacts = False
60
+
61
+ def _get_modelo_final(self) -> Any:
62
+ self._ensure_runtime_state()
63
+ return self.modelos.get("oraculo_calibrado", self.modelos.get("oraculo_lightgbm"))
64
+
65
+ def _get_training_feature_names(self) -> list[str]:
66
+ modelo_final = self._get_modelo_final()
67
+ if modelo_final is None:
68
+ return []
69
+
70
+ if hasattr(modelo_final, "feature_names_in_"):
71
+ return [str(col) for col in modelo_final.feature_names_in_]
72
+
73
+ if hasattr(modelo_final, "estimator") and hasattr(modelo_final.estimator, "feature_name_"):
74
+ feature_names = modelo_final.estimator.feature_name_
75
+ feature_names = feature_names() if callable(feature_names) else feature_names
76
+ return [str(col) for col in feature_names]
77
+
78
+ if hasattr(modelo_final, "booster_"):
79
+ return [str(col) for col in modelo_final.booster_.feature_name()]
80
+
81
+ return []
82
+
83
+ @staticmethod
84
+ def _sanitize_column_name(name: Any) -> str:
85
+ text = str(name).strip().lower()
86
+ text = _COLUMN_SANITIZER.sub("_", text)
87
+ text = _MULTI_UNDERSCORE.sub("_", text)
88
+ return text.strip("_")
89
+
90
+ @classmethod
91
+ def _sanitize_text_series(cls, series: pd.Series) -> pd.Series:
92
+ mask = series.isna()
93
+ clean = series.astype("string")
94
+ clean = clean.str.lower()
95
+ clean = clean.str.normalize("NFKD").str.encode("ascii", errors="ignore").str.decode("utf-8")
96
+ clean = clean.str.replace(r"\s+", " ", regex=True)
97
+ clean = clean.str.replace(r"\s*([^\w\s])\s*", r"\1", regex=True)
98
+ clean = clean.str.replace(r"(?<=\d)\s+(?=[a-z])|(?<=[a-z])\s+(?=\d)", "", regex=True)
99
+ clean = clean.str.strip().str.replace(r"\s+", "_", regex=True)
100
+ clean = clean.astype(object)
101
+ clean[mask] = np.nan
102
+ return clean
103
+
104
+ @classmethod
105
+ def _normalize_input_frame(cls, X_raw: pd.DataFrame) -> pd.DataFrame:
106
+ X = X_raw.copy()
107
+ X.columns = [cls._sanitize_column_name(col) for col in X.columns]
108
+
109
+ for col in X.columns:
110
+ dtype = X[col].dtype
111
+ if (
112
+ pd.api.types.is_object_dtype(dtype)
113
+ or pd.api.types.is_string_dtype(dtype)
114
+ or isinstance(dtype, pd.CategoricalDtype)
115
+ ):
116
+ X[col] = cls._sanitize_text_series(X[col])
117
+ return X
118
+
119
+ def _reference_dataset_path(self) -> Path:
120
+ return Path(__file__).resolve().parents[2] / "adult.csv"
121
+
122
+ def _load_reference_dataset(self) -> Optional[pd.DataFrame]:
123
+ self._ensure_runtime_state()
124
+ if self._reference_dataset is not None:
125
+ return self._reference_dataset.copy()
126
+
127
+ dataset_path = self._reference_dataset_path()
128
+ if not dataset_path.exists():
129
+ logger.warning(
130
+ "No se encontro '%s'; la inferencia seguira sin reconstruir recetas faltantes.",
131
+ dataset_path,
132
+ )
133
+ return None
134
+
135
+ try:
136
+ df = pd.read_csv(dataset_path, sep=";")
137
+ except Exception as exc:
138
+ logger.warning(
139
+ "No fue posible leer '%s' para reconstruir artefactos de inferencia: %s",
140
+ dataset_path,
141
+ exc,
142
+ )
143
+ return None
144
+
145
+ df = self._normalize_input_frame(df)
146
+ self._reference_dataset = df
147
+ return df.copy()
148
+
149
+ def _get_rare_recipe(self) -> Dict[str, list]:
150
+ self._ensure_runtime_state()
151
+ recipe = self.artefactos.get("receta_categorias_raras")
152
+ if recipe:
153
+ return recipe
154
+ recipe = self.modelos.get("vocabulario_rare_labeling")
155
+ if recipe:
156
+ return recipe
157
+ return {}
158
+
159
+ def _get_binary_recipe(self) -> Dict[str, Dict[Any, int]]:
160
+ self._ensure_runtime_state()
161
+ recipe = self.artefactos.get("receta_mapeo_binario") or self.artefactos.get("reglas_binarias") or {}
162
+ return {
163
+ col: mapping
164
+ for col, mapping in recipe.items()
165
+ if not str(col).startswith("TARGET_")
166
+ }
167
+
168
+ def _get_target_recipe(self) -> Dict[str, Dict[str, Dict[Any, float]]]:
169
+ self._ensure_runtime_state()
170
+ return self.artefactos.get("receta_target_encoding") or self.artefactos.get("receta_woe_encoding") or {}
171
+
172
+ def _get_ratio_recipe(self) -> Iterable[tuple]:
173
+ self._ensure_runtime_state()
174
+ return self.artefactos.get("receta_ratios_matematicos") or self.artefactos.get("receta_ratios_train") or []
175
+
176
+ def _get_llm_recipe(self) -> Dict[str, str]:
177
+ self._ensure_runtime_state()
178
+ return self.artefactos.get("receta_llm_fe") or {}
179
+
180
+ def _learn_rare_recipe(self, X: pd.DataFrame, threshold: float = 0.01) -> Dict[str, list]:
181
+ recipe: Dict[str, list] = {}
182
+ cat_cols = X.select_dtypes(include=["object", "category", "string"]).columns.tolist()
183
+
184
+ for col in cat_cols:
185
+ frequencies = X[col].value_counts(normalize=True)
186
+ valid_categories = frequencies[frequencies >= threshold].index.tolist()
187
+ masks = [val for val in frequencies.index if _MASK_PATTERN.match(str(val).strip())]
188
+ valid_categories.extend(masks)
189
+ valid_categories = list(dict.fromkeys(valid_categories))
190
+ rare_categories = frequencies[~frequencies.index.isin(valid_categories)]
191
+ if not rare_categories.empty:
192
+ recipe[col] = valid_categories
193
+
194
+ return recipe
195
+
196
+ @staticmethod
197
+ def _apply_rare_labeling(X: pd.DataFrame, recipe: Dict[str, list]) -> pd.DataFrame:
198
+ X_trans = X.copy()
199
+ for col, valid_categories in recipe.items():
200
+ if col not in X_trans.columns:
201
+ continue
202
+ mask_null = X_trans[col].isna()
203
+ mask_replace = ~X_trans[col].isin(valid_categories) & ~mask_null
204
+ X_trans.loc[mask_replace, col] = _RARE_LABEL
205
+ return X_trans
206
+
207
+ @staticmethod
208
+ def _encode_binary_target(target: pd.Series) -> pd.Series:
209
+ ordered_values = sorted(target.dropna().unique().tolist())
210
+ mapping = {value: index for index, value in enumerate(ordered_values)}
211
+ return target.map(mapping).astype(float)
212
+
213
+ @staticmethod
214
+ def _learn_binary_recipe(X: pd.DataFrame) -> Dict[str, Dict[Any, int]]:
215
+ recipe: Dict[str, Dict[Any, int]] = {}
216
+ for col in X.columns:
217
+ values = X[col].dropna().unique().tolist()
218
+ if len(values) != 2:
219
+ continue
220
+ if pd.api.types.is_numeric_dtype(X[col]):
221
+ continue
222
+ ordered_values = sorted(values)
223
+ recipe[col] = {ordered_values[0]: 0, ordered_values[1]: 1}
224
+ return recipe
225
+
226
+ @staticmethod
227
+ def _learn_target_encoding_recipe(
228
+ X: pd.DataFrame,
229
+ y: pd.Series,
230
+ rutas: Optional[Dict[str, list]] = None,
231
+ smoothing: float = 10.0,
232
+ ) -> Dict[str, Dict[str, Dict[Any, float]]]:
233
+ rutas = rutas or {"cat_vars": []}
234
+ recipe: Dict[str, Dict[str, Dict[Any, float]]] = {}
235
+
236
+ for col in X.columns:
237
+ if col.startswith("TARGET_"):
238
+ continue
239
+
240
+ unique_values = X[col].dropna().nunique()
241
+ is_numeric = pd.api.types.is_numeric_dtype(X[col])
242
+ is_categorical = (
243
+ col in rutas.get("cat_vars", [])
244
+ or pd.api.types.is_object_dtype(X[col])
245
+ or isinstance(X[col].dtype, pd.CategoricalDtype)
246
+ )
247
+
248
+ if not is_categorical or is_numeric or unique_values <= 2:
249
+ continue
250
+
251
+ working = X[col].astype(object)
252
+ stats = pd.DataFrame({"Target": y, "Categoria": working}).groupby("Categoria")["Target"].agg(["count", "mean"])
253
+ n_obs = stats["count"]
254
+ global_mean = float(y.mean())
255
+ smooth = (n_obs * stats["mean"] + smoothing * global_mean) / (n_obs + smoothing)
256
+ recipe[col] = {
257
+ "Target_Directo": {
258
+ **smooth.to_dict(),
259
+ "__GLOBAL_MEAN__": global_mean,
260
+ }
261
+ }
262
+
263
+ return recipe
264
+
265
+ def _infer_llm_recipe_from_feature_names(self, feature_names: Iterable[str]) -> Dict[str, str]:
266
+ llm_recipe: Dict[str, str] = {}
267
+
268
+ for feature_name in feature_names:
269
+ if not feature_name.startswith("llm_"):
270
+ continue
271
+
272
+ expression = feature_name[4:]
273
+ for token, operator in _LLM_OPERATORS.items():
274
+ if token not in expression:
275
+ continue
276
+ left, right = expression.split(token, 1)
277
+ llm_recipe[feature_name] = f"X['{left}'] {operator} X['{right}']"
278
+ break
279
+
280
+ return llm_recipe
281
+
282
+ def _infer_missing_artefacts(self) -> None:
283
+ self._ensure_runtime_state()
284
+ if self._did_infer_missing_artefacts:
285
+ return
286
+
287
+ feature_names = self._get_training_feature_names()
288
+ needs_binary = "sex" in feature_names and not self._get_binary_recipe()
289
+ needs_target = any(
290
+ feature in feature_names
291
+ for feature in ("workclass", "marital_status", "occupation", "relationship", "race", "native_country")
292
+ ) and not self._get_target_recipe()
293
+ needs_rare = (needs_binary or needs_target) and not self._get_rare_recipe()
294
+ needs_llm = any(feature.startswith("llm_") for feature in feature_names) and not self._get_llm_recipe()
295
+
296
+ if not any((needs_binary, needs_target, needs_rare, needs_llm)):
297
+ self._did_infer_missing_artefacts = True
298
+ return
299
+
300
+ reference_df = self._load_reference_dataset()
301
+ if reference_df is None:
302
+ self._did_infer_missing_artefacts = True
303
+ return
304
+
305
+ target_name = self.rutas.get("target_name", "income")
306
+ if target_name not in reference_df.columns:
307
+ logger.warning(
308
+ "El dataset de referencia no contiene la columna target '%s'; no se pudieron reconstruir todas las recetas.",
309
+ target_name,
310
+ )
311
+ self._did_infer_missing_artefacts = True
312
+ return
313
+
314
+ X_ref = reference_df.drop(columns=[target_name]).copy()
315
+ y_ref = self._encode_binary_target(reference_df[target_name].copy())
316
+
317
+ rare_recipe = self._get_rare_recipe() or self._learn_rare_recipe(X_ref)
318
+ X_rare = self._apply_rare_labeling(X_ref, rare_recipe)
319
+
320
+ if rare_recipe and "receta_categorias_raras" not in self.artefactos:
321
+ self.artefactos["receta_categorias_raras"] = rare_recipe
322
+
323
+ binary_recipe = self._get_binary_recipe() or self._learn_binary_recipe(X_rare)
324
+ if binary_recipe and "receta_mapeo_binario" not in self.artefactos:
325
+ self.artefactos["receta_mapeo_binario"] = binary_recipe
326
+
327
+ target_recipe = self._get_target_recipe() or self._learn_target_encoding_recipe(X_rare, y_ref, self.rutas)
328
+ if target_recipe and "receta_target_encoding" not in self.artefactos and "receta_woe_encoding" not in self.artefactos:
329
+ self.artefactos["receta_target_encoding"] = target_recipe
330
+
331
+ llm_recipe = self._get_llm_recipe() or self._infer_llm_recipe_from_feature_names(feature_names)
332
+ if llm_recipe and "receta_llm_fe" not in self.artefactos:
333
+ self.artefactos["receta_llm_fe"] = llm_recipe
334
+
335
+ if needs_binary or needs_target or needs_rare or needs_llm:
336
+ logger.warning(
337
+ "Se reconstruyeron artefactos faltantes del notebook usando '%s'. "
338
+ "La causa raiz es un desajuste entre la exportacion del .pkl y la API.",
339
+ self._reference_dataset_path().name,
340
+ )
341
+
342
+ self._did_infer_missing_artefacts = True
343
+
344
+ def _apply_llm_formulas(self, X: pd.DataFrame) -> pd.DataFrame:
345
+ llm_recipe = self._get_llm_recipe()
346
+ if not llm_recipe:
347
+ return X
348
+
349
+ X_trans = X.copy()
350
+ safe_env = {"np": np, "X": X_trans}
351
+
352
+ for feature_name, formula in llm_recipe.items():
353
+ try:
354
+ X_trans[feature_name] = eval(formula, {"__builtins__": {}}, safe_env)
355
+ except Exception:
356
+ X_trans[feature_name] = 0.0
357
+
358
+ return X_trans
359
+
360
+ def _transformar_features(self, X_raw: pd.DataFrame) -> pd.DataFrame:
361
+ self._ensure_runtime_state()
362
+ self._infer_missing_artefacts()
363
+
364
+ X = self._normalize_input_frame(X_raw)
365
+ X = self._apply_llm_formulas(X)
366
+
367
+ receta_raras = self._get_rare_recipe()
368
+ if receta_raras:
369
+ X = self._apply_rare_labeling(X, receta_raras)
370
+
371
+ receta_target = self._get_target_recipe()
372
+ for col, config_encoding in receta_target.items():
373
+ if col not in X.columns:
374
+ continue
375
+
376
+ if "__GLOBAL_NEUTRAL__" in config_encoding:
377
+ neutral = config_encoding.get("__GLOBAL_NEUTRAL__", 0.0)
378
+ mask_nan = X[col].isna()
379
+ pure_map = {key: value for key, value in config_encoding.items() if key != "__GLOBAL_NEUTRAL__"}
380
+ X[col] = X[col].astype(object).map(pure_map).fillna(neutral)
381
+ X.loc[mask_nan, col] = np.nan
382
+ continue
383
+
384
+ for class_name, mapping in config_encoding.items():
385
+ global_mean = mapping.get("__GLOBAL_MEAN__", 0.0)
386
+ pure_map = {key: value for key, value in mapping.items() if key != "__GLOBAL_MEAN__"}
387
+ new_col = col if len(config_encoding) == 1 else f"{col}_prob_{class_name}"
388
+ mask_nan = X[col].isna()
389
+ X[new_col] = X[col].astype(object).map(pure_map).fillna(global_mean)
390
+ X.loc[mask_nan, new_col] = np.nan
391
+
392
+ if len(config_encoding) > 1:
393
+ X.drop(columns=[col], inplace=True)
394
+
395
+ receta_binaria = self._get_binary_recipe()
396
+ for col, mapping in receta_binaria.items():
397
+ if col in X.columns:
398
+ X[col] = X[col].map(mapping).fillna(0).astype(int)
399
+
400
+ receta_ratios = self._get_ratio_recipe()
401
+ for ratio in receta_ratios:
402
+ if len(ratio) != 3:
403
+ continue
404
+ if ratio[0] in X.columns and ratio[1] in X.columns and ratio[2] not in X.columns:
405
+ div_col, num_col, ratio_name = ratio
406
+ else:
407
+ ratio_name, num_col, div_col = ratio
408
+
409
+ if num_col in X.columns and div_col in X.columns:
410
+ X[ratio_name] = X[num_col].astype(float) / (X[div_col].astype(float) + 1e-9)
411
+
412
+ receta_winsor = self.artefactos.get("receta_winsorizacion", {})
413
+ for col, (lim_inf, lim_sup) in receta_winsor.items():
414
+ if col in X.columns:
415
+ X[col] = pd.to_numeric(X[col], errors="coerce").clip(lower=lim_inf, upper=lim_sup)
416
+
417
+ escalador = self.modelos.get("escalador_numerico")
418
+ if escalador is not None:
419
+ cols_to_scale = getattr(escalador, "feature_names_in_", [])
420
+ cols_present = [col for col in cols_to_scale if col in X.columns]
421
+ if cols_present:
422
+ X.loc[:, cols_present] = escalador.transform(X[cols_present]).astype(np.float32)
423
+
424
+ basura = (
425
+ self.rutas.get("basura_boruta", [])
426
+ + self.rutas.get("gemelos_colineales", [])
427
+ + self.rutas.get("fugas_del_futuro", [])
428
+ )
429
+ basura_presente = [col for col in basura if col in X.columns]
430
+ if basura_presente:
431
+ X.drop(columns=basura_presente, inplace=True)
432
+
433
+ return X
434
+
435
+ def _coerce_model_input(self, X: pd.DataFrame, expected_features: list[str]) -> pd.DataFrame:
436
+ X_final = X.copy()
437
+
438
+ for feature in expected_features:
439
+ if feature not in X_final.columns:
440
+ X_final[feature] = np.nan
441
+
442
+ X_final = X_final[expected_features].copy()
443
+
444
+ receta_nativas = self.artefactos.get("receta_categorias_nativas", {})
445
+ for col, categories in receta_nativas.items():
446
+ if col in X_final.columns:
447
+ dtype = pd.CategoricalDtype(categories=categories, ordered=False)
448
+ X_final[col] = X_final[col].astype(str).replace("nan", np.nan).astype(dtype)
449
+
450
+ for col in X_final.columns:
451
+ if isinstance(X_final[col].dtype, pd.CategoricalDtype):
452
+ continue
453
+ if pd.api.types.is_object_dtype(X_final[col]) or pd.api.types.is_string_dtype(X_final[col]):
454
+ X_final[col] = pd.to_numeric(X_final[col], errors="coerce")
455
+
456
+ return X_final
457
+
458
+ def predict_proba(self, X_raw: pd.DataFrame) -> np.ndarray:
459
+ with warnings.catch_warnings():
460
+ warnings.simplefilter("ignore")
461
+
462
+ modelo_final = self._get_modelo_final()
463
+ if modelo_final is None:
464
+ raise RuntimeError("No hay un modelo cargado dentro del pipeline de produccion.")
465
+
466
+ expected_features = self._get_training_feature_names()
467
+ X_procesado = self._transformar_features(X_raw)
468
+ X_final = self._coerce_model_input(X_procesado, expected_features or list(X_procesado.columns))
469
+ return modelo_final.predict_proba(X_final)
470
+
471
+ def predict(self, X_raw: pd.DataFrame) -> np.ndarray:
472
+ probas = self.predict_proba(X_raw)
473
+
474
+ if probas.shape[1] == 2:
475
+ classes = (probas[:, 1] >= self.umbral_oro).astype(int)
476
+ label_map = {0: "<=50K", 1: ">50K"}
477
+ return np.array([label_map[value] for value in classes])
478
+
479
+ return np.argmax(probas, axis=1)
app/ml/model_manager.py ADDED
@@ -0,0 +1,123 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import sys
5
+ from dataclasses import dataclass
6
+ import json
7
+ from pathlib import Path
8
+ from threading import Lock
9
+ from typing import Any
10
+
11
+ import joblib
12
+ import numpy as np
13
+ import pandas as pd
14
+
15
+ import app.ml.custom_transformers as custom_transformers
16
+ from app.core.exceptions import ModelInferenceError, ServiceUnavailableError
17
+
18
+ logger = logging.getLogger("oraculo_api.model")
19
+
20
+
21
+ @dataclass(slots=True)
22
+ class ModelPrediction:
23
+ label: str
24
+ probability: float
25
+ raw_probabilities: list[float]
26
+ model_version: str
27
+
28
+
29
+ class ModelManager:
30
+ def __init__(self, model_path: str | Path):
31
+ self.model_path = Path(model_path)
32
+ self.pipeline: Any = None
33
+ self.manifest: dict[str, Any] = {}
34
+ self._lock = Lock()
35
+
36
+ @property
37
+ def is_loaded(self) -> bool:
38
+ return self.pipeline is not None
39
+
40
+ @property
41
+ def model_version(self) -> str:
42
+ if self.manifest.get("model_version"):
43
+ return str(self.manifest["model_version"])
44
+ if self.pipeline is None:
45
+ return "unloaded"
46
+ return str(getattr(self.pipeline, "version", "unknown"))
47
+
48
+ @property
49
+ def manifest_path(self) -> Path:
50
+ return self.model_path.with_name("model_manifest.json")
51
+
52
+ def _register_pickle_bridge(self) -> None:
53
+ setattr(
54
+ sys.modules["__main__"],
55
+ "PipelineProduccionMLOps",
56
+ custom_transformers.PipelineProduccionMLOps,
57
+ )
58
+
59
+ def load_model(self) -> None:
60
+ with self._lock:
61
+ if self.pipeline is not None:
62
+ return
63
+
64
+ if not self.model_path.exists():
65
+ raise ServiceUnavailableError(f"Model artifact not found at '{self.model_path}'.")
66
+
67
+ try:
68
+ self._register_pickle_bridge()
69
+ self.pipeline = joblib.load(self.model_path)
70
+ if hasattr(self.pipeline, "_infer_missing_artefacts"):
71
+ self.pipeline._infer_missing_artefacts()
72
+ if self.manifest_path.exists():
73
+ self.manifest = json.loads(self.manifest_path.read_text(encoding="utf-8"))
74
+ logger.info("Model artifact loaded from %s", self.model_path)
75
+ except Exception as exc:
76
+ logger.exception("Unable to load model artifact: %s", exc)
77
+ raise ServiceUnavailableError("Model artifact could not be loaded.") from exc
78
+
79
+ def unload_model(self) -> None:
80
+ with self._lock:
81
+ self.pipeline = None
82
+ self.manifest = {}
83
+
84
+ def _ensure_loaded(self) -> Any:
85
+ if self.pipeline is None:
86
+ raise ServiceUnavailableError("Model is not loaded.")
87
+ return self.pipeline
88
+
89
+ @staticmethod
90
+ def _to_frame(input_data: dict[str, Any] | pd.DataFrame) -> pd.DataFrame:
91
+ if isinstance(input_data, pd.DataFrame):
92
+ return input_data.copy()
93
+ return pd.DataFrame([input_data])
94
+
95
+ def predict(self, input_data: dict[str, Any] | pd.DataFrame) -> np.ndarray:
96
+ pipeline = self._ensure_loaded()
97
+ try:
98
+ frame = self._to_frame(input_data)
99
+ return pipeline.predict(frame)
100
+ except Exception as exc:
101
+ logger.exception("Prediction failed: %s", exc)
102
+ raise ModelInferenceError("Prediction failed.") from exc
103
+
104
+ def predict_proba(self, input_data: dict[str, Any] | pd.DataFrame) -> np.ndarray:
105
+ pipeline = self._ensure_loaded()
106
+ try:
107
+ frame = self._to_frame(input_data)
108
+ return pipeline.predict_proba(frame)
109
+ except Exception as exc:
110
+ logger.exception("Probability inference failed: %s", exc)
111
+ raise ModelInferenceError("Probability inference failed.") from exc
112
+
113
+ def predict_one(self, input_data: dict[str, Any]) -> ModelPrediction:
114
+ labels = self.predict(input_data)
115
+ probabilities = self.predict_proba(input_data)
116
+ probability_vector = probabilities[0].tolist()
117
+ positive_probability = float(probability_vector[1] if len(probability_vector) > 1 else probability_vector[0])
118
+ return ModelPrediction(
119
+ label=str(labels[0]),
120
+ probability=positive_probability,
121
+ raw_probabilities=probability_vector,
122
+ model_version=self.model_version,
123
+ )
app/ml/pipeline_produccion.pkl ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:78ce8c47e3e47951191bbe190af36bc975973b249fbf7449a621d554ab45ad87
3
+ size 6454915
app/schemas/__init__.py ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from app.schemas.auth import LoginRequest, RegisterRequest, TokenResponse, UserResponse
2
+ from app.schemas.health import LiveHealthResponse, ReadyHealthResponse
3
+ from app.schemas.prediction import (
4
+ PredictionDetailResponse,
5
+ PredictionInput,
6
+ PredictionListResponse,
7
+ PredictionResponse,
8
+ )
9
+
10
+ __all__ = [
11
+ "LiveHealthResponse",
12
+ "LoginRequest",
13
+ "PredictionDetailResponse",
14
+ "PredictionInput",
15
+ "PredictionListResponse",
16
+ "PredictionResponse",
17
+ "ReadyHealthResponse",
18
+ "RegisterRequest",
19
+ "TokenResponse",
20
+ "UserResponse",
21
+ ]
app/schemas/adult_dataset.py ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ from app.schemas.prediction import PredictionInput as AdultDataInput
2
+ from app.schemas.prediction import PredictionResponse as AdultPredictionOutput
3
+
4
+ __all__ = ["AdultDataInput", "AdultPredictionOutput"]
app/schemas/auth.py ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from datetime import datetime
5
+ from typing import Literal
6
+
7
+ from pydantic import ConfigDict, Field, field_validator
8
+
9
+ from app.schemas.common import BaseSchema
10
+
11
+ EMAIL_PATTERN = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
12
+
13
+
14
+ class RegisterRequest(BaseSchema):
15
+ model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
16
+
17
+ email: str = Field(..., max_length=255)
18
+ full_name: str = Field(..., min_length=3, max_length=255)
19
+ password: str = Field(..., min_length=12, max_length=128)
20
+
21
+ @field_validator("email")
22
+ @classmethod
23
+ def validate_email(cls, value: str) -> str:
24
+ lowered = value.lower()
25
+ if not EMAIL_PATTERN.match(lowered):
26
+ raise ValueError("Invalid email format.")
27
+ return lowered
28
+
29
+ @field_validator("password")
30
+ @classmethod
31
+ def validate_password_strength(cls, value: str) -> str:
32
+ checks = [
33
+ any(char.islower() for char in value),
34
+ any(char.isupper() for char in value),
35
+ any(char.isdigit() for char in value),
36
+ any(not char.isalnum() for char in value),
37
+ ]
38
+ if not all(checks):
39
+ raise ValueError(
40
+ "Password must contain uppercase, lowercase, numeric, and special characters."
41
+ )
42
+ return value
43
+
44
+
45
+ class LoginRequest(BaseSchema):
46
+ model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
47
+
48
+ email: str = Field(..., max_length=255)
49
+ password: str = Field(..., min_length=12, max_length=128)
50
+
51
+ @field_validator("email")
52
+ @classmethod
53
+ def normalize_email(cls, value: str) -> str:
54
+ lowered = value.lower()
55
+ if not EMAIL_PATTERN.match(lowered):
56
+ raise ValueError("Invalid email format.")
57
+ return lowered
58
+
59
+
60
+ class UserResponse(BaseSchema):
61
+ id: str
62
+ email: str
63
+ full_name: str
64
+ role: Literal["admin", "user"]
65
+ is_active: bool
66
+ created_at: datetime
67
+ updated_at: datetime
68
+
69
+
70
+ class TokenResponse(BaseSchema):
71
+ access_token: str
72
+ token_type: Literal["bearer"] = "bearer"
73
+ expires_in_seconds: int
74
+ user: UserResponse
app/schemas/common.py ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime
4
+
5
+ from pydantic import BaseModel, ConfigDict
6
+
7
+
8
+ class BaseSchema(BaseModel):
9
+ model_config = ConfigDict(from_attributes=True)
10
+
11
+
12
+ class PaginationMeta(BaseSchema):
13
+ total: int
14
+ skip: int
15
+ limit: int
16
+
17
+
18
+ class TimestampedSchema(BaseSchema):
19
+ created_at: datetime
20
+ updated_at: datetime
app/schemas/health.py ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from app.schemas.common import BaseSchema
4
+
5
+
6
+ class LiveHealthResponse(BaseSchema):
7
+ status: str
8
+ service: str
9
+ version: str
10
+
11
+
12
+ class ReadyHealthResponse(BaseSchema):
13
+ status: str
14
+ service: str
15
+ version: str
16
+ model_loaded: bool
17
+ database_connected: bool