Spaces:
Sleeping
Sleeping
Actualizo la app
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .dockerignore +15 -0
- .env.example +28 -0
- .gitignore +124 -0
- Dockerfile +31 -0
- EDA_For_All_Tree.ipynb +0 -0
- EDA_For_All_Tree_clean.ipynb +0 -0
- README.md +325 -6
- adult.csv +0 -0
- alembic.ini +149 -0
- alembic/README +1 -0
- alembic/env.py +57 -0
- alembic/script.py.mako +28 -0
- alembic/versions/33e682013d1c_initial_api_schema.py +71 -0
- app/__init__.py +0 -0
- app/api/__init__.py +3 -0
- app/api/dependencies.py +78 -0
- app/api/router.py +6 -0
- app/api/v1/__init__.py +3 -0
- app/api/v1/endpoints/__init__.py +1 -0
- app/api/v1/endpoints/auth.py +29 -0
- app/api/v1/endpoints/health.py +21 -0
- app/api/v1/endpoints/predictions.py +61 -0
- app/api/v1/router.py +10 -0
- app/core/__init__.py +0 -0
- app/core/config.py +103 -0
- app/core/error_handlers.py +68 -0
- app/core/exceptions.py +62 -0
- app/core/logging.py +34 -0
- app/core/middleware.py +151 -0
- app/core/security.py +73 -0
- app/db/__init__.py +3 -0
- app/db/base.py +27 -0
- app/db/models/__init__.py +4 -0
- app/db/models/prediction_log.py +27 -0
- app/db/models/user.py +27 -0
- app/db/repositories/__init__.py +4 -0
- app/db/repositories/predictions.py +73 -0
- app/db/repositories/users.py +31 -0
- app/db/seeds.py +32 -0
- app/db/session.py +57 -0
- app/main.py +100 -0
- app/ml/__init__.py +0 -0
- app/ml/custom_transformers.py +479 -0
- app/ml/model_manager.py +123 -0
- app/ml/pipeline_produccion.pkl +3 -0
- app/schemas/__init__.py +21 -0
- app/schemas/adult_dataset.py +4 -0
- app/schemas/auth.py +74 -0
- app/schemas/common.py +20 -0
- app/schemas/health.py +17 -0
.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
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
|
|
|
|
|
|
| 7 |
pinned: false
|
| 8 |
-
license: mit
|
| 9 |
---
|
| 10 |
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|