github-actions commited on
Commit
5fa8558
·
0 Parent(s):

deploy: snapshot

Browse files
.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