Spaces:
Sleeping
Sleeping
github-actions commited on
Commit ·
5fa8558
0
Parent(s):
deploy: snapshot
Browse files- .env.example +11 -0
- .github/workflows/ci.yml +28 -0
- .github/workflows/deploy.yml +32 -0
- .gitignore +11 -0
- Dockerfile +19 -0
- README.md +350 -0
- app/__init__.py +0 -0
- app/core/__init__.py +0 -0
- app/core/config.py +35 -0
- app/db/__init__.py +0 -0
- app/db/engine.py +8 -0
- app/db/queries.py +20 -0
- app/main.py +95 -0
- app/ml/__init__.py +0 -0
- app/ml/loader.py +49 -0
- app/ml/predict.py +84 -0
- app/ml/preprocessing.py +18 -0
- app/schemas/__init__.py +0 -0
- app/schemas/prediction.py +39 -0
- app/security/__init__.py +0 -0
- app/security/auth.py +14 -0
- app/services/__init__.py +0 -0
- app/services/audit.py +31 -0
- app/services/features.py +18 -0
- app/services/predict.py +35 -0
- config/threshold.json +1 -0
- db/01_schema.sql +15 -0
- db/02_raw_tables.sql +58 -0
- db/03_load_raw.sql +35 -0
- db/04_staging.sql +119 -0
- db/05_mart.sql +74 -0
- db/06_audit.sql +44 -0
- db/README_SQL.md +167 -0
- encoder/__init__.py +0 -0
- encoder/custom_encoder.py +54 -0
- requirements.txt +15 -0
- tests/conftest.py +22 -0
- tests/test_api.py +108 -0
- tests/test_audit.py +20 -0
- tests/test_engine.py +9 -0
- tests/test_feature.py +24 -0
- tests/test_health.py +9 -0
- tests/test_predict.py +153 -0
.env.example
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Authentification API
|
| 2 |
+
API_KEY=your_api_key_here
|
| 3 |
+
|
| 4 |
+
# Base de données PostgreSQL
|
| 5 |
+
DATABASE_URL=postgresql://user:password@localhost:5432/technova
|
| 6 |
+
|
| 7 |
+
# Modèle ML (local ou Hugging Face)
|
| 8 |
+
MODEL_PATH=path/to/model.joblib
|
| 9 |
+
HF_MODEL_REPO=donizetti-yoann/technova-ml-model
|
| 10 |
+
HF_MODEL_FILENAME=model.joblib
|
| 11 |
+
HF_TOKEN=your_huggingface_token_here
|
.github/workflows/ci.yml
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: CI
|
| 2 |
+
|
| 3 |
+
on:
|
| 4 |
+
push:
|
| 5 |
+
branches: ["main", "develop", "feature/**"]
|
| 6 |
+
pull_request:
|
| 7 |
+
branches: ["main", "develop"]
|
| 8 |
+
|
| 9 |
+
jobs:
|
| 10 |
+
tests:
|
| 11 |
+
runs-on: ubuntu-latest
|
| 12 |
+
|
| 13 |
+
steps:
|
| 14 |
+
- uses: actions/checkout@v4
|
| 15 |
+
|
| 16 |
+
- uses: actions/setup-python@v5
|
| 17 |
+
with:
|
| 18 |
+
python-version: "3.11"
|
| 19 |
+
cache: "pip"
|
| 20 |
+
|
| 21 |
+
- name: Install dependencies
|
| 22 |
+
run: |
|
| 23 |
+
python -m pip install --upgrade pip
|
| 24 |
+
pip install -r requirements.txt
|
| 25 |
+
|
| 26 |
+
- name: Run tests
|
| 27 |
+
run: |
|
| 28 |
+
python -m pytest -q
|
.github/workflows/deploy.yml
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: Deploy to Hugging Face Space
|
| 2 |
+
|
| 3 |
+
on:
|
| 4 |
+
push:
|
| 5 |
+
branches: ["main"]
|
| 6 |
+
workflow_dispatch:
|
| 7 |
+
|
| 8 |
+
jobs:
|
| 9 |
+
deploy:
|
| 10 |
+
runs-on: ubuntu-latest
|
| 11 |
+
steps:
|
| 12 |
+
- name: Checkout (shallow)
|
| 13 |
+
uses: actions/checkout@v4
|
| 14 |
+
with:
|
| 15 |
+
fetch-depth: 1
|
| 16 |
+
|
| 17 |
+
- name: Prepare clean deploy repo (no git history)
|
| 18 |
+
run: |
|
| 19 |
+
rm -rf .git
|
| 20 |
+
git init
|
| 21 |
+
git config user.email "actions@github.com"
|
| 22 |
+
git config user.name "github-actions"
|
| 23 |
+
git add .
|
| 24 |
+
git commit -m "deploy: snapshot"
|
| 25 |
+
|
| 26 |
+
- name: Push snapshot to Hugging Face Space
|
| 27 |
+
env:
|
| 28 |
+
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
| 29 |
+
HF_SPACE_REPO: ${{ vars.HF_SPACE_REPO }}
|
| 30 |
+
run: |
|
| 31 |
+
git remote add hf https://user:${HF_TOKEN}@huggingface.co/spaces/${HF_SPACE_REPO}
|
| 32 |
+
git push hf HEAD:main --force
|
.gitignore
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.venv/
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.pyc
|
| 4 |
+
.vscode/
|
| 5 |
+
.idea/
|
| 6 |
+
models/*.joblib
|
| 7 |
+
.env
|
| 8 |
+
data/
|
| 9 |
+
.coverage
|
| 10 |
+
htmlcov/
|
| 11 |
+
coverage.xml
|
Dockerfile
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
# HF Spaces Docker: le container tourne avec UID 1000
|
| 4 |
+
RUN useradd -m -u 1000 user
|
| 5 |
+
|
| 6 |
+
ENV HOME=/home/user \
|
| 7 |
+
PATH=/home/user/.local/bin:$PATH
|
| 8 |
+
|
| 9 |
+
WORKDIR /home/user/app
|
| 10 |
+
|
| 11 |
+
COPY requirements.txt .
|
| 12 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 13 |
+
|
| 14 |
+
COPY . .
|
| 15 |
+
|
| 16 |
+
EXPOSE 7860
|
| 17 |
+
|
| 18 |
+
# Utilise le port attendu par HF (7860 par défaut)
|
| 19 |
+
CMD ["sh", "-c", "uvicorn app.main:app --host 0.0.0.0 --port ${PORT:-7860}"]
|
README.md
ADDED
|
@@ -0,0 +1,350 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Technova ML API
|
| 3 |
+
emoji: 🤖
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: purple
|
| 6 |
+
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
+
pinned: false
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
# Technova ML API
|
| 12 |
+
|
| 13 |
+
## Sommaire
|
| 14 |
+
- [Présentation du projet](#présentation-du-projet)
|
| 15 |
+
- [Structure du projet](#structure-du-projet)
|
| 16 |
+
- [Architecture globale](#architecture-globale)
|
| 17 |
+
- [Intégration Continue et Déploiement Continu](#intégration-continue-et-déploiement-continu)
|
| 18 |
+
- [Architecture des données (BDD)](#architecture-des-données-bdd)
|
| 19 |
+
- [Modèle de Machine Learning](#modèle-de-machine-learning)
|
| 20 |
+
- [API FastAPI](#api-fastapi)
|
| 21 |
+
- [Sécurité et authentification](#sécurité-et-authentification)
|
| 22 |
+
- [Audit et traçabilité](#audit-et-traçabilité)
|
| 23 |
+
- [Tests et qualité du code](#tests-et-qualité-du-code)
|
| 24 |
+
- [Déploiement](#déploiement)
|
| 25 |
+
- [Installation et utilisation](#installation-et-utilisation)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
---
|
| 29 |
+
|
| 30 |
+
## Présentation du projet
|
| 31 |
+
|
| 32 |
+
**Technova ML API** est une API de prédiction d’attrition des employés basée sur un modèle de Machine Learning.
|
| 33 |
+
Elle permet de prédire la probabilité de départ d’un employé à partir de données RH structurées.
|
| 34 |
+
|
| 35 |
+
---
|
| 36 |
+
|
| 37 |
+
## Structure du projet
|
| 38 |
+
|
| 39 |
+
Le dépôt est organisé de manière modulaire afin de séparer
|
| 40 |
+
les responsabilités (API, ML, base de données, tests).
|
| 41 |
+
|
| 42 |
+
```text
|
| 43 |
+
technova-ml-api/
|
| 44 |
+
├─ app/
|
| 45 |
+
│ ├─ main.py # Point d’entrée de l’API FastAPI
|
| 46 |
+
│ ├─ core/ # Configuration et settings
|
| 47 |
+
│ ├─ security/ # Authentification (API Key)
|
| 48 |
+
│ ├─ ml/ # Chargement du modèle et prédictions
|
| 49 |
+
│ ├─ services/ # Logique métier (predict, audit)
|
| 50 |
+
│ ├─ db/ # Connexion DB et scripts SQL
|
| 51 |
+
│ └─ schemas/ # Schémas Pydantic (entrées/sorties)
|
| 52 |
+
│
|
| 53 |
+
├─ db/
|
| 54 |
+
│ ├─ schema.sql # Création des schémas PostgreSQL
|
| 55 |
+
│ ├─ raw.sql # Tables de données brutes
|
| 56 |
+
│ ├─ staging.sql # Nettoyage et transformations
|
| 57 |
+
│ ├─ mart.sql # Dataset final pour le modèle ML
|
| 58 |
+
│ └─ audit.sql # Journalisation des prédictions
|
| 59 |
+
│
|
| 60 |
+
├─ tests/ # Tests unitaires et fonctionnels (Pytest)
|
| 61 |
+
│
|
| 62 |
+
├─ .github/workflows/ # Pipeline CI (tests automatiques)
|
| 63 |
+
├─ requirements.txt
|
| 64 |
+
└─ README.md
|
| 65 |
+
```
|
| 66 |
+
---
|
| 67 |
+
|
| 68 |
+
## Architecture globale
|
| 69 |
+
|
| 70 |
+
- API développée avec **FastAPI**
|
| 71 |
+
- Base de données **PostgreSQL**
|
| 72 |
+
- Modèle ML entraîné en amont (hors API), puis chargé au démarrage de l’application
|
| 73 |
+
- Déploiement local et sur **Hugging Face Spaces**
|
| 74 |
+
- Sécurité par **API Key**
|
| 75 |
+
- Tests automatisés avec **Pytest**
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
---
|
| 80 |
+
## Intégration Continue et Déploiement Continu
|
| 81 |
+
|
| 82 |
+
Le projet intègre une démarche d’intégration continue (CI) afin de garantir
|
| 83 |
+
la qualité et la stabilité du code à chaque modification.
|
| 84 |
+
Les mises à jour du code ou du modèle sont réalisées via des commits sur la branche principale,
|
| 85 |
+
déclenchant automatiquement les tests et le redéploiement de l’API grâce au pipeline CI/CD.
|
| 86 |
+
|
| 87 |
+
### Intégration Continue (CI)
|
| 88 |
+
- Pipeline automatisé via **GitHub Actions**
|
| 89 |
+
- Exécution des tests Pytest à chaque push et pull request
|
| 90 |
+
- Validation du code avant fusion sur la branche principale
|
| 91 |
+
- Détection précoce des régressions
|
| 92 |
+
|
| 93 |
+
### Déploiement Continu (CD)
|
| 94 |
+
- Déploiement de l’API sur **Hugging Face Spaces**
|
| 95 |
+
- Gestion des secrets (API Key, accès modèle) via variables d’environnement
|
| 96 |
+
- Séparation des environnements (local / CI / production)
|
| 97 |
+
|
| 98 |
+
Cette approche permet un déploiement fiable, reproductible et sécurisé
|
| 99 |
+
du modèle de Machine Learning exposé par l’API.
|
| 100 |
+
|
| 101 |
+
---
|
| 102 |
+
|
| 103 |
+
## Architecture des données (BDD)
|
| 104 |
+
|
| 105 |
+
Les détails techniques concernant la base de données
|
| 106 |
+
(schémas, tables, scripts SQL et pipeline de transformation)
|
| 107 |
+
sont documentés dans `db/README_SQL.md`.
|
| 108 |
+
|
| 109 |
+
Le schéma ci-dessous présente le flux logique des données,
|
| 110 |
+
de l’ingestion jusqu’à l’audit des prédictions.
|
| 111 |
+
|
| 112 |
+
### Pipeline de données
|
| 113 |
+
|
| 114 |
+
```mermaid
|
| 115 |
+
flowchart TD
|
| 116 |
+
RAW[RAW<br/>Données brutes]
|
| 117 |
+
RAW_SIRH[extrait_sirh]
|
| 118 |
+
RAW_EVAL[extrait_eval]
|
| 119 |
+
RAW_SONDAGE[extrait_sondage]
|
| 120 |
+
|
| 121 |
+
STAGING[STAGING<br/>Nettoyage & normalisation]
|
| 122 |
+
STAGING_EMP[employee_base]
|
| 123 |
+
|
| 124 |
+
MART[MART<br/>Dataset ML]
|
| 125 |
+
MART_EMP[employee_features]
|
| 126 |
+
|
| 127 |
+
AUDIT[AUDIT<br/>Traçabilité API]
|
| 128 |
+
AUDIT_REQ[prediction_requests]
|
| 129 |
+
AUDIT_RES[prediction_responses]
|
| 130 |
+
|
| 131 |
+
RAW --> RAW_SIRH
|
| 132 |
+
RAW --> RAW_EVAL
|
| 133 |
+
RAW --> RAW_SONDAGE
|
| 134 |
+
|
| 135 |
+
RAW_SIRH --> STAGING
|
| 136 |
+
RAW_EVAL --> STAGING
|
| 137 |
+
RAW_SONDAGE --> STAGING
|
| 138 |
+
|
| 139 |
+
STAGING --> STAGING_EMP
|
| 140 |
+
STAGING_EMP --> MART
|
| 141 |
+
MART --> MART_EMP
|
| 142 |
+
|
| 143 |
+
MART_EMP -->|utilisé par le modèle ML| AUDIT
|
| 144 |
+
AUDIT --> AUDIT_REQ
|
| 145 |
+
AUDIT --> AUDIT_RES
|
| 146 |
+
```
|
| 147 |
+
|
| 148 |
+
### Description des schémas
|
| 149 |
+
|
| 150 |
+
- **RAW** : données brutes sans transformation.
|
| 151 |
+
- **STAGING** : nettoyage, normalisation et jointure des sources.
|
| 152 |
+
- **MART** : dataset final utilisé par le modèle ML.
|
| 153 |
+
- **AUDIT** : traçabilité complète des appels API et des prédictions.
|
| 154 |
+
|
| 155 |
+
---
|
| 156 |
+
|
| 157 |
+
## Modèle de Machine Learning
|
| 158 |
+
|
| 159 |
+
- Type : classification binaire
|
| 160 |
+
- Cible : départ de l’employé
|
| 161 |
+
- Sortie : probabilité + décision selon un seuil configurable
|
| 162 |
+
- Seuil stocké dans un fichier de configuration
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
Les performances du modèle ont été évaluées en amont lors du projet de data science,
|
| 166 |
+
et le modèle est ici réutilisé comme un composant validé pour un usage en production.
|
| 167 |
+
|
| 168 |
+
Le modèle peut être remplacé ou mis à jour sans modification de l’API, en respectant le même schéma d’entrée.
|
| 169 |
+
---
|
| 170 |
+
|
| 171 |
+
## API FastAPI
|
| 172 |
+
|
| 173 |
+
### Endpoints principaux
|
| 174 |
+
|
| 175 |
+
- `GET /health` : état de l’API
|
| 176 |
+
- `POST /predict` : prédiction à partir de données fournies
|
| 177 |
+
- `GET /predict/{id_employee}` : prédiction à partir de la base de données
|
| 178 |
+
|
| 179 |
+
L’endpoint `/health` permet de vérifier l’état de l’API(chargement du modèle, seuil, configuration de la base) et peut être utilisé pour le monitoring.
|
| 180 |
+
|
| 181 |
+
La documentation est disponible via Swagger :
|
| 182 |
+
`/docs`
|
| 183 |
+
|
| 184 |
+
---
|
| 185 |
+
### Exemple POST /predict
|
| 186 |
+
|
| 187 |
+
```json
|
| 188 |
+
{
|
| 189 |
+
"age": 41,
|
| 190 |
+
"genre": "femme",
|
| 191 |
+
"revenu_mensuel": 5993,
|
| 192 |
+
"statut_marital": "célibataire",
|
| 193 |
+
"departement": "commercial",
|
| 194 |
+
"poste": "cadre commercial",
|
| 195 |
+
"nombre_experiences_precedentes": 8,
|
| 196 |
+
"annees_dans_l_entreprise": 2,
|
| 197 |
+
"satisfaction_employee_environnement": 4,
|
| 198 |
+
"satisfaction_employee_nature_travail": 1,
|
| 199 |
+
"satisfaction_employee_equipe": 1,
|
| 200 |
+
"satisfaction_employee_equilibre_pro_perso": 1,
|
| 201 |
+
"heure_supplementaires": True,
|
| 202 |
+
"augmentation_salaire_precedente": 11,
|
| 203 |
+
"nombre_participation_pee": 0,
|
| 204 |
+
"nb_formations_suivies": 0,
|
| 205 |
+
"distance_domicile_travail": 1,
|
| 206 |
+
"niveau_education": 2,
|
| 207 |
+
"domaine_etude": "infra & cloud",
|
| 208 |
+
"frequence_deplacement": "occasionnel",
|
| 209 |
+
"annees_sous_responsable_actuel": 0,
|
| 210 |
+
"annees_dans_le_poste_actuel": 0,
|
| 211 |
+
"note_evaluation_actuelle": 0,
|
| 212 |
+
"note_evaluation_precedente": 0,
|
| 213 |
+
"annees_depuis_la_derniere_promotion": 0
|
| 214 |
+
}
|
| 215 |
+
```
|
| 216 |
+
```bash
|
| 217 |
+
curl -X POST http://localhost:8000/predict \
|
| 218 |
+
-H "Content-Type: application/json" \
|
| 219 |
+
-H "X-API-Key: <YOUR_API_KEY>" \
|
| 220 |
+
-d @payload.json
|
| 221 |
+
```
|
| 222 |
+
|
| 223 |
+
### Exemple GET /predict/7
|
| 224 |
+
|
| 225 |
+
Prédiction à partir des données stockées pour l’employé d’identifiant 7.
|
| 226 |
+
|
| 227 |
+
---
|
| 228 |
+
|
| 229 |
+
## Sécurité et authentification
|
| 230 |
+
|
| 231 |
+
- Protection des endpoints sensibles via **API Key**
|
| 232 |
+
- Clé transmise dans le header : `X-API-Key`
|
| 233 |
+
- Gestion des secrets via variables d’environnement
|
| 234 |
+
- Compatible CI/CD et Hugging Face Spaces
|
| 235 |
+
|
| 236 |
+
---
|
| 237 |
+
|
| 238 |
+
## Audit et traçabilité
|
| 239 |
+
|
| 240 |
+
Chaque appel de prédiction est enregistré :
|
| 241 |
+
|
| 242 |
+
- **prediction_requests** : payload d’entrée
|
| 243 |
+
- **prediction_responses** : probabilité, décision, seuil
|
| 244 |
+
|
| 245 |
+
Cette approche garantit la reproductibilité et l’auditabilité des prédictions.
|
| 246 |
+
|
| 247 |
+
---
|
| 248 |
+
|
| 249 |
+
## Tests et qualité du code
|
| 250 |
+
|
| 251 |
+
- Tests unitaires et fonctionnels avec **Pytest**
|
| 252 |
+
- Tests de sécurité (API Key)
|
| 253 |
+
- Tests des endpoints critiques
|
| 254 |
+
- Exécution automatisée en CI
|
| 255 |
+
|
| 256 |
+
Les dépendances externes (chargement du modèle distant, connexion PostgreSQL réelle) ne sont pas testées en CI afin de garantir des tests rapides et reproductibles. Ces scénarios relèvent de tests d’intégration ou d’environnements
|
| 257 |
+
|
| 258 |
+
### Couverture de tests
|
| 259 |
+
|
| 260 |
+
Le projet intègre une mesure de la couverture de tests afin d’évaluer
|
| 261 |
+
la robustesse du code et la fiabilité de l’API.
|
| 262 |
+
|
| 263 |
+
Les tests sont exécutés avec **pytest** et **pytest-cov**.
|
| 264 |
+
|
| 265 |
+
```bash
|
| 266 |
+
python -m pytest --cov=app --cov-report=term
|
| 267 |
+
```
|
| 268 |
+
```text
|
| 269 |
+
---
|
| 270 |
+
Name Stmts Miss Cover
|
| 271 |
+
-----------------------------------------------
|
| 272 |
+
app\__init__.py 0 0 100%
|
| 273 |
+
app\core\__init__.py 0 0 100%
|
| 274 |
+
app\core\config.py 17 0 100%
|
| 275 |
+
app\db\__init__.py 0 0 100%
|
| 276 |
+
app\db\engine.py 7 1 86%
|
| 277 |
+
app\db\queries.py 4 0 100%
|
| 278 |
+
app\main.py 51 13 75%
|
| 279 |
+
app\ml\__init__.py 0 0 100%
|
| 280 |
+
app\ml\loader.py 26 18 31%
|
| 281 |
+
app\ml\predict.py 28 7 75%
|
| 282 |
+
app\ml\preprocessing.py 8 0 100%
|
| 283 |
+
app\schemas\__init__.py 0 0 100%
|
| 284 |
+
app\schemas\prediction.py 31 0 100%
|
| 285 |
+
app\security\__init__.py 0 0 100%
|
| 286 |
+
app\security\auth.py 9 1 89%
|
| 287 |
+
app\services\__init__.py 0 0 100%
|
| 288 |
+
app\services\audit.py 7 0 100%
|
| 289 |
+
app\services\features.py 7 1 86%
|
| 290 |
+
app\services\predict.py 19 0 100%
|
| 291 |
+
-----------------------------------------------
|
| 292 |
+
TOTAL 214 41 81%
|
| 293 |
+
```
|
| 294 |
+
|
| 295 |
+
Les fichiers les moins couverts sont surtout ceux liés au démarrage et aux dépendances externes (chargement du modèle, Hugging Face, connexion DB). En mode test, j’isole ces dépendances avec un DummyModel pour avoir des tests rapides et reproductibles. Les tests couvrent en priorité l’API, la sécurité et les scénarios critiques. Les chemins restants seraient plutôt couverts via tests d’intégration.
|
| 296 |
+
|
| 297 |
+
---
|
| 298 |
+
|
| 299 |
+
|
| 300 |
+
## Déploiement
|
| 301 |
+
|
| 302 |
+
- Déploiement local (Python)
|
| 303 |
+
- Déploiement cloud sur Hugging Face Spaces
|
| 304 |
+
- Gestion des secrets via variables d’environnement
|
| 305 |
+
|
| 306 |
+
Lien de l’API déployée :
|
| 307 |
+
https://huggingface.co/spaces/donizetti-yoann/technova-ml-api
|
| 308 |
+
|
| 309 |
+
---
|
| 310 |
+
|
| 311 |
+
## Variables d’environnement
|
| 312 |
+
|
| 313 |
+
Les variables suivantes sont nécessaires au fonctionnement de l’API :
|
| 314 |
+
|
| 315 |
+
- `API_KEY` : clé d’authentification des endpoints
|
| 316 |
+
- `DATABASE_URL` : chaîne de connexion PostgreSQL
|
| 317 |
+
- `MODEL_PATH` : chemin vers le modèle local (optionnel)
|
| 318 |
+
- `HF_MODEL_REPO` / `HF_MODEL_FILENAME` : modèle hébergé sur Hugging Face
|
| 319 |
+
- `HF_TOKEN` : token Hugging Face
|
| 320 |
+
|
| 321 |
+
Ces variables sont fournies via l’environnement d’exécution
|
| 322 |
+
(local, CI/CD ou Hugging Face Spaces) et ne sont jamais stockées
|
| 323 |
+
en clair dans le dépôt.
|
| 324 |
+
|
| 325 |
+
### Configuration des variables d’environnement
|
| 326 |
+
|
| 327 |
+
Le projet utilise des variables d’environnement pour gérer la configuration
|
| 328 |
+
et les secrets.
|
| 329 |
+
|
| 330 |
+
Un fichier `.env.example` est fourni à la racine du dépôt.
|
| 331 |
+
Il peut être copié et renommé en `.env`, puis complété avec les valeurs
|
| 332 |
+
appropriées selon l’environnement d’exécution (local, CI/CD, production).
|
| 333 |
+
|
| 334 |
+
```bash
|
| 335 |
+
cp .env.example .env
|
| 336 |
+
```
|
| 337 |
+
---
|
| 338 |
+
|
| 339 |
+
## Installation et utilisation
|
| 340 |
+
|
| 341 |
+
```bash
|
| 342 |
+
git clone https://github.com/yoann-donizetti/technova-ml-api
|
| 343 |
+
cd technova-ml-api
|
| 344 |
+
pip install -r requirements.txt
|
| 345 |
+
uvicorn app.main:app --reload
|
| 346 |
+
```
|
| 347 |
+
|
| 348 |
+
---
|
| 349 |
+
Ce projet met en œuvre une API de Machine Learning prête pour un usage en production,
|
| 350 |
+
avec une attention particulière portée à la sécurité, à la testabilité et à la reproductibilité.
|
app/__init__.py
ADDED
|
File without changes
|
app/core/__init__.py
ADDED
|
File without changes
|
app/core/config.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from dataclasses import dataclass
|
| 3 |
+
from functools import lru_cache
|
| 4 |
+
from dotenv import load_dotenv
|
| 5 |
+
|
| 6 |
+
load_dotenv()#Charge automatiquement les variables définies dans un fichier .env
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
@dataclass(frozen=True)# Crée une classe de configuration immuable
|
| 10 |
+
class Settings:
|
| 11 |
+
DATABASE_URL: str | None
|
| 12 |
+
THRESHOLD_PATH: str
|
| 13 |
+
MODEL_PATH: str | None
|
| 14 |
+
HF_MODEL_REPO: str | None
|
| 15 |
+
HF_MODEL_FILENAME: str
|
| 16 |
+
HF_TOKEN: str | None
|
| 17 |
+
API_KEY: str | None
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
@lru_cache # Cache le résultat de la fonction en mémoire
|
| 21 |
+
def get_settings() -> Settings:
|
| 22 |
+
'''
|
| 23 |
+
La fonction get_settings retourne explicitement
|
| 24 |
+
un objet de type Settings, ce qui rend la configuration claire,
|
| 25 |
+
typée et plus sûre à l’échelle de l’application.
|
| 26 |
+
'''
|
| 27 |
+
return Settings(
|
| 28 |
+
DATABASE_URL=os.getenv("DATABASE_URL"),
|
| 29 |
+
THRESHOLD_PATH=os.getenv("THRESHOLD_PATH", "config/threshold.json"),
|
| 30 |
+
MODEL_PATH=os.getenv("MODEL_PATH"),
|
| 31 |
+
HF_MODEL_REPO=os.getenv("HF_MODEL_REPO"),
|
| 32 |
+
HF_MODEL_FILENAME=os.getenv("HF_MODEL_FILENAME", "model.joblib"),
|
| 33 |
+
HF_TOKEN=os.getenv("HF_TOKEN"),
|
| 34 |
+
API_KEY=os.getenv("API_KEY"),
|
| 35 |
+
)
|
app/db/__init__.py
ADDED
|
File without changes
|
app/db/engine.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from app.core.config import get_settings
|
| 2 |
+
from sqlalchemy import create_engine
|
| 3 |
+
# Initialise la connexion à la base de données à partir de la configuration centralisée
|
| 4 |
+
def get_engine():
|
| 5 |
+
settings = get_settings()
|
| 6 |
+
if not settings.DATABASE_URL:
|
| 7 |
+
return None
|
| 8 |
+
return create_engine(settings.DATABASE_URL, pool_pre_ping=True)
|
app/db/queries.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from sqlalchemy import text
|
| 2 |
+
# Requetes SQL utilisées par l'API
|
| 3 |
+
SQL_INSERT_REQUEST = text("""
|
| 4 |
+
INSERT INTO audit.prediction_requests (payload)
|
| 5 |
+
VALUES (CAST(:payload AS jsonb))
|
| 6 |
+
RETURNING request_id
|
| 7 |
+
""")
|
| 8 |
+
|
| 9 |
+
SQL_INSERT_RESPONSE = text("""
|
| 10 |
+
INSERT INTO audit.prediction_responses
|
| 11 |
+
(request_id, proba, prediction, threshold)
|
| 12 |
+
VALUES (:request_id, :proba, :prediction, :threshold)
|
| 13 |
+
""")
|
| 14 |
+
|
| 15 |
+
SQL_GET_EMPLOYEE_FEATURES = text("""
|
| 16 |
+
SELECT *
|
| 17 |
+
FROM mart.employee_features
|
| 18 |
+
WHERE id_employee = :id_employee
|
| 19 |
+
LIMIT 1
|
| 20 |
+
""")
|
app/main.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# app/main.py
|
| 2 |
+
import os
|
| 3 |
+
from contextlib import asynccontextmanager
|
| 4 |
+
|
| 5 |
+
from fastapi import FastAPI, HTTPException, Depends
|
| 6 |
+
from fastapi.responses import RedirectResponse
|
| 7 |
+
|
| 8 |
+
from app.security.auth import require_api_key
|
| 9 |
+
from app.ml.loader import load_model, load_threshold
|
| 10 |
+
from app.schemas.prediction import PredictionRequest, PredictionResponse
|
| 11 |
+
from app.services.predict import run_predict_manual, run_predict_by_id
|
| 12 |
+
from app.db.engine import get_engine
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
@asynccontextmanager
|
| 16 |
+
async def lifespan(app: FastAPI):
|
| 17 |
+
#mode test
|
| 18 |
+
if os.getenv("APP_ENV") == "test":
|
| 19 |
+
class DummyModel:
|
| 20 |
+
def predict_proba(self, X):
|
| 21 |
+
return [[0.2, 0.8]]
|
| 22 |
+
|
| 23 |
+
app.state.model = DummyModel()
|
| 24 |
+
app.state.threshold = 0.292
|
| 25 |
+
app.state.engine = None
|
| 26 |
+
yield
|
| 27 |
+
return
|
| 28 |
+
|
| 29 |
+
# mode normal
|
| 30 |
+
app.state.model = load_model()
|
| 31 |
+
app.state.threshold = float(load_threshold())
|
| 32 |
+
app.state.engine = get_engine()
|
| 33 |
+
print("[startup] model + threshold loaded OK")
|
| 34 |
+
yield
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
app = FastAPI(title="Technova ML API", version="1.0.0", lifespan=lifespan)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
@app.get("/", include_in_schema=False)
|
| 41 |
+
def root():
|
| 42 |
+
return RedirectResponse(url="/docs")
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
@app.get("/health")
|
| 46 |
+
def health():
|
| 47 |
+
model_loaded = getattr(app.state, "model", None) is not None
|
| 48 |
+
threshold = getattr(app.state, "threshold", None)
|
| 49 |
+
engine = getattr(app.state, "engine", None)
|
| 50 |
+
return {
|
| 51 |
+
"status": "ok",
|
| 52 |
+
"model_loaded": model_loaded,
|
| 53 |
+
"threshold": threshold,
|
| 54 |
+
"db_configured": engine is not None,
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
@app.post(
|
| 59 |
+
"/predict",
|
| 60 |
+
response_model=PredictionResponse,
|
| 61 |
+
tags=["default"],
|
| 62 |
+
dependencies=[Depends(require_api_key)],
|
| 63 |
+
)
|
| 64 |
+
def predict_manual(data: PredictionRequest):
|
| 65 |
+
try:
|
| 66 |
+
proba, pred, _payload = run_predict_manual(
|
| 67 |
+
payload=data.model_dump(),
|
| 68 |
+
model=app.state.model,
|
| 69 |
+
threshold=float(app.state.threshold),
|
| 70 |
+
engine=getattr(app.state, "engine", None),
|
| 71 |
+
)
|
| 72 |
+
return PredictionResponse(proba=float(proba), prediction=int(pred), threshold=float(app.state.threshold))
|
| 73 |
+
except Exception as e:
|
| 74 |
+
raise HTTPException(status_code=400, detail=str(e))
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
@app.get(
|
| 78 |
+
"/predict/{id_employee}",
|
| 79 |
+
response_model=PredictionResponse,
|
| 80 |
+
tags=["default"],
|
| 81 |
+
dependencies=[Depends(require_api_key)],
|
| 82 |
+
)
|
| 83 |
+
def predict_by_id(id_employee: int):
|
| 84 |
+
try:
|
| 85 |
+
proba, pred, _payload = run_predict_by_id(
|
| 86 |
+
id_employee=id_employee,
|
| 87 |
+
model=app.state.model,
|
| 88 |
+
threshold=float(app.state.threshold),
|
| 89 |
+
engine=getattr(app.state, "engine", None),
|
| 90 |
+
)
|
| 91 |
+
return PredictionResponse(proba=float(proba), prediction=int(pred), threshold=float(app.state.threshold))
|
| 92 |
+
except KeyError as e:
|
| 93 |
+
raise HTTPException(status_code=404, detail=str(e))
|
| 94 |
+
except Exception as e:
|
| 95 |
+
raise HTTPException(status_code=400, detail=str(e))
|
app/ml/__init__.py
ADDED
|
File without changes
|
app/ml/loader.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import os
|
| 3 |
+
import joblib
|
| 4 |
+
from huggingface_hub import hf_hub_download
|
| 5 |
+
|
| 6 |
+
from app.core.config import get_settings
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def load_threshold() -> float:
|
| 10 |
+
settings = get_settings()
|
| 11 |
+
threshold_path = settings.THRESHOLD_PATH
|
| 12 |
+
|
| 13 |
+
if not os.path.exists(threshold_path):
|
| 14 |
+
raise FileNotFoundError(f"Threshold file not found: {threshold_path}")
|
| 15 |
+
|
| 16 |
+
with open(threshold_path, "r", encoding="utf-8") as f:
|
| 17 |
+
return float(json.load(f)["threshold"])
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def load_model():
|
| 21 |
+
"""
|
| 22 |
+
Charge le modèle.
|
| 23 |
+
- Sur HF Spaces: on utilise HF_MODEL_REPO + HF_MODEL_FILENAME
|
| 24 |
+
- En local: on peut utiliser MODEL_PATH si tu l'as (optionnel)
|
| 25 |
+
"""
|
| 26 |
+
settings = get_settings()
|
| 27 |
+
|
| 28 |
+
# 1) modèle local si présent
|
| 29 |
+
if settings.MODEL_PATH:
|
| 30 |
+
if not os.path.exists(settings.MODEL_PATH):
|
| 31 |
+
raise FileNotFoundError(f"Local model not found: {settings.MODEL_PATH}")
|
| 32 |
+
return joblib.load(settings.MODEL_PATH)
|
| 33 |
+
|
| 34 |
+
# 2) sinon HF
|
| 35 |
+
if not settings.HF_MODEL_REPO or not settings.HF_MODEL_FILENAME:
|
| 36 |
+
raise RuntimeError("HF_MODEL_REPO and/or HF_MODEL_FILENAME not set")
|
| 37 |
+
|
| 38 |
+
model_path = hf_hub_download(
|
| 39 |
+
repo_id=settings.HF_MODEL_REPO,
|
| 40 |
+
filename=settings.HF_MODEL_FILENAME,
|
| 41 |
+
token=settings.HF_TOKEN, # ok même si None
|
| 42 |
+
)
|
| 43 |
+
return joblib.load(model_path)
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def load_artifacts():
|
| 47 |
+
model = load_model()
|
| 48 |
+
threshold = load_threshold()
|
| 49 |
+
return model, threshold
|
app/ml/predict.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pandas as pd
|
| 2 |
+
from app.ml.preprocessing import normalize_text
|
| 3 |
+
|
| 4 |
+
# Colonnes attendues par le modèle
|
| 5 |
+
FEATURE_COLUMNS = [
|
| 6 |
+
"age",
|
| 7 |
+
"genre",
|
| 8 |
+
"revenu_mensuel",
|
| 9 |
+
"statut_marital",
|
| 10 |
+
"departement",
|
| 11 |
+
"poste",
|
| 12 |
+
"nombre_experiences_precedentes",
|
| 13 |
+
"annees_dans_l_entreprise",
|
| 14 |
+
"satisfaction_employee_environnement",
|
| 15 |
+
"satisfaction_employee_nature_travail",
|
| 16 |
+
"satisfaction_employee_equipe",
|
| 17 |
+
"satisfaction_employee_equilibre_pro_perso",
|
| 18 |
+
"heure_supplementaires",
|
| 19 |
+
"augmentation_salaire_precedente",
|
| 20 |
+
"nombre_participation_pee",
|
| 21 |
+
"nb_formations_suivies",
|
| 22 |
+
"distance_domicile_travail",
|
| 23 |
+
"niveau_education",
|
| 24 |
+
"domaine_etude",
|
| 25 |
+
"frequence_deplacement",
|
| 26 |
+
"ratio_manager_anciennete",
|
| 27 |
+
"mobilite_relative",
|
| 28 |
+
"evolution_performance",
|
| 29 |
+
"pression_stagnation",
|
| 30 |
+
]
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def add_features_from_raw(df: pd.DataFrame) -> pd.DataFrame:
|
| 34 |
+
"""Ajoute les features calculées À PARTIR DES CHAMPS BRUTS (manuel)."""
|
| 35 |
+
df = df.copy()
|
| 36 |
+
|
| 37 |
+
df["ratio_manager_anciennete"] = (
|
| 38 |
+
(df["annees_sous_responsable_actuel"] + 1)
|
| 39 |
+
/ (df["annees_dans_l_entreprise"] + 1)
|
| 40 |
+
)
|
| 41 |
+
|
| 42 |
+
mobilite_interne = df["annees_dans_l_entreprise"] - df["annees_dans_le_poste_actuel"]
|
| 43 |
+
df["mobilite_relative"] = mobilite_interne / (df["annees_dans_l_entreprise"] + 1)
|
| 44 |
+
|
| 45 |
+
df["evolution_performance"] = df["note_evaluation_actuelle"] - df["note_evaluation_precedente"]
|
| 46 |
+
|
| 47 |
+
df["pression_stagnation"] = (
|
| 48 |
+
df["annees_depuis_la_derniere_promotion"]
|
| 49 |
+
/ (df["annees_dans_l_entreprise"] + 1)
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
return df
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def predict_manual(payload: dict, model, threshold: float):
|
| 56 |
+
"""
|
| 57 |
+
Cas /predict (manuel) : payload = champs bruts => on calcule features.
|
| 58 |
+
"""
|
| 59 |
+
df = pd.DataFrame([payload])
|
| 60 |
+
df = normalize_text(df)
|
| 61 |
+
df = add_features_from_raw(df)
|
| 62 |
+
|
| 63 |
+
X = df[FEATURE_COLUMNS]
|
| 64 |
+
proba = float(model.predict_proba(X)[0][1])
|
| 65 |
+
pred = int(proba >= float(threshold))
|
| 66 |
+
|
| 67 |
+
payload_enrichi = df.iloc[0].to_dict()
|
| 68 |
+
return proba, pred, payload_enrichi
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def predict_from_employee_features(employee_row: dict, model, threshold: float):
|
| 72 |
+
"""
|
| 73 |
+
Cas /predict/{id_employee} : la table mart.employee_features doit déjà contenir
|
| 74 |
+
les colonnes calculées (ratio_manager_anciennete, etc.).
|
| 75 |
+
"""
|
| 76 |
+
df = pd.DataFrame([employee_row])
|
| 77 |
+
df = normalize_text(df)
|
| 78 |
+
|
| 79 |
+
X = df[FEATURE_COLUMNS]
|
| 80 |
+
proba = float(model.predict_proba(X)[0][1])
|
| 81 |
+
pred = int(proba >= float(threshold))
|
| 82 |
+
|
| 83 |
+
payload_enrichi = df.iloc[0].to_dict()
|
| 84 |
+
return proba, pred, payload_enrichi
|
app/ml/preprocessing.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pandas as pd
|
| 2 |
+
|
| 3 |
+
TEXT_COLUMNS = [
|
| 4 |
+
"genre",
|
| 5 |
+
"statut_marital",
|
| 6 |
+
"departement",
|
| 7 |
+
"poste",
|
| 8 |
+
"domaine_etude",
|
| 9 |
+
"frequence_deplacement",
|
| 10 |
+
]
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def normalize_text(df: pd.DataFrame) -> pd.DataFrame:
|
| 14 |
+
df = df.copy()
|
| 15 |
+
for col in TEXT_COLUMNS:
|
| 16 |
+
if col in df.columns:
|
| 17 |
+
df[col] = df[col].astype(str).str.strip().str.lower()
|
| 18 |
+
return df
|
app/schemas/__init__.py
ADDED
|
File without changes
|
app/schemas/prediction.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic import BaseModel, Field
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
class PredictionRequest(BaseModel):
|
| 5 |
+
age: int = Field(..., ge=0)
|
| 6 |
+
genre: str
|
| 7 |
+
revenu_mensuel: int = Field(..., ge=0)
|
| 8 |
+
statut_marital: str
|
| 9 |
+
departement: str
|
| 10 |
+
poste: str
|
| 11 |
+
nombre_experiences_precedentes: int = Field(..., ge=0)
|
| 12 |
+
annees_dans_l_entreprise: int = Field(..., ge=0)
|
| 13 |
+
|
| 14 |
+
satisfaction_employee_environnement: int = Field(..., ge=0)
|
| 15 |
+
satisfaction_employee_nature_travail: int = Field(..., ge=0)
|
| 16 |
+
satisfaction_employee_equipe: int = Field(..., ge=0)
|
| 17 |
+
satisfaction_employee_equilibre_pro_perso: int = Field(..., ge=0)
|
| 18 |
+
|
| 19 |
+
heure_supplementaires: bool
|
| 20 |
+
augmentation_salaire_precedente: int = Field(..., ge=0)
|
| 21 |
+
nombre_participation_pee: int = Field(..., ge=0)
|
| 22 |
+
nb_formations_suivies: int = Field(..., ge=0)
|
| 23 |
+
distance_domicile_travail: int = Field(..., ge=0)
|
| 24 |
+
niveau_education: int = Field(..., ge=0)
|
| 25 |
+
domaine_etude: str
|
| 26 |
+
frequence_deplacement: str
|
| 27 |
+
|
| 28 |
+
# champs BRUTS (uniquement pour la route /predict manuel)
|
| 29 |
+
annees_sous_responsable_actuel: int = Field(..., ge=0)
|
| 30 |
+
annees_dans_le_poste_actuel: int = Field(..., ge=0)
|
| 31 |
+
note_evaluation_actuelle: int
|
| 32 |
+
note_evaluation_precedente: int
|
| 33 |
+
annees_depuis_la_derniere_promotion: int = Field(..., ge=0)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
class PredictionResponse(BaseModel):
|
| 37 |
+
proba: float
|
| 38 |
+
prediction: int
|
| 39 |
+
threshold: float
|
app/security/__init__.py
ADDED
|
File without changes
|
app/security/auth.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
from fastapi import Header, HTTPException
|
| 3 |
+
from app.core.config import get_settings
|
| 4 |
+
|
| 5 |
+
def require_api_key(x_api_key: str | None = Header(default=None, alias="X-API-Key")):
|
| 6 |
+
settings = get_settings()
|
| 7 |
+
|
| 8 |
+
if not settings.API_KEY:
|
| 9 |
+
raise HTTPException(status_code=500, detail="API_KEY not configured")
|
| 10 |
+
|
| 11 |
+
if x_api_key != settings.API_KEY:
|
| 12 |
+
raise HTTPException(status_code=401, detail="Unauthorized")
|
| 13 |
+
|
| 14 |
+
return True
|
app/services/__init__.py
ADDED
|
File without changes
|
app/services/audit.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
from sqlalchemy import Connection
|
| 3 |
+
|
| 4 |
+
from app.db.queries import SQL_INSERT_REQUEST, SQL_INSERT_RESPONSE
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
def log_audit(conn: Connection, payload: dict, proba: float, prediction: int, threshold: float) -> int:
|
| 8 |
+
|
| 9 |
+
'''
|
| 10 |
+
log_audit sert à enregistrer une prédiction dans la base de données
|
| 11 |
+
Elle trace :
|
| 12 |
+
ce que l’API a reçu (entrée)
|
| 13 |
+
ce que le modèle a produit (sortie)
|
| 14 |
+
'''
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
req_id = conn.execute(
|
| 18 |
+
SQL_INSERT_REQUEST,
|
| 19 |
+
{"payload": json.dumps(payload, ensure_ascii=False, default=str)},
|
| 20 |
+
).scalar_one()#récupère l’id généré par la base (clé primaire)
|
| 21 |
+
|
| 22 |
+
conn.execute(
|
| 23 |
+
SQL_INSERT_RESPONSE,
|
| 24 |
+
{
|
| 25 |
+
"request_id": req_id,
|
| 26 |
+
"proba": float(proba),
|
| 27 |
+
"prediction": int(prediction),
|
| 28 |
+
"threshold": float(threshold),
|
| 29 |
+
},
|
| 30 |
+
)
|
| 31 |
+
return int(req_id)
|
app/services/features.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from app.db.queries import SQL_GET_EMPLOYEE_FEATURES
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
def get_employee_features_by_id(engine, id_employee: int) -> dict | None:
|
| 5 |
+
"""
|
| 6 |
+
Récupère une ligne depuis mart.employee_features pour un id_employee.
|
| 7 |
+
Retourne un dict ou None si absent.
|
| 8 |
+
"""
|
| 9 |
+
if engine is None:
|
| 10 |
+
raise RuntimeError("DATABASE_URL non configurée (engine = None).")
|
| 11 |
+
|
| 12 |
+
with engine.connect() as conn:
|
| 13 |
+
row = (
|
| 14 |
+
conn.execute(SQL_GET_EMPLOYEE_FEATURES, {"id_employee": id_employee})
|
| 15 |
+
.mappings()
|
| 16 |
+
.first()
|
| 17 |
+
)
|
| 18 |
+
return dict(row) if row else None
|
app/services/predict.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from app.services.audit import log_audit
|
| 2 |
+
from app.services.features import get_employee_features_by_id
|
| 3 |
+
|
| 4 |
+
from app.ml.predict import predict_manual, predict_from_employee_features
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
def run_predict_manual(payload: dict, model, threshold: float, engine):
|
| 8 |
+
'''
|
| 9 |
+
Gère une prédiction à partir des données envoyées par l’utilisateur.
|
| 10 |
+
'''
|
| 11 |
+
proba, pred, payload_enrichi = predict_manual(payload, model, threshold)
|
| 12 |
+
|
| 13 |
+
if engine is not None:
|
| 14 |
+
with engine.begin() as conn:
|
| 15 |
+
log_audit(conn, payload_enrichi, proba, pred, threshold)
|
| 16 |
+
|
| 17 |
+
return proba, pred, payload_enrichi
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def run_predict_by_id(id_employee: int, model, threshold: float, engine):
|
| 21 |
+
'''
|
| 22 |
+
Gère une prédiction à partir d’un employé existant en base.
|
| 23 |
+
'''
|
| 24 |
+
employee = get_employee_features_by_id(engine, id_employee)
|
| 25 |
+
if employee is None:
|
| 26 |
+
raise KeyError(f"id_employee {id_employee} introuvable dans mart.employee_features")
|
| 27 |
+
|
| 28 |
+
proba, pred, payload_enrichi = predict_from_employee_features(employee, model, threshold)
|
| 29 |
+
|
| 30 |
+
if engine is not None:
|
| 31 |
+
with engine.begin() as conn:
|
| 32 |
+
payload_enrichi["id_employee"] = id_employee
|
| 33 |
+
log_audit(conn, payload_enrichi, proba, pred, threshold)
|
| 34 |
+
|
| 35 |
+
return proba, pred, payload_enrichi
|
config/threshold.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
{ "threshold": 0.292 }
|
db/01_schema.sql
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
-- =====================================================
|
| 2 |
+
-- Création des schémas du projet Technova ML API
|
| 3 |
+
-- =====================================================
|
| 4 |
+
|
| 5 |
+
-- Schéma pour les données brutes (RAW)
|
| 6 |
+
CREATE SCHEMA IF NOT EXISTS raw;
|
| 7 |
+
|
| 8 |
+
-- Schéma pour les données nettoyées / intermédiaires
|
| 9 |
+
CREATE SCHEMA IF NOT EXISTS staging;
|
| 10 |
+
|
| 11 |
+
-- Schéma pour les données finales utilisées par le modèle
|
| 12 |
+
CREATE SCHEMA IF NOT EXISTS mart;
|
| 13 |
+
|
| 14 |
+
-- Schéma pour l’audit et la traçabilité (API, prédictions)
|
| 15 |
+
CREATE SCHEMA IF NOT EXISTS audit;
|
db/02_raw_tables.sql
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
-- =====================================================
|
| 2 |
+
-- Tables RAW - données brutes sans transformation
|
| 3 |
+
-- =====================================================
|
| 4 |
+
|
| 5 |
+
-- -------------------------------
|
| 6 |
+
-- Table extrait_sirh
|
| 7 |
+
-- -------------------------------
|
| 8 |
+
DROP TABLE IF EXISTS raw.extrait_sirh;
|
| 9 |
+
CREATE TABLE raw.extrait_sirh (
|
| 10 |
+
id_employee INTEGER,
|
| 11 |
+
age INTEGER,
|
| 12 |
+
genre TEXT,
|
| 13 |
+
revenu_mensuel INTEGER,
|
| 14 |
+
statut_marital TEXT,
|
| 15 |
+
departement TEXT,
|
| 16 |
+
poste TEXT,
|
| 17 |
+
nombre_experiences_precedentes INTEGER,
|
| 18 |
+
nombre_heures_travailless INTEGER,
|
| 19 |
+
annee_experience_totale INTEGER,
|
| 20 |
+
annees_dans_l_entreprise INTEGER,
|
| 21 |
+
annees_dans_le_poste_actuel INTEGER
|
| 22 |
+
);
|
| 23 |
+
|
| 24 |
+
-- -------------------------------
|
| 25 |
+
-- Table extrait_eval
|
| 26 |
+
-- -------------------------------
|
| 27 |
+
DROP TABLE IF EXISTS raw.extrait_eval;
|
| 28 |
+
CREATE TABLE raw.extrait_eval (
|
| 29 |
+
satisfaction_employee_environnement INTEGER,
|
| 30 |
+
note_evaluation_precedente INTEGER,
|
| 31 |
+
niveau_hierarchique_poste INTEGER,
|
| 32 |
+
satisfaction_employee_nature_travail INTEGER,
|
| 33 |
+
satisfaction_employee_equipe INTEGER,
|
| 34 |
+
satisfaction_employee_equilibre_pro_perso INTEGER,
|
| 35 |
+
eval_number TEXT,
|
| 36 |
+
note_evaluation_actuelle INTEGER,
|
| 37 |
+
heure_supplementaires TEXT,
|
| 38 |
+
augementation_salaire_precedente TEXT
|
| 39 |
+
);
|
| 40 |
+
|
| 41 |
+
-- -------------------------------
|
| 42 |
+
-- Table extrait_sondage
|
| 43 |
+
-- -------------------------------
|
| 44 |
+
DROP TABLE IF EXISTS raw.extrait_sondage;
|
| 45 |
+
CREATE TABLE raw.extrait_sondage (
|
| 46 |
+
a_quitte_l_entreprise TEXT,
|
| 47 |
+
nombre_participation_pee INTEGER,
|
| 48 |
+
nb_formations_suivies INTEGER,
|
| 49 |
+
nombre_employee_sous_responsabilite INTEGER,
|
| 50 |
+
code_sondage INTEGER,
|
| 51 |
+
distance_domicile_travail INTEGER,
|
| 52 |
+
niveau_education INTEGER,
|
| 53 |
+
domaine_etude TEXT,
|
| 54 |
+
ayant_enfants TEXT,
|
| 55 |
+
frequence_deplacement TEXT,
|
| 56 |
+
annees_depuis_la_derniere_promotion INTEGER,
|
| 57 |
+
annes_sous_responsable_actuel INTEGER
|
| 58 |
+
);
|
db/03_load_raw.sql
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
-- =====================================================
|
| 2 |
+
-- 03_load_raw.sql — Chargement des données BRUTES
|
| 3 |
+
-- =====================================================
|
| 4 |
+
|
| 5 |
+
-- Nettoyage avant rechargement (idempotent)
|
| 6 |
+
TRUNCATE TABLE raw.extrait_sirh;
|
| 7 |
+
TRUNCATE TABLE raw.extrait_eval;
|
| 8 |
+
TRUNCATE TABLE raw.extrait_sondage;
|
| 9 |
+
|
| 10 |
+
-- -------------------------
|
| 11 |
+
-- Chargement SIRH
|
| 12 |
+
-- -------------------------
|
| 13 |
+
COPY raw.extrait_sirh
|
| 14 |
+
FROM 'C:/Users/yoann/OneDrive/Documents/OpenClassrooms/Déployez un modèle de Machine Learning/technova-ml-api/data/extrait_sirh.csv'
|
| 15 |
+
DELIMITER ';'
|
| 16 |
+
CSV HEADER
|
| 17 |
+
ENCODING 'UTF8';
|
| 18 |
+
|
| 19 |
+
-- -------------------------
|
| 20 |
+
-- Chargement EVAL
|
| 21 |
+
-- -------------------------
|
| 22 |
+
COPY raw.extrait_eval
|
| 23 |
+
FROM 'C:/Users/yoann/OneDrive/Documents/OpenClassrooms/Déployez un modèle de Machine Learning/technova-ml-api/data/extrait_eval.csv'
|
| 24 |
+
DELIMITER ';'
|
| 25 |
+
CSV HEADER
|
| 26 |
+
ENCODING 'UTF8';
|
| 27 |
+
|
| 28 |
+
-- -------------------------
|
| 29 |
+
-- Chargement SONDAGE
|
| 30 |
+
-- -------------------------
|
| 31 |
+
COPY raw.extrait_sondage
|
| 32 |
+
FROM 'C:/Users/yoann/OneDrive/Documents/OpenClassrooms/Déployez un modèle de Machine Learning/technova-ml-api/data/extrait_sondage.csv'
|
| 33 |
+
DELIMITER ';'
|
| 34 |
+
CSV HEADER
|
| 35 |
+
ENCODING 'UTF8';
|
db/04_staging.sql
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
-- =====================================================
|
| 2 |
+
-- 04_staging.sql — Nettoyage + normalisation (STAGING)
|
| 3 |
+
-- =====================================================
|
| 4 |
+
|
| 5 |
+
-- Sécurité: recréer proprement
|
| 6 |
+
DROP TABLE IF EXISTS staging.sirh_clean;
|
| 7 |
+
DROP TABLE IF EXISTS staging.eval_clean;
|
| 8 |
+
DROP TABLE IF EXISTS staging.sondage_clean;
|
| 9 |
+
DROP TABLE IF EXISTS staging.employee_base;
|
| 10 |
+
|
| 11 |
+
-- -------------------------
|
| 12 |
+
-- 1) SIRH
|
| 13 |
+
-- - supprimer nombre_heures_travailless (valeur constante 80)
|
| 14 |
+
-- - normaliser genre (Homme/Femme vs M/F)
|
| 15 |
+
-- -------------------------
|
| 16 |
+
CREATE TABLE staging.sirh_clean AS
|
| 17 |
+
SELECT
|
| 18 |
+
id_employee,
|
| 19 |
+
age,
|
| 20 |
+
CASE
|
| 21 |
+
WHEN lower(trim(genre)) IN ('h', 'homme', 'm') THEN 'Homme'
|
| 22 |
+
WHEN lower(trim(genre)) IN ('f', 'femme') THEN 'Femme'
|
| 23 |
+
ELSE NULL
|
| 24 |
+
END AS genre,
|
| 25 |
+
revenu_mensuel,
|
| 26 |
+
statut_marital,
|
| 27 |
+
departement,
|
| 28 |
+
poste,
|
| 29 |
+
nombre_experiences_precedentes,
|
| 30 |
+
annee_experience_totale,
|
| 31 |
+
annees_dans_l_entreprise,
|
| 32 |
+
annees_dans_le_poste_actuel
|
| 33 |
+
FROM raw.extrait_sirh;
|
| 34 |
+
|
| 35 |
+
-- -------------------------
|
| 36 |
+
-- 2) EVAL
|
| 37 |
+
-- - heure_supplementaires -> bool
|
| 38 |
+
-- - augmentation salaire -> numérique (retirer %)
|
| 39 |
+
-- - eval_number: retirer "e_" -> int
|
| 40 |
+
-- - renommer eval_number -> id_employee
|
| 41 |
+
-- -------------------------
|
| 42 |
+
CREATE TABLE staging.eval_clean AS
|
| 43 |
+
SELECT
|
| 44 |
+
CAST(replace(lower(trim(eval_number)), 'e_', '') AS INT) AS id_employee,
|
| 45 |
+
|
| 46 |
+
satisfaction_employee_environnement,
|
| 47 |
+
note_evaluation_precedente,
|
| 48 |
+
niveau_hierarchique_poste,
|
| 49 |
+
satisfaction_employee_nature_travail,
|
| 50 |
+
satisfaction_employee_equipe,
|
| 51 |
+
satisfaction_employee_equilibre_pro_perso,
|
| 52 |
+
note_evaluation_actuelle,
|
| 53 |
+
|
| 54 |
+
CASE
|
| 55 |
+
WHEN lower(trim(heure_supplementaires)) IN ('yes', 'y', 'oui', 'true', '1') THEN TRUE
|
| 56 |
+
WHEN lower(trim(heure_supplementaires)) IN ('no', 'n', 'non', 'false', '0') THEN FALSE
|
| 57 |
+
ELSE NULL
|
| 58 |
+
END AS heure_supplementaires,
|
| 59 |
+
|
| 60 |
+
NULLIF(REPLACE(TRIM(augementation_salaire_precedente), '%', ''), '')::INT AS augmentation_salaire_precedente
|
| 61 |
+
FROM raw.extrait_eval;
|
| 62 |
+
|
| 63 |
+
-- -------------------------
|
| 64 |
+
-- 3) SONDAGE
|
| 65 |
+
-- - supprimer ayant_enfants (constante Y)
|
| 66 |
+
-- - supprimer nombre_employee_sous_responsabilite (constante 1)
|
| 67 |
+
-- - a_quitte_l_entreprise -> bool
|
| 68 |
+
-- - code_sondage -> id_employee
|
| 69 |
+
-- - annes_sous_responsable_actuel -> annees_sous_responsable_actuel
|
| 70 |
+
-- -------------------------
|
| 71 |
+
CREATE TABLE staging.sondage_clean AS
|
| 72 |
+
SELECT
|
| 73 |
+
code_sondage AS id_employee,
|
| 74 |
+
|
| 75 |
+
CASE
|
| 76 |
+
WHEN lower(trim(a_quitte_l_entreprise)) IN ('yes', 'y', 'oui', 'true', '1') THEN TRUE
|
| 77 |
+
WHEN lower(trim(a_quitte_l_entreprise)) IN ('no', 'n', 'non', 'false', '0') THEN FALSE
|
| 78 |
+
ELSE NULL
|
| 79 |
+
END AS a_quitte_l_entreprise,
|
| 80 |
+
|
| 81 |
+
nombre_participation_pee,
|
| 82 |
+
nb_formations_suivies,
|
| 83 |
+
|
| 84 |
+
distance_domicile_travail,
|
| 85 |
+
niveau_education,
|
| 86 |
+
domaine_etude,
|
| 87 |
+
frequence_deplacement,
|
| 88 |
+
annees_depuis_la_derniere_promotion,
|
| 89 |
+
|
| 90 |
+
annes_sous_responsable_actuel AS annees_sous_responsable_actuel
|
| 91 |
+
FROM raw.extrait_sondage;
|
| 92 |
+
|
| 93 |
+
-- -------------------------
|
| 94 |
+
-- 4) Jointure STAGING (1 ligne = 1 employé)
|
| 95 |
+
-- -------------------------
|
| 96 |
+
CREATE TABLE staging.employee_base AS
|
| 97 |
+
SELECT
|
| 98 |
+
s.*,
|
| 99 |
+
e.satisfaction_employee_environnement,
|
| 100 |
+
e.note_evaluation_precedente,
|
| 101 |
+
e.niveau_hierarchique_poste,
|
| 102 |
+
e.satisfaction_employee_nature_travail,
|
| 103 |
+
e.satisfaction_employee_equipe,
|
| 104 |
+
e.satisfaction_employee_equilibre_pro_perso,
|
| 105 |
+
e.note_evaluation_actuelle,
|
| 106 |
+
e.heure_supplementaires,
|
| 107 |
+
e.augmentation_salaire_precedente,
|
| 108 |
+
so.a_quitte_l_entreprise,
|
| 109 |
+
so.nombre_participation_pee,
|
| 110 |
+
so.nb_formations_suivies,
|
| 111 |
+
so.distance_domicile_travail,
|
| 112 |
+
so.niveau_education,
|
| 113 |
+
so.domaine_etude,
|
| 114 |
+
so.frequence_deplacement,
|
| 115 |
+
so.annees_depuis_la_derniere_promotion,
|
| 116 |
+
so.annees_sous_responsable_actuel
|
| 117 |
+
FROM staging.sirh_clean s
|
| 118 |
+
LEFT JOIN staging.eval_clean e USING (id_employee)
|
| 119 |
+
LEFT JOIN staging.sondage_clean so USING (id_employee);
|
db/05_mart.sql
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
-- =====================================================
|
| 2 |
+
-- MART — Dataset final pour le modèle ML
|
| 3 |
+
-- =====================================================
|
| 4 |
+
|
| 5 |
+
CREATE SCHEMA IF NOT EXISTS mart;
|
| 6 |
+
|
| 7 |
+
DROP TABLE IF EXISTS mart.employee_features;
|
| 8 |
+
|
| 9 |
+
CREATE TABLE mart.employee_features AS
|
| 10 |
+
SELECT
|
| 11 |
+
-- Identifiant
|
| 12 |
+
id_employee,
|
| 13 |
+
|
| 14 |
+
-- =========================
|
| 15 |
+
-- Variables de base (X)
|
| 16 |
+
-- =========================
|
| 17 |
+
age,
|
| 18 |
+
|
| 19 |
+
lower(trim(genre)) AS genre,
|
| 20 |
+
revenu_mensuel,
|
| 21 |
+
lower(trim(statut_marital)) AS statut_marital,
|
| 22 |
+
lower(trim(departement)) AS departement,
|
| 23 |
+
lower(trim(poste)) AS poste,
|
| 24 |
+
|
| 25 |
+
nombre_experiences_precedentes,
|
| 26 |
+
annees_dans_l_entreprise,
|
| 27 |
+
|
| 28 |
+
satisfaction_employee_environnement,
|
| 29 |
+
satisfaction_employee_nature_travail,
|
| 30 |
+
satisfaction_employee_equipe,
|
| 31 |
+
satisfaction_employee_equilibre_pro_perso,
|
| 32 |
+
|
| 33 |
+
heure_supplementaires,
|
| 34 |
+
augmentation_salaire_precedente,
|
| 35 |
+
nombre_participation_pee,
|
| 36 |
+
nb_formations_suivies,
|
| 37 |
+
distance_domicile_travail,
|
| 38 |
+
niveau_education,
|
| 39 |
+
|
| 40 |
+
lower(trim(domaine_etude)) AS domaine_etude,
|
| 41 |
+
lower(trim(frequence_deplacement)) AS frequence_deplacement,
|
| 42 |
+
|
| 43 |
+
-- =========================
|
| 44 |
+
-- Features calculées
|
| 45 |
+
-- =========================
|
| 46 |
+
(
|
| 47 |
+
(COALESCE(annees_sous_responsable_actuel, 0) + 1)::double precision
|
| 48 |
+
/
|
| 49 |
+
(COALESCE(annees_dans_l_entreprise, 0) + 1)::double precision
|
| 50 |
+
) AS ratio_manager_anciennete,
|
| 51 |
+
|
| 52 |
+
(
|
| 53 |
+
(COALESCE(annees_dans_l_entreprise, 0) - COALESCE(annees_dans_le_poste_actuel, 0))::double precision
|
| 54 |
+
/
|
| 55 |
+
(COALESCE(annees_dans_l_entreprise, 0) + 1)::double precision
|
| 56 |
+
) AS mobilite_relative,
|
| 57 |
+
|
| 58 |
+
(COALESCE(note_evaluation_actuelle, 0) - COALESCE(note_evaluation_precedente, 0)) AS evolution_performance,
|
| 59 |
+
|
| 60 |
+
(
|
| 61 |
+
COALESCE(annees_depuis_la_derniere_promotion, 0)::double precision
|
| 62 |
+
/
|
| 63 |
+
(COALESCE(annees_dans_l_entreprise, 0) + 1)::double precision
|
| 64 |
+
) AS pression_stagnation,
|
| 65 |
+
|
| 66 |
+
-- =========================
|
| 67 |
+
-- Target (y)
|
| 68 |
+
-- =========================
|
| 69 |
+
a_quitte_l_entreprise
|
| 70 |
+
|
| 71 |
+
FROM staging.employee_base;
|
| 72 |
+
|
| 73 |
+
CREATE INDEX IF NOT EXISTS idx_mart_employee_features_id
|
| 74 |
+
ON mart.employee_features(id_employee);
|
db/06_audit.sql
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
-- =====================================================
|
| 2 |
+
-- AUDIT : Traçabilité des appels API / prédictions
|
| 3 |
+
-- =====================================================
|
| 4 |
+
|
| 5 |
+
CREATE SCHEMA IF NOT EXISTS audit;
|
| 6 |
+
|
| 7 |
+
-- 1) Requêtes (inputs envoyés au modèle)
|
| 8 |
+
DROP TABLE IF EXISTS audit.prediction_requests;
|
| 9 |
+
|
| 10 |
+
CREATE TABLE audit.prediction_requests (
|
| 11 |
+
request_id BIGSERIAL PRIMARY KEY,
|
| 12 |
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
| 13 |
+
id_employee INT NULL,
|
| 14 |
+
payload JSONB NOT NULL
|
| 15 |
+
);
|
| 16 |
+
|
| 17 |
+
-- 2) Réponses (outputs générés par le modèle)
|
| 18 |
+
DROP TABLE IF EXISTS audit.prediction_responses;
|
| 19 |
+
|
| 20 |
+
CREATE TABLE audit.prediction_responses (
|
| 21 |
+
response_id BIGSERIAL PRIMARY KEY,
|
| 22 |
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
| 23 |
+
|
| 24 |
+
request_id BIGINT NOT NULL
|
| 25 |
+
REFERENCES audit.prediction_requests(request_id)
|
| 26 |
+
ON DELETE CASCADE,
|
| 27 |
+
|
| 28 |
+
proba DOUBLE PRECISION NOT NULL,
|
| 29 |
+
prediction INT NOT NULL,
|
| 30 |
+
threshold DOUBLE PRECISION NOT NULL,
|
| 31 |
+
|
| 32 |
+
status TEXT NOT NULL DEFAULT 'OK',
|
| 33 |
+
error_message TEXT NULL
|
| 34 |
+
);
|
| 35 |
+
|
| 36 |
+
-- Index utiles
|
| 37 |
+
CREATE INDEX IF NOT EXISTS idx_pred_req_employee
|
| 38 |
+
ON audit.prediction_requests(id_employee);
|
| 39 |
+
|
| 40 |
+
CREATE INDEX IF NOT EXISTS idx_pred_req_created_at
|
| 41 |
+
ON audit.prediction_requests(created_at);
|
| 42 |
+
|
| 43 |
+
CREATE INDEX IF NOT EXISTS idx_pred_res_request_id
|
| 44 |
+
ON audit.prediction_responses(request_id);
|
db/README_SQL.md
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Technova ML API – Documentation SQL & Base de Données
|
| 2 |
+
|
| 3 |
+
## Objectif
|
| 4 |
+
Ce document décrit en détail l’architecture de la base de données PostgreSQL utilisée par **Technova ML API**.
|
| 5 |
+
La base est organisée selon une approche analytique en couches : **RAW → STAGING → MART → AUDIT**.
|
| 6 |
+
|
| 7 |
+
---
|
| 8 |
+
|
| 9 |
+
### Initialisation de la base de données
|
| 10 |
+
|
| 11 |
+
Les scripts SQL doivent être exécutés dans l’ordre suivant afin de garantir
|
| 12 |
+
la cohérence des données et des dépendances entre les schémas :
|
| 13 |
+
|
| 14 |
+
1. `schema.sql` : création des schémas PostgreSQL (raw, staging, mart, audit)
|
| 15 |
+
2. `raw.sql` : création des tables de données brutes
|
| 16 |
+
3. `load_raw.sql` : chargement des données sources
|
| 17 |
+
4. `staging.sql` : nettoyage, normalisation et jointure des données
|
| 18 |
+
5. `mart.sql` : création du dataset final pour le modèle ML
|
| 19 |
+
6. `audit.sql` : création des tables de traçabilité des prédictions
|
| 20 |
+
|
| 21 |
+
Cet ordre permet d’assurer l’intégrité des données et la reproductibilité
|
| 22 |
+
du pipeline de traitement.
|
| 23 |
+
|
| 24 |
+
Les scripts sont conçus pour être idempotents
|
| 25 |
+
(`DROP TABLE IF EXISTS`, `TRUNCATE`) afin de permettre
|
| 26 |
+
une réexécution sans effet de bord.
|
| 27 |
+
|
| 28 |
+
## Vue d’ensemble du pipeline de données
|
| 29 |
+
|
| 30 |
+
```text
|
| 31 |
+
RAW
|
| 32 |
+
├─ extrait_sirh
|
| 33 |
+
├─ extrait_eval
|
| 34 |
+
└─ extrait_sondage
|
| 35 |
+
│
|
| 36 |
+
▼
|
| 37 |
+
STAGING
|
| 38 |
+
└─ employee_base
|
| 39 |
+
│
|
| 40 |
+
▼
|
| 41 |
+
MART
|
| 42 |
+
└─ employee_features
|
| 43 |
+
│
|
| 44 |
+
├─ utilisé par le modèle de Machine Learning
|
| 45 |
+
▼
|
| 46 |
+
AUDIT
|
| 47 |
+
├─ prediction_requests
|
| 48 |
+
└─ prediction_responses
|
| 49 |
+
```
|
| 50 |
+
|
| 51 |
+
---
|
| 52 |
+
|
| 53 |
+
## Diagramme UML – Modèle de données (ERD)
|
| 54 |
+
|
| 55 |
+
```mermaid
|
| 56 |
+
erDiagram
|
| 57 |
+
|
| 58 |
+
RAW_EXTRAIT_SIRH {
|
| 59 |
+
INT id_employee
|
| 60 |
+
INT age
|
| 61 |
+
TEXT genre
|
| 62 |
+
INT revenu_mensuel
|
| 63 |
+
TEXT statut_marital
|
| 64 |
+
TEXT departement
|
| 65 |
+
TEXT poste
|
| 66 |
+
INT nombre_experiences_precedentes
|
| 67 |
+
INT annee_experience_totale
|
| 68 |
+
INT annees_dans_l_entreprise
|
| 69 |
+
INT annees_dans_le_poste_actuel
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
RAW_EXTRAIT_EVAL {
|
| 73 |
+
TEXT eval_number
|
| 74 |
+
INT satisfaction_employee_environnement
|
| 75 |
+
INT note_evaluation_precedente
|
| 76 |
+
INT niveau_hierarchique_poste
|
| 77 |
+
INT satisfaction_employee_nature_travail
|
| 78 |
+
INT satisfaction_employee_equipe
|
| 79 |
+
INT satisfaction_employee_equilibre_pro_perso
|
| 80 |
+
INT note_evaluation_actuelle
|
| 81 |
+
TEXT heure_supplementaires
|
| 82 |
+
TEXT augementation_salaire_precedente
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
RAW_EXTRAIT_SONDAGE {
|
| 86 |
+
INT code_sondage
|
| 87 |
+
TEXT a_quitte_l_entreprise
|
| 88 |
+
INT nombre_participation_pee
|
| 89 |
+
INT nb_formations_suivies
|
| 90 |
+
INT distance_domicile_travail
|
| 91 |
+
INT niveau_education
|
| 92 |
+
TEXT domaine_etude
|
| 93 |
+
TEXT frequence_deplacement
|
| 94 |
+
INT annees_depuis_la_derniere_promotion
|
| 95 |
+
INT annes_sous_responsable_actuel
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
STAGING_EMPLOYEE_BASE {
|
| 99 |
+
INT id_employee
|
| 100 |
+
INT age
|
| 101 |
+
TEXT genre
|
| 102 |
+
INT revenu_mensuel
|
| 103 |
+
TEXT statut_marital
|
| 104 |
+
TEXT departement
|
| 105 |
+
TEXT poste
|
| 106 |
+
BOOLEAN heure_supplementaires
|
| 107 |
+
BOOLEAN a_quitte_l_entreprise
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
MART_EMPLOYEE_FEATURES {
|
| 111 |
+
INT id_employee
|
| 112 |
+
FLOAT ratio_manager_anciennete
|
| 113 |
+
FLOAT mobilite_relative
|
| 114 |
+
INT evolution_performance
|
| 115 |
+
FLOAT pression_stagnation
|
| 116 |
+
BOOLEAN a_quitte_l_entreprise
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
AUDIT_PREDICTION_REQUESTS {
|
| 120 |
+
BIGINT request_id PK
|
| 121 |
+
TIMESTAMPTZ created_at
|
| 122 |
+
INT id_employee
|
| 123 |
+
JSONB payload
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
AUDIT_PREDICTION_RESPONSES {
|
| 127 |
+
BIGINT response_id PK
|
| 128 |
+
BIGINT request_id FK
|
| 129 |
+
FLOAT proba
|
| 130 |
+
INT prediction
|
| 131 |
+
FLOAT threshold
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
RAW_EXTRAIT_SIRH }o--|| STAGING_EMPLOYEE_BASE : clean
|
| 135 |
+
RAW_EXTRAIT_EVAL }o--|| STAGING_EMPLOYEE_BASE : clean
|
| 136 |
+
RAW_EXTRAIT_SONDAGE }o--|| STAGING_EMPLOYEE_BASE : clean
|
| 137 |
+
|
| 138 |
+
STAGING_EMPLOYEE_BASE ||--|| MART_EMPLOYEE_FEATURES : feature_engineering
|
| 139 |
+
AUDIT_PREDICTION_REQUESTS ||--o{ AUDIT_PREDICTION_RESPONSES : logs
|
| 140 |
+
```
|
| 141 |
+
|
| 142 |
+
---
|
| 143 |
+
|
| 144 |
+
## Description des couches
|
| 145 |
+
|
| 146 |
+
### RAW
|
| 147 |
+
- Données brutes issues de différentes sources RH.
|
| 148 |
+
- Aucune transformation.
|
| 149 |
+
- Chargement via scripts SQL (`COPY`).
|
| 150 |
+
|
| 151 |
+
### STAGING
|
| 152 |
+
- Nettoyage des valeurs.
|
| 153 |
+
- Normalisation des types.
|
| 154 |
+
- Jointure des sources autour de `id_employee`.
|
| 155 |
+
|
| 156 |
+
### MART
|
| 157 |
+
- Dataset final utilisé par le modèle de Machine Learning.
|
| 158 |
+
- Features calculées (ratios, évolutions, indicateurs).
|
| 159 |
+
- Contient la cible `a_quitte_l_entreprise`.
|
| 160 |
+
|
| 161 |
+
### AUDIT
|
| 162 |
+
- Journalisation des appels API.
|
| 163 |
+
- Séparation claire entre requêtes et réponses.
|
| 164 |
+
- Garantit la traçabilité et l’auditabilité des prédictions.
|
| 165 |
+
|
| 166 |
+
---
|
| 167 |
+
|
encoder/__init__.py
ADDED
|
File without changes
|
encoder/custom_encoder.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pandas as pd
|
| 2 |
+
import numpy as np
|
| 3 |
+
from sklearn.base import BaseEstimator, TransformerMixin
|
| 4 |
+
from sklearn.preprocessing import OneHotEncoder
|
| 5 |
+
|
| 6 |
+
class CustomEncoder(BaseEstimator, TransformerMixin):
|
| 7 |
+
def __init__(self, bool_cols=None, cat_onehot_cols=None, num_cols=None):
|
| 8 |
+
self.bool_cols = bool_cols or []
|
| 9 |
+
self.cat_onehot_cols = cat_onehot_cols or []
|
| 10 |
+
self.num_cols = num_cols or []
|
| 11 |
+
|
| 12 |
+
def fit(self, X, y=None):
|
| 13 |
+
# Stockage des colonnes
|
| 14 |
+
self.bool_cols_ = list(self.bool_cols)
|
| 15 |
+
self.cat_onehot_cols_ = list(self.cat_onehot_cols)
|
| 16 |
+
self.num_cols_ = list(self.num_cols)
|
| 17 |
+
|
| 18 |
+
# OneHot
|
| 19 |
+
self.ohe_ = OneHotEncoder(handle_unknown="ignore", sparse_output=False)
|
| 20 |
+
if self.cat_onehot_cols_:
|
| 21 |
+
self.ohe_.fit(X[self.cat_onehot_cols_])
|
| 22 |
+
|
| 23 |
+
return self
|
| 24 |
+
|
| 25 |
+
def transform(self, X):
|
| 26 |
+
parts = []
|
| 27 |
+
|
| 28 |
+
# Booléens
|
| 29 |
+
if self.bool_cols_:
|
| 30 |
+
df_bool = X[self.bool_cols_].astype(int)
|
| 31 |
+
parts.append(df_bool)
|
| 32 |
+
|
| 33 |
+
# Numériques
|
| 34 |
+
if self.num_cols_:
|
| 35 |
+
df_num = X[self.num_cols_]
|
| 36 |
+
parts.append(df_num)
|
| 37 |
+
|
| 38 |
+
# OneHot
|
| 39 |
+
if self.cat_onehot_cols_:
|
| 40 |
+
ohe_data = self.ohe_.transform(X[self.cat_onehot_cols_])
|
| 41 |
+
ohe_df = pd.DataFrame(
|
| 42 |
+
ohe_data,
|
| 43 |
+
columns=self.ohe_.get_feature_names_out(self.cat_onehot_cols_),
|
| 44 |
+
index=X.index
|
| 45 |
+
)
|
| 46 |
+
parts.append(ohe_df)
|
| 47 |
+
|
| 48 |
+
# Fusion
|
| 49 |
+
df_final = pd.concat(parts, axis=1)
|
| 50 |
+
|
| 51 |
+
# Stockage des colonnes finales (utile pour FI)
|
| 52 |
+
self.feature_names_ = df_final.columns.tolist()
|
| 53 |
+
|
| 54 |
+
return df_final
|
requirements.txt
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi
|
| 2 |
+
uvicorn
|
| 3 |
+
pydantic
|
| 4 |
+
pytest
|
| 5 |
+
httpx
|
| 6 |
+
pandas
|
| 7 |
+
joblib
|
| 8 |
+
numpy
|
| 9 |
+
huggingface_hub
|
| 10 |
+
scikit-learn==1.6.1
|
| 11 |
+
xgboost==3.1.2
|
| 12 |
+
SQLAlchemy>=2.0
|
| 13 |
+
psycopg[binary]
|
| 14 |
+
python-dotenv
|
| 15 |
+
pytest-cov
|
tests/conftest.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
import pytest
|
| 3 |
+
from fastapi.testclient import TestClient
|
| 4 |
+
|
| 5 |
+
from app.core.config import get_settings
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
@pytest.fixture
|
| 9 |
+
def client(monkeypatch):
|
| 10 |
+
monkeypatch.setenv("APP_ENV", "test") # <-- clé du fix
|
| 11 |
+
monkeypatch.setenv("API_KEY", "test-key")
|
| 12 |
+
|
| 13 |
+
get_settings.cache_clear()
|
| 14 |
+
|
| 15 |
+
from app.main import app
|
| 16 |
+
with TestClient(app) as c:
|
| 17 |
+
yield c
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
@pytest.fixture
|
| 21 |
+
def auth_headers():
|
| 22 |
+
return {"X-API-Key": "test-key"}
|
tests/test_api.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
import pytest
|
| 3 |
+
|
| 4 |
+
# Payload valide pour /predict
|
| 5 |
+
PAYLOAD_OK = {
|
| 6 |
+
"age": 41,
|
| 7 |
+
"genre": "homme",
|
| 8 |
+
"revenu_mensuel": 3993,
|
| 9 |
+
"statut_marital": "célibataire",
|
| 10 |
+
"departement": "commercial",
|
| 11 |
+
"poste": "cadre commercial",
|
| 12 |
+
"nombre_experiences_precedentes": 2,
|
| 13 |
+
"annees_dans_l_entreprise": 5,
|
| 14 |
+
"satisfaction_employee_environnement": 4,
|
| 15 |
+
"satisfaction_employee_nature_travail": 1,
|
| 16 |
+
"satisfaction_employee_equipe": 1,
|
| 17 |
+
"satisfaction_employee_equilibre_pro_perso": 1,
|
| 18 |
+
"heure_supplementaires": True,
|
| 19 |
+
"augmentation_salaire_precedente": 11,
|
| 20 |
+
"nombre_participation_pee": 0,
|
| 21 |
+
"nb_formations_suivies": 0,
|
| 22 |
+
"distance_domicile_travail": 1,
|
| 23 |
+
"niveau_education": 2,
|
| 24 |
+
"domaine_etude": "infra & cloud",
|
| 25 |
+
"frequence_deplacement": "occasionnel",
|
| 26 |
+
"annees_sous_responsable_actuel": 0,
|
| 27 |
+
"annees_dans_le_poste_actuel": 0,
|
| 28 |
+
"note_evaluation_actuelle": 0,
|
| 29 |
+
"note_evaluation_precedente": 0,
|
| 30 |
+
"annees_depuis_la_derniere_promotion": 0
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
# -------------------------------------------------------------------
|
| 35 |
+
# Utilitaire : injecter un état minimal dans l'app (pas de HF / pas de DB)
|
| 36 |
+
# -------------------------------------------------------------------
|
| 37 |
+
def _inject_dummy_state():
|
| 38 |
+
from app.main import app
|
| 39 |
+
|
| 40 |
+
class DummyModel:
|
| 41 |
+
def predict_proba(self, X):
|
| 42 |
+
return [[0.2, 0.8]] # proba classe 1
|
| 43 |
+
|
| 44 |
+
app.state.model = DummyModel()
|
| 45 |
+
app.state.threshold = 0.292
|
| 46 |
+
app.state.engine = None
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
# =========================
|
| 50 |
+
# /predict (POST)
|
| 51 |
+
# =========================
|
| 52 |
+
def test_post_predict_unauthorized_without_api_key(client):
|
| 53 |
+
r = client.post("/predict", json=PAYLOAD_OK)
|
| 54 |
+
assert r.status_code == 401
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def test_post_predict_unauthorized_with_wrong_api_key(client):
|
| 58 |
+
r = client.post(
|
| 59 |
+
"/predict",
|
| 60 |
+
json=PAYLOAD_OK,
|
| 61 |
+
headers={"X-API-Key": "WRONG"},
|
| 62 |
+
)
|
| 63 |
+
assert r.status_code == 401
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def test_post_predict_ok_with_api_key(client, auth_headers):
|
| 67 |
+
_inject_dummy_state()
|
| 68 |
+
|
| 69 |
+
r = client.post("/predict", json=PAYLOAD_OK, headers=auth_headers)
|
| 70 |
+
assert r.status_code == 200, r.text
|
| 71 |
+
|
| 72 |
+
body = r.json()
|
| 73 |
+
assert body["threshold"] == 0.292
|
| 74 |
+
assert body["prediction"] in (0, 1)
|
| 75 |
+
assert "proba" in body
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
# =========================
|
| 79 |
+
# /predict/{id} (GET)
|
| 80 |
+
# =========================
|
| 81 |
+
def test_get_predict_by_id_unauthorized_without_api_key(client):
|
| 82 |
+
r = client.get("/predict/7")
|
| 83 |
+
assert r.status_code == 401
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def test_get_predict_by_id_unauthorized_with_wrong_api_key(client):
|
| 87 |
+
r = client.get("/predict/7", headers={"X-API-Key": "WRONG"})
|
| 88 |
+
assert r.status_code == 401
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def test_get_predict_by_id_ok_with_api_key(client, auth_headers, monkeypatch):
|
| 92 |
+
_inject_dummy_state()
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
import app.main as main_module
|
| 96 |
+
|
| 97 |
+
def fake_run_predict_by_id(*, id_employee, model, threshold, engine):
|
| 98 |
+
return 0.55, 1, {"id_employee": id_employee}
|
| 99 |
+
|
| 100 |
+
monkeypatch.setattr(main_module, "run_predict_by_id", fake_run_predict_by_id)
|
| 101 |
+
|
| 102 |
+
r = client.get("/predict/7", headers=auth_headers)
|
| 103 |
+
assert r.status_code == 200, r.text
|
| 104 |
+
|
| 105 |
+
body = r.json()
|
| 106 |
+
assert body["threshold"] == 0.292
|
| 107 |
+
assert body["prediction"] in (0, 1)
|
| 108 |
+
assert "proba" in body
|
tests/test_audit.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
def test_log_audit_returns_request_id(monkeypatch):
|
| 2 |
+
from app.services.audit import log_audit
|
| 3 |
+
|
| 4 |
+
class DummyResult:
|
| 5 |
+
def scalar_one(self):
|
| 6 |
+
return 42
|
| 7 |
+
|
| 8 |
+
class DummyConn:
|
| 9 |
+
def execute(self, *args, **kwargs):
|
| 10 |
+
return DummyResult()
|
| 11 |
+
|
| 12 |
+
req_id = log_audit(
|
| 13 |
+
conn=DummyConn(),
|
| 14 |
+
payload={"a": 1},
|
| 15 |
+
proba=0.7,
|
| 16 |
+
prediction=1,
|
| 17 |
+
threshold=0.3,
|
| 18 |
+
)
|
| 19 |
+
|
| 20 |
+
assert req_id == 42
|
tests/test_engine.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from app.db.engine import get_engine
|
| 2 |
+
from app.core.config import get_settings
|
| 3 |
+
|
| 4 |
+
def test_get_engine_without_database_url(monkeypatch):
|
| 5 |
+
monkeypatch.delenv("DATABASE_URL", raising=False)
|
| 6 |
+
get_settings.cache_clear()
|
| 7 |
+
|
| 8 |
+
engine = get_engine()
|
| 9 |
+
assert engine is None
|
tests/test_feature.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
def test_get_employee_features_not_found(monkeypatch):
|
| 2 |
+
from app.services.features import get_employee_features_by_id
|
| 3 |
+
|
| 4 |
+
class DummyResult:
|
| 5 |
+
def mappings(self):
|
| 6 |
+
return self
|
| 7 |
+
|
| 8 |
+
def first(self):
|
| 9 |
+
return None
|
| 10 |
+
|
| 11 |
+
class DummyConn:
|
| 12 |
+
def execute(self, *args, **kwargs):
|
| 13 |
+
return DummyResult()
|
| 14 |
+
|
| 15 |
+
class DummyEngine:
|
| 16 |
+
def connect(self):
|
| 17 |
+
return self
|
| 18 |
+
def __enter__(self):
|
| 19 |
+
return DummyConn()
|
| 20 |
+
def __exit__(self, *args):
|
| 21 |
+
pass
|
| 22 |
+
|
| 23 |
+
result = get_employee_features_by_id(DummyEngine(), 999)
|
| 24 |
+
assert result is None
|
tests/test_health.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
def test_health_ok(client):
|
| 2 |
+
r = client.get("/health")
|
| 3 |
+
assert r.status_code == 200
|
| 4 |
+
|
| 5 |
+
data = r.json()
|
| 6 |
+
assert data["status"] == "ok"
|
| 7 |
+
assert "model_loaded" in data
|
| 8 |
+
assert "threshold" in data
|
| 9 |
+
assert "db_configured" in data
|
tests/test_predict.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# tests/test_services_predict.py
|
| 2 |
+
import pytest
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
def test_run_predict_manual_without_engine(monkeypatch):
|
| 6 |
+
"""
|
| 7 |
+
Cas simple : engine=None => pas d'audit, on renvoie proba/pred/payload enrichi.
|
| 8 |
+
"""
|
| 9 |
+
from app.services import predict as predict_service
|
| 10 |
+
|
| 11 |
+
# Fake predict_manual (ML)
|
| 12 |
+
def fake_predict_manual(payload, model, threshold):
|
| 13 |
+
return 0.8, 1, {"x": 1, "enrich": True}
|
| 14 |
+
|
| 15 |
+
monkeypatch.setattr(predict_service, "predict_manual", fake_predict_manual)
|
| 16 |
+
|
| 17 |
+
proba, pred, payload_enrichi = predict_service.run_predict_manual(
|
| 18 |
+
payload={"x": 1},
|
| 19 |
+
model=object(),
|
| 20 |
+
threshold=0.3,
|
| 21 |
+
engine=None,
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
assert proba == 0.8
|
| 25 |
+
assert pred == 1
|
| 26 |
+
assert payload_enrichi["enrich"] is True
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def test_run_predict_manual_with_engine_calls_audit(monkeypatch):
|
| 30 |
+
"""
|
| 31 |
+
Cas engine présent : log_audit doit être appelé.
|
| 32 |
+
"""
|
| 33 |
+
from app.services import predict as predict_service
|
| 34 |
+
|
| 35 |
+
# Fake predict_manual
|
| 36 |
+
def fake_predict_manual(payload, model, threshold):
|
| 37 |
+
return 0.2, 0, {"foo": "bar"}
|
| 38 |
+
|
| 39 |
+
monkeypatch.setattr(predict_service, "predict_manual", fake_predict_manual)
|
| 40 |
+
|
| 41 |
+
# Spy log_audit
|
| 42 |
+
calls = {"count": 0, "args": None}
|
| 43 |
+
|
| 44 |
+
def fake_log_audit(conn, payload, proba, prediction, threshold):
|
| 45 |
+
calls["count"] += 1
|
| 46 |
+
calls["args"] = (conn, payload, proba, prediction, threshold)
|
| 47 |
+
return 123
|
| 48 |
+
|
| 49 |
+
monkeypatch.setattr(predict_service, "log_audit", fake_log_audit)
|
| 50 |
+
|
| 51 |
+
# Dummy engine.begin() context manager
|
| 52 |
+
class DummyEngine:
|
| 53 |
+
def begin(self):
|
| 54 |
+
return self
|
| 55 |
+
|
| 56 |
+
def __enter__(self):
|
| 57 |
+
return "dummy-conn"
|
| 58 |
+
|
| 59 |
+
def __exit__(self, exc_type, exc, tb):
|
| 60 |
+
return False
|
| 61 |
+
|
| 62 |
+
proba, pred, payload_enrichi = predict_service.run_predict_manual(
|
| 63 |
+
payload={"hello": "world"},
|
| 64 |
+
model=object(),
|
| 65 |
+
threshold=0.292,
|
| 66 |
+
engine=DummyEngine(),
|
| 67 |
+
)
|
| 68 |
+
|
| 69 |
+
assert proba == 0.2
|
| 70 |
+
assert pred == 0
|
| 71 |
+
assert payload_enrichi == {"foo": "bar"}
|
| 72 |
+
assert calls["count"] == 1
|
| 73 |
+
assert calls["args"][0] == "dummy-conn"
|
| 74 |
+
assert calls["args"][1] == {"foo": "bar"}
|
| 75 |
+
assert calls["args"][2] == 0.2
|
| 76 |
+
assert calls["args"][3] == 0
|
| 77 |
+
assert calls["args"][4] == 0.292
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def test_run_predict_by_id_not_found_raises_keyerror(monkeypatch):
|
| 81 |
+
"""
|
| 82 |
+
Cas id absent : get_employee_features_by_id renvoie None => KeyError attendu.
|
| 83 |
+
"""
|
| 84 |
+
from app.services import predict as predict_service
|
| 85 |
+
|
| 86 |
+
def fake_get_employee_features_by_id(engine, id_employee):
|
| 87 |
+
return None
|
| 88 |
+
|
| 89 |
+
monkeypatch.setattr(predict_service, "get_employee_features_by_id", fake_get_employee_features_by_id)
|
| 90 |
+
|
| 91 |
+
with pytest.raises(KeyError):
|
| 92 |
+
predict_service.run_predict_by_id(
|
| 93 |
+
id_employee=999,
|
| 94 |
+
model=object(),
|
| 95 |
+
threshold=0.5,
|
| 96 |
+
engine=object(),
|
| 97 |
+
)
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def test_run_predict_by_id_with_engine_calls_audit_and_adds_id(monkeypatch):
|
| 101 |
+
"""
|
| 102 |
+
Cas nominal : on récupère un employé, on prédit, on log en audit,
|
| 103 |
+
et on ajoute id_employee dans payload_enrichi avant log.
|
| 104 |
+
"""
|
| 105 |
+
from app.services import predict as predict_service
|
| 106 |
+
|
| 107 |
+
# Fake features fetch
|
| 108 |
+
def fake_get_employee_features_by_id(engine, id_employee):
|
| 109 |
+
return {"id_employee": id_employee, "age": 40}
|
| 110 |
+
|
| 111 |
+
monkeypatch.setattr(predict_service, "get_employee_features_by_id", fake_get_employee_features_by_id)
|
| 112 |
+
|
| 113 |
+
# Fake predict_from_employee_features
|
| 114 |
+
def fake_predict_from_employee_features(employee, model, threshold):
|
| 115 |
+
# payload enrichi sans id -> le service doit l'ajouter
|
| 116 |
+
return 0.55, 1, {"age": employee["age"]}
|
| 117 |
+
|
| 118 |
+
monkeypatch.setattr(predict_service, "predict_from_employee_features", fake_predict_from_employee_features)
|
| 119 |
+
|
| 120 |
+
# Spy log_audit
|
| 121 |
+
calls = {"count": 0, "payload": None}
|
| 122 |
+
|
| 123 |
+
def fake_log_audit(conn, payload, proba, prediction, threshold):
|
| 124 |
+
calls["count"] += 1
|
| 125 |
+
calls["payload"] = payload
|
| 126 |
+
return 456
|
| 127 |
+
|
| 128 |
+
monkeypatch.setattr(predict_service, "log_audit", fake_log_audit)
|
| 129 |
+
|
| 130 |
+
class DummyEngine:
|
| 131 |
+
def begin(self):
|
| 132 |
+
return self
|
| 133 |
+
|
| 134 |
+
def __enter__(self):
|
| 135 |
+
return "dummy-conn"
|
| 136 |
+
|
| 137 |
+
def __exit__(self, exc_type, exc, tb):
|
| 138 |
+
return False
|
| 139 |
+
|
| 140 |
+
proba, pred, payload_enrichi = predict_service.run_predict_by_id(
|
| 141 |
+
id_employee=7,
|
| 142 |
+
model=object(),
|
| 143 |
+
threshold=0.292,
|
| 144 |
+
engine=DummyEngine(),
|
| 145 |
+
)
|
| 146 |
+
|
| 147 |
+
assert proba == 0.55
|
| 148 |
+
assert pred == 1
|
| 149 |
+
assert payload_enrichi["age"] == 40
|
| 150 |
+
assert payload_enrichi["id_employee"] == 7
|
| 151 |
+
|
| 152 |
+
assert calls["count"] == 1
|
| 153 |
+
assert calls["payload"]["id_employee"] == 7
|