marintosti12 commited on
Commit
709dae0
·
unverified ·
2 Parent(s): 3519719b726611

Merge pull request #37 from marintosti12/dev

Browse files
.github/workflows/ci.yaml CHANGED
@@ -28,6 +28,7 @@ jobs:
28
 
29
  env:
30
  DATABASE_URL: postgresql+asyncpg://ci:ci@localhost:5432/futurisys_ci
 
31
 
32
  steps:
33
  - name: Checkout
 
28
 
29
  env:
30
  DATABASE_URL: postgresql+asyncpg://ci:ci@localhost:5432/futurisys_ci
31
+ HF_TOKEN: ${{ secrets.HF_TOKEN }}
32
 
33
  steps:
34
  - name: Checkout
alembic/env.py CHANGED
@@ -7,7 +7,8 @@ from sqlalchemy.ext.asyncio import create_async_engine, AsyncEngine
7
  from sqlalchemy import pool
8
 
9
  from src.config.db import Base
10
-
 
11
  # Alembic Config
12
  config = context.config
13
  if config.config_file_name is not None:
 
7
  from sqlalchemy import pool
8
 
9
  from src.config.db import Base
10
+ from dotenv import load_dotenv, find_dotenv
11
+ load_dotenv(find_dotenv())
12
  # Alembic Config
13
  config = context.config
14
  if config.config_file_name is not None:
alembic/versions/24251a13df00_ml_outputs.py ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """ml outputs
2
+
3
+ Revision ID: 24251a13df00
4
+ Revises: ecd589af543e
5
+ Create Date: 2025-09-15 16:34:46.842373
6
+
7
+ """
8
+ from typing import Sequence, Union
9
+
10
+ from alembic import op
11
+ import sqlalchemy as sa
12
+ from sqlalchemy.dialects import postgresql
13
+
14
+
15
+ # revision identifiers, used by Alembic.
16
+ revision: str = '24251a13df00'
17
+ down_revision: Union[str, Sequence[str], None] = 'ecd589af543e'
18
+ branch_labels: Union[str, Sequence[str], None] = None
19
+ depends_on: Union[str, Sequence[str], None] = None
20
+
21
+
22
+ def upgrade() -> None:
23
+ op.create_table(
24
+ "ml_outputs",
25
+ sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True,
26
+ server_default=sa.text("gen_random_uuid()")),
27
+ sa.Column("created_at", sa.DateTime(timezone=True), nullable=False,
28
+ server_default=sa.text("TIMEZONE('utc', now())")),
29
+ sa.Column("input_id", postgresql.UUID(as_uuid=True), nullable=False),
30
+ sa.Column("prediction", sa.String(length=100), nullable=False),
31
+ sa.Column("prob", sa.Float(), nullable=True),
32
+ sa.Column("error", sa.String(length=500), nullable=True),
33
+ sa.ForeignKeyConstraint(["input_id"], ["ml_inputs.id"], ondelete="CASCADE"),
34
+ )
35
+
36
+
37
+ def downgrade() -> None:
38
+ op.drop_table("ml_outputs")
39
+
src/config/db.py CHANGED
@@ -13,3 +13,10 @@ class Base(DeclarativeBase):
13
 
14
  engine = create_engine(settings.DATABASE_URL, echo=True, future=True)
15
  SessionLocal = sessionmaker(bind=engine, autocommit=False, autoflush=False)
 
 
 
 
 
 
 
 
13
 
14
  engine = create_engine(settings.DATABASE_URL, echo=True, future=True)
15
  SessionLocal = sessionmaker(bind=engine, autocommit=False, autoflush=False)
16
+
17
+ def get_db():
18
+ db = SessionLocal()
19
+ try:
20
+ yield db
21
+ finally:
22
+ db.close()
src/controllers/home_controller.py CHANGED
@@ -1,27 +1,88 @@
1
- from fastapi import APIRouter, HTTPException
2
- from config.db import SessionLocal
3
- from models.ml import MLModel
4
 
5
- router = APIRouter()
 
 
6
 
7
- @router.get("/", tags=["models"])
8
- def list_ml_models():
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
  try:
10
- with SessionLocal() as s:
11
- rows = (
12
- s.query(MLModel)
13
- .order_by(MLModel.created_at.desc())
14
- .all()
 
 
 
 
 
 
 
15
  )
16
- return [
17
- {
18
- "id": str(r.id),
19
- "name": r.name,
20
- "description": r.description,
21
- "created_at": r.created_at.isoformat() if r.created_at else None,
22
- "is_active": r.is_active,
23
- }
24
- for r in rows
25
- ]
26
  except Exception as e:
27
  raise HTTPException(status_code=500, detail=str(e))
 
1
+ from datetime import datetime
2
+ from typing import List, Optional
 
3
 
4
+ from fastapi import APIRouter, Depends, HTTPException, status
5
+ from pydantic import BaseModel, Field
6
+ from sqlalchemy.orm import Session
7
 
8
+ from config.db import get_db
9
+ from models.ml import MLModel
10
+
11
+ router = APIRouter(tags=["Models"])
12
+
13
+
14
+ class MLModelOut(BaseModel):
15
+ id: str = Field(..., description="Identifiant unique du modèle (UUID en chaîne).")
16
+ name: str = Field(..., description="Nom court du modèle.")
17
+ description: Optional[str] = Field(None, description="Description du modèle.")
18
+ created_at: Optional[datetime] = Field(
19
+ None, description="Date de création du modèle (UTC, ISO 8601)."
20
+ )
21
+ is_active: bool = Field(..., description="Modèle actif/inactif.")
22
+ model_config = {"json_schema_extra": {
23
+ "examples": [{
24
+ "id": "5b1c7b3a-0000-4000-8000-000000000002",
25
+ "name": "best_model",
26
+ "description": "XGB v1",
27
+ "created_at": "2025-09-15T10:11:03.950802+00:00",
28
+ "is_active": True
29
+ }]
30
+ }}
31
+
32
+
33
+ @router.get(
34
+ "/",
35
+ response_model=List[MLModelOut],
36
+ status_code=status.HTTP_200_OK,
37
+ summary="Lister les modèles ML",
38
+ description=(
39
+ "Retourne la liste des modèles disponibles, triés du plus récent au plus ancien.\n\n"
40
+ "**Remarques**\n"
41
+ "- Les champs sont mappés depuis la table `ml_models`.\n"
42
+ ),
43
+ responses={
44
+ 200: {
45
+ "description": "Liste des modèles.",
46
+ "content": {
47
+ "application/json": {
48
+ "example": [
49
+ {
50
+ "id": "5b1c7b3a-0000-4000-8000-000000000002",
51
+ "name": "best_model",
52
+ "description": "XGB v1",
53
+ "created_at": "2025-09-15T10:11:03.950802+00:00",
54
+ "is_active": True
55
+ },
56
+ {
57
+ "id": "5b1c7b3a-0000-4000-8000-000000000001",
58
+ "name": "baseline",
59
+ "description": "Baseline model",
60
+ "created_at": "2025-09-15T10:11:03.950802+00:00",
61
+ "is_active": True
62
+ }
63
+ ]
64
+ }
65
+ },
66
+ },
67
+ 500: {"description": "Erreur serveur lors de la lecture des modèles."},
68
+ },
69
+ )
70
+ def list_ml_models(db: Session = Depends(get_db)) -> List[MLModelOut]:
71
  try:
72
+ rows = (
73
+ db.query(MLModel)
74
+ .order_by(MLModel.created_at.desc())
75
+ .all()
76
+ )
77
+ return [
78
+ MLModelOut(
79
+ id=str(r.id),
80
+ name=r.name,
81
+ description=r.description,
82
+ created_at=r.created_at,
83
+ is_active=r.is_active,
84
  )
85
+ for r in rows
86
+ ]
 
 
 
 
 
 
 
 
87
  except Exception as e:
88
  raise HTTPException(status_code=500, detail=str(e))
src/controllers/predict_controller.py CHANGED
@@ -1,11 +1,11 @@
1
- # src/controllers/predict_controller.py
2
- from fastapi import APIRouter, HTTPException
3
 
4
- from config.db import SessionLocal
5
  from models.ml import MLModel
6
 
7
  # Schemas
8
  from models.ml_inputs import MLInput
 
9
 
10
  import pandas as pd
11
  from model_loader import load_model
@@ -13,37 +13,119 @@ from features import compute_features
13
  from schemas.PredictItemResult import PredictItemResult
14
  from schemas.PredictResponse import PredictResponse
15
  from schemas.PredictRequest import PredictRequest
 
16
 
17
- router = APIRouter(prefix="/predict", tags=["inference"])
18
 
19
- # (optionnel) mapping lisible des classes
20
  LABELS = {
21
  "0": "reste_dans_l_entreprise",
22
  "1": "parti_de_l_entreprise",
23
  }
24
 
25
- # --------- Route ----------
26
- @router.post("/", response_model=PredictResponse)
27
- def batch_predict(payload: PredictRequest):
28
- with SessionLocal() as s:
29
- row = (
30
- s.query(MLModel)
31
- .filter(MLModel.name == payload.model_name)
32
- .first()
33
- )
34
-
35
- objs = [MLInput(**x.model_dump()) for x in payload.inputs]
36
- s.add_all(objs)
37
- s.commit()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
 
 
 
 
39
 
40
- if not row or getattr(row, "is_active", True) is False:
41
- raise HTTPException(status_code=404, detail="Modèle introuvable ou inactif")
42
 
43
  try:
44
  m = load_model(payload.model_name)
45
  except Exception as e:
46
- raise HTTPException(status_code=500, detail=f"Chargement du modèle '{payload.model_name}' impossible: {e}")
 
 
 
47
 
48
  try:
49
  df = pd.DataFrame([x.model_dump() for x in payload.inputs])
@@ -53,16 +135,30 @@ def batch_predict(payload: PredictRequest):
53
 
54
  probas = m.predict_proba(X)
55
  classes = getattr(m, "classes_", None)
56
- for p in probas:
 
57
  i = int(p.argmax())
58
  key = str(classes[i]) if classes is not None else str(i)
59
  label = LABELS.get(key, key)
60
- results.append(PredictItemResult(label=label, proba=float(p[i])))
 
 
 
 
 
 
 
 
 
 
 
 
61
 
62
  except Exception as e:
 
63
  raise HTTPException(status_code=400, detail=f"Erreur pendant la prédiction: {e}")
64
 
65
  return PredictResponse(
66
  model_name=payload.model_name,
67
  results=results,
68
- )
 
1
+ from fastapi import APIRouter, Depends, HTTPException, Body, status
 
2
 
3
+ from config.db import get_db
4
  from models.ml import MLModel
5
 
6
  # Schemas
7
  from models.ml_inputs import MLInput
8
+ from models.ml_output import MLOutput
9
 
10
  import pandas as pd
11
  from model_loader import load_model
 
13
  from schemas.PredictItemResult import PredictItemResult
14
  from schemas.PredictResponse import PredictResponse
15
  from schemas.PredictRequest import PredictRequest
16
+ from sqlalchemy.orm import Session
17
 
18
+ router = APIRouter(prefix="/predict", tags=["Prédiction"])
19
 
 
20
  LABELS = {
21
  "0": "reste_dans_l_entreprise",
22
  "1": "parti_de_l_entreprise",
23
  }
24
 
25
+ @router.post(
26
+ "/",
27
+ response_model=PredictResponse,
28
+ status_code=status.HTTP_200_OK,
29
+ summary="Prédire l’attrition d’un employé",
30
+ description=(
31
+ "Calcule la probabilité d’attrition pour chaque entrée fournie.\n\n"
32
+ "**Notes**\n"
33
+ "- `model_name` doit référencer un modèle *actif* en base (`MLModel`).\n"
34
+ "- Les données d’entrée sont persistées (`MLInput`) puis les sorties (`MLOutput`) sont enregistrées.\n"
35
+ "- En cas d’erreur de features ou de prédiction, la requête retourne **400**.\n"
36
+ ),
37
+ responses={
38
+ 200: {"description": "Prédictions calculées avec succès."},
39
+ 400: {"description": "Erreur pendant la préparation des features ou la prédiction."},
40
+ 404: {"description": "Modèle introuvable ou inactif."},
41
+ 500: {"description": "Impossible de charger le modèle/erreur serveur."},
42
+ },
43
+ )
44
+ def batch_predict(
45
+ payload: PredictRequest = Body(
46
+ ...,
47
+ examples={
48
+ "cas-minimal": {
49
+ "summary": "Exemple minimal",
50
+ "value": {
51
+ "model_name": "best_model",
52
+ "inputs": [
53
+ {
54
+ "id_employee": 123,
55
+ "age": 35,
56
+ "genre": "Homme",
57
+ "revenu_mensuel": 4200
58
+ }
59
+ ],
60
+ },
61
+ },
62
+ "cas-complet": {
63
+ "summary": "Exemple complet",
64
+ "value": {
65
+ "model_name": "best_model",
66
+ "inputs": [
67
+ {
68
+ "id_employee": 123,
69
+ "age": 35,
70
+ "genre": "Homme",
71
+ "revenu_mensuel": 4200,
72
+ "statut_marital": "Célibataire",
73
+ "departement": "Ventes",
74
+ "poste": "Commercial",
75
+ "nombre_experiences_precedentes": 2,
76
+ "nombre_heures_travailless": 40,
77
+ "annee_experience_totale": 5,
78
+ "annees_dans_l_entreprise": 2,
79
+ "annees_dans_le_poste_actuel": 1,
80
+ "nombre_participation_pee": 1,
81
+ "nb_formations_suivies": 3,
82
+ "nombre_employee_sous_responsabilite": 0,
83
+ "code_sondage": 7,
84
+ "distance_domicile_travail": 12,
85
+ "niveau_education": 3,
86
+ "domaine_etude": "Marketing",
87
+ "ayant_enfants": "Non",
88
+ "frequence_deplacement": "Rarement",
89
+ "annees_depuis_la_derniere_promotion": 0,
90
+ "annes_sous_responsable_actuel": 1,
91
+ "satisfaction_employee_environnement": 3,
92
+ "note_evaluation_precedente": 4,
93
+ "niveau_hierarchique_poste": 2,
94
+ "satisfaction_employee_nature_travail": 3,
95
+ "satisfaction_employee_equipe": 4,
96
+ "satisfaction_employee_equilibre_pro_perso": 3,
97
+ "eval_number": "E2",
98
+ "note_evaluation_actuelle": 4,
99
+ "heure_supplementaires": "Non",
100
+ "augementation_salaire_precedente": 11
101
+ }
102
+ ],
103
+ },
104
+ },
105
+ },
106
+ ),
107
+ db: Session = Depends(get_db),
108
+ ):
109
+ row = (
110
+ db.query(MLModel)
111
+ .filter(MLModel.name == payload.model_name)
112
+ .first()
113
+ )
114
 
115
+ objs = [MLInput(**x.model_dump()) for x in payload.inputs]
116
+ db.add_all(objs)
117
+ db.commit()
118
 
119
+ if not row or getattr(row, "is_active", True) is False:
120
+ raise HTTPException(status_code=404, detail="Modèle introuvable ou inactif")
121
 
122
  try:
123
  m = load_model(payload.model_name)
124
  except Exception as e:
125
+ raise HTTPException(
126
+ status_code=500,
127
+ detail=f"Chargement du modèle '{payload.model_name}' impossible: {e}",
128
+ )
129
 
130
  try:
131
  df = pd.DataFrame([x.model_dump() for x in payload.inputs])
 
135
 
136
  probas = m.predict_proba(X)
137
  classes = getattr(m, "classes_", None)
138
+
139
+ for idx, p in enumerate(probas):
140
  i = int(p.argmax())
141
  key = str(classes[i]) if classes is not None else str(i)
142
  label = LABELS.get(key, key)
143
+
144
+ pred = PredictItemResult(label=label, proba=float(p[i]))
145
+ results.append(pred)
146
+
147
+ db.add(
148
+ MLOutput(
149
+ input_id=objs[idx].id,
150
+ prediction=label,
151
+ prob=float(p[i]),
152
+ )
153
+ )
154
+
155
+ db.commit()
156
 
157
  except Exception as e:
158
+ db.rollback()
159
  raise HTTPException(status_code=400, detail=f"Erreur pendant la prédiction: {e}")
160
 
161
  return PredictResponse(
162
  model_name=payload.model_name,
163
  results=results,
164
+ )
src/main.py CHANGED
@@ -1,21 +1,17 @@
1
 
2
  from fastapi import FastAPI
3
 
4
- from config.db import SessionLocal
5
-
6
 
7
  from controllers.home_controller import router as ml_home_router
8
  from controllers.predict_controller import router as predict_router
9
 
10
- def get_db():
11
- db = SessionLocal()
12
- try:
13
- yield db
14
- finally:
15
- db.close()
16
-
17
 
18
- app = FastAPI()
 
 
 
 
 
19
 
20
  app.include_router(ml_home_router)
21
 
 
1
 
2
  from fastapi import FastAPI
3
 
 
 
4
 
5
  from controllers.home_controller import router as ml_home_router
6
  from controllers.predict_controller import router as predict_router
7
 
 
 
 
 
 
 
 
8
 
9
+ app = FastAPI(title="ML API",
10
+ description="""
11
+ API d’inférence pour la prédiction d’attrition.
12
+ - **/predict**: prédire un résultat selon le modèle
13
+ - **/models**: lister les modèles disponibles
14
+ """, version="1.0.0")
15
 
16
  app.include_router(ml_home_router)
17
 
src/models/ml_output.py ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # src/models/ml_output.py
2
+ from __future__ import annotations
3
+ import uuid
4
+ from datetime import datetime
5
+ from typing import Optional
6
+
7
+ from sqlalchemy import String, Float, DateTime, ForeignKey
8
+ from sqlalchemy.orm import Mapped, mapped_column
9
+ from sqlalchemy.sql import func
10
+ from sqlalchemy.dialects.postgresql import UUID
11
+
12
+ from .base import Base
13
+
14
+
15
+ class MLOutput(Base):
16
+ __tablename__ = "ml_outputs"
17
+
18
+ id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
19
+
20
+
21
+ created_at: Mapped[datetime] = mapped_column(
22
+ DateTime(timezone=True), server_default=func.now()
23
+ )
24
+
25
+ input_id: Mapped[uuid.UUID] = mapped_column(
26
+ UUID(as_uuid=True), ForeignKey("ml_inputs.id", ondelete="CASCADE"), nullable=False
27
+ )
28
+
29
+ prediction: Mapped[str] = mapped_column(String(255), nullable=False)
30
+ prob: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
31
+ error: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
src/seeds/ml_models_seed.py CHANGED
@@ -1,17 +1,15 @@
1
- # src/seeds/ml_models_seed.py
2
  import os
3
  from datetime import datetime, timezone
4
  from sqlalchemy import create_engine, text
5
  from sqlalchemy.orm import Session
6
 
7
- # (optionnel) charge .env automatiquement
8
  try:
9
  from dotenv import load_dotenv
10
  load_dotenv()
11
  except Exception:
12
  pass
13
 
14
- DATABASE_URL = os.environ["DATABASE_URL"] # ex: postgresql+psycopg2://...
15
  engine = create_engine(DATABASE_URL, future=True)
16
 
17
  UPSERT = text("""
 
 
1
  import os
2
  from datetime import datetime, timezone
3
  from sqlalchemy import create_engine, text
4
  from sqlalchemy.orm import Session
5
 
 
6
  try:
7
  from dotenv import load_dotenv
8
  load_dotenv()
9
  except Exception:
10
  pass
11
 
12
+ DATABASE_URL = os.environ["DATABASE_URL"]
13
  engine = create_engine(DATABASE_URL, future=True)
14
 
15
  UPSERT = text("""
tests/test_home.py ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi.testclient import TestClient
2
+ from sqlalchemy import create_engine
3
+ from sqlalchemy.orm import sessionmaker
4
+
5
+ from main import app
6
+ from config.db import get_db
7
+
8
+ from models.ml import MLModel
9
+
10
+ import uuid
11
+ from datetime import datetime, timezone
12
+
13
+
14
+ def test_list_models_simple(tmp_path):
15
+ db_path = tmp_path / "testing.db"
16
+ engine = create_engine(
17
+ f"sqlite:///{db_path}",
18
+ connect_args={"check_same_thread": False},
19
+ future=True,
20
+ )
21
+ SQLSession = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)
22
+
23
+ MLModel.metadata.create_all(engine)
24
+
25
+ session = SQLSession()
26
+
27
+ def get_db_override():
28
+ return session
29
+
30
+ app.dependency_overrides[get_db] = get_db_override
31
+
32
+ client = TestClient(app, raise_server_exceptions=False)
33
+
34
+ created = datetime(2025, 9, 15, 10, 11, 3, 950802, tzinfo=timezone.utc)
35
+ session.add_all(
36
+ [
37
+ MLModel(
38
+ id=uuid.UUID("5b1c7b3a-0000-4000-8000-000000000001"),
39
+ name="baseline",
40
+ description="Baseline model",
41
+ created_at=created,
42
+ is_active=True,
43
+ ),
44
+ MLModel(
45
+ id=uuid.UUID("5b1c7b3a-0000-4000-8000-000000000002"),
46
+ name="best_model",
47
+ description="XGB v1",
48
+ created_at=created,
49
+ is_active=True,
50
+ ),
51
+ MLModel(
52
+ id=uuid.UUID("5b1c7b3a-0000-4000-8000-000000000003"),
53
+ name="logistic_regression",
54
+ description="Logistic Regression",
55
+ created_at=created,
56
+ is_active=True,
57
+ ),
58
+ ]
59
+ )
60
+ session.commit()
61
+
62
+ resp = client.get("/")
63
+
64
+
65
+ app.dependency_overrides.clear()
66
+ session.close()
67
+
68
+ assert resp.status_code == 200
69
+ data = resp.json()
70
+ names = {row["name"] for row in data}
71
+ assert names == {"baseline", "best_model", 'logistic_regression'}
72
+
73
+
74
+ def test_list_models_returns_500_when_db_fails():
75
+ class BrokenSession:
76
+ def query(self, *a, **kw):
77
+ raise RuntimeError("DB is down")
78
+
79
+ def get_db_override():
80
+ yield BrokenSession()
81
+
82
+ app.dependency_overrides[get_db] = get_db_override
83
+ client = TestClient(app, raise_server_exceptions=False)
84
+
85
+ resp = client.get("/")
86
+
87
+ app.dependency_overrides.clear()
88
+
89
+ assert resp.status_code == 500
90
+ body = resp.json()
91
+ assert "DB is down" in body["detail"]
92
+
tests/test_main.py DELETED
@@ -1,8 +0,0 @@
1
- from fastapi.testclient import TestClient
2
-
3
- from main import app
4
-
5
- client = TestClient(app)
6
-
7
- def test_root_ok():
8
- assert True
 
 
 
 
 
 
 
 
 
tests/test_predict.py ADDED
@@ -0,0 +1,364 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi.testclient import TestClient
2
+ from sqlalchemy import create_engine
3
+ from sqlalchemy.orm import sessionmaker
4
+
5
+ from main import app
6
+ from config.db import get_db
7
+
8
+ from models.ml import MLModel
9
+ from models.ml_inputs import MLInput
10
+ from models.ml_output import MLOutput
11
+
12
+ import uuid
13
+ from datetime import datetime, timezone
14
+
15
+
16
+ def test_simple_predict(tmp_path):
17
+ db_path = tmp_path / "testing.db"
18
+ engine = create_engine(
19
+ f"sqlite:///{db_path}",
20
+ connect_args={"check_same_thread": False},
21
+ future=True,
22
+ )
23
+ SQLSession = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)
24
+
25
+ MLModel.metadata.create_all(engine)
26
+ MLInput.metadata.create_all(engine)
27
+ MLOutput.metadata.create_all(engine)
28
+
29
+ session = SQLSession()
30
+
31
+ def get_db_override():
32
+ return session
33
+
34
+
35
+ app.dependency_overrides[get_db] = get_db_override
36
+
37
+ client = TestClient(app, raise_server_exceptions=False)
38
+
39
+ created = datetime(2025, 9, 15, 10, 11, 3, 950802, tzinfo=timezone.utc)
40
+ session.add_all(
41
+ [
42
+ MLModel(
43
+ id=uuid.UUID("5b1c7b3a-0000-4000-8000-000000000001"),
44
+ name="baseline",
45
+ description="Baseline model",
46
+ created_at=created,
47
+ is_active=True,
48
+ ),
49
+ MLModel(
50
+ id=uuid.UUID("5b1c7b3a-0000-4000-8000-000000000002"),
51
+ name="best_model",
52
+ description="XGB v1",
53
+ created_at=created,
54
+ is_active=True,
55
+ ),
56
+ MLModel(
57
+ id=uuid.UUID("5b1c7b3a-0000-4000-8000-000000000003"),
58
+ name="logistic_regression",
59
+ description="Logistic Regression",
60
+ created_at=created,
61
+ is_active=True,
62
+ ),
63
+ ]
64
+ )
65
+ session.commit()
66
+
67
+
68
+ payload = {
69
+ "model_name": "best_model",
70
+ "inputs": [{
71
+ "id_employee": 123,
72
+ "age": 35,
73
+ "genre": "Homme",
74
+ "revenu_mensuel": 4200,
75
+ "statut_marital": "Célibataire",
76
+ "departement": "Ventes",
77
+ "poste": "Commercial",
78
+ "nombre_experiences_precedentes": 2,
79
+ "nombre_heures_travailless": 40,
80
+ "annee_experience_totale": 5,
81
+ "annees_dans_l_entreprise": 2,
82
+ "annees_dans_le_poste_actuel": 1,
83
+ "nombre_participation_pee": 1,
84
+ "nb_formations_suivies": 3,
85
+ "nombre_employee_sous_responsabilite": 0,
86
+ "code_sondage": 7,
87
+ "distance_domicile_travail": 12,
88
+ "niveau_education": 3,
89
+ "domaine_etude": "Marketing",
90
+ "ayant_enfants": "Non",
91
+ "frequence_deplacement": "Rarement",
92
+ "annees_depuis_la_derniere_promotion": 0,
93
+ "annes_sous_responsable_actuel": 1,
94
+ "satisfaction_employee_environnement": 3,
95
+ "note_evaluation_precedente": 4,
96
+ "niveau_hierarchique_poste": 2,
97
+ "satisfaction_employee_nature_travail": 3,
98
+ "satisfaction_employee_equipe": 4,
99
+ "satisfaction_employee_equilibre_pro_perso": 3,
100
+ "eval_number": "E2",
101
+ "note_evaluation_actuelle": 4,
102
+ "heure_supplementaires": "Non",
103
+ "augementation_salaire_precedente": 11
104
+ }]
105
+ }
106
+
107
+
108
+ resp = client.post("/predict", json=payload)
109
+
110
+ print("STATUS:", resp.status_code)
111
+ print("BODY:", resp.text)
112
+
113
+ app.dependency_overrides.clear()
114
+ session.close()
115
+
116
+ assert resp.status_code == 200
117
+ data = resp.json()
118
+ assert data["model_name"] == "best_model"
119
+ assert isinstance(data["results"], list)
120
+ assert len(data["results"]) == 1
121
+
122
+ result = data["results"][0]
123
+ assert result["label"] == "reste_dans_l_entreprise"
124
+ assert isinstance(result["proba"], float)
125
+ assert 0 <= result["proba"] <= 1
126
+
127
+
128
+ def test_not_found_model(tmp_path):
129
+ db_path = tmp_path / "testing.db"
130
+ engine = create_engine(
131
+ f"sqlite:///{db_path}",
132
+ connect_args={"check_same_thread": False},
133
+ future=True,
134
+ )
135
+ SQLSession = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)
136
+
137
+ MLModel.metadata.create_all(engine)
138
+ MLInput.metadata.create_all(engine)
139
+ MLOutput.metadata.create_all(engine)
140
+
141
+ session = SQLSession()
142
+
143
+ def get_db_override():
144
+ return session
145
+
146
+
147
+ app.dependency_overrides[get_db] = get_db_override
148
+
149
+ client = TestClient(app, raise_server_exceptions=False)
150
+
151
+ created = datetime(2025, 9, 15, 10, 11, 3, 950802, tzinfo=timezone.utc)
152
+ session.add_all(
153
+ [
154
+ MLModel(
155
+ id=uuid.UUID("5b1c7b3a-0000-4000-8000-000000000001"),
156
+ name="baseline",
157
+ description="Baseline model",
158
+ created_at=created,
159
+ is_active=True,
160
+ ),
161
+ ]
162
+ )
163
+ session.commit()
164
+
165
+
166
+ payload = {
167
+ "model_name": "best_model",
168
+ "inputs": [{
169
+ "id_employee": 123,
170
+ "age": 35,
171
+ "genre": "Homme",
172
+ "revenu_mensuel": 4200,
173
+ "statut_marital": "Célibataire",
174
+ "departement": "Ventes",
175
+ "poste": "Commercial",
176
+ "nombre_experiences_precedentes": 2,
177
+ "nombre_heures_travailless": 40,
178
+ "annee_experience_totale": 5,
179
+ "annees_dans_l_entreprise": 2,
180
+ "annees_dans_le_poste_actuel": 1,
181
+ "nombre_participation_pee": 1,
182
+ "nb_formations_suivies": 3,
183
+ "nombre_employee_sous_responsabilite": 0,
184
+ "code_sondage": 7,
185
+ "distance_domicile_travail": 12,
186
+ "niveau_education": 3,
187
+ "domaine_etude": "Marketing",
188
+ "ayant_enfants": "Non",
189
+ "frequence_deplacement": "Rarement",
190
+ "annees_depuis_la_derniere_promotion": 0,
191
+ "annes_sous_responsable_actuel": 1,
192
+ "satisfaction_employee_environnement": 3,
193
+ "note_evaluation_precedente": 4,
194
+ "niveau_hierarchique_poste": 2,
195
+ "satisfaction_employee_nature_travail": 3,
196
+ "satisfaction_employee_equipe": 4,
197
+ "satisfaction_employee_equilibre_pro_perso": 3,
198
+ "eval_number": "E2",
199
+ "note_evaluation_actuelle": 4,
200
+ "heure_supplementaires": "Non",
201
+ "augementation_salaire_precedente": 11
202
+ }]
203
+ }
204
+
205
+
206
+ resp = client.post("/predict", json=payload)
207
+
208
+
209
+ app.dependency_overrides.clear()
210
+ session.close()
211
+
212
+ assert resp.status_code == 404
213
+ data = resp.json()
214
+ assert data["detail"] == "Modèle introuvable ou inactif"
215
+
216
+
217
+ def test_inactif_model(tmp_path):
218
+ db_path = tmp_path / "testing.db"
219
+ engine = create_engine(
220
+ f"sqlite:///{db_path}",
221
+ connect_args={"check_same_thread": False},
222
+ future=True,
223
+ )
224
+ SQLSession = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)
225
+
226
+ MLModel.metadata.create_all(engine)
227
+ MLInput.metadata.create_all(engine)
228
+ MLOutput.metadata.create_all(engine)
229
+
230
+ session = SQLSession()
231
+
232
+ def get_db_override():
233
+ return session
234
+
235
+
236
+ app.dependency_overrides[get_db] = get_db_override
237
+
238
+ client = TestClient(app, raise_server_exceptions=False)
239
+
240
+ created = datetime(2025, 9, 15, 10, 11, 3, 950802, tzinfo=timezone.utc)
241
+ session.add_all(
242
+ [
243
+ MLModel(
244
+ id=uuid.UUID("5b1c7b3a-0000-4000-8000-000000000001"),
245
+ name="baseline",
246
+ description="Baseline model",
247
+ created_at=created,
248
+ is_active=False,
249
+ ),
250
+ ]
251
+ )
252
+ session.commit()
253
+
254
+
255
+ payload = {
256
+ "model_name": "baseline",
257
+ "inputs": [{
258
+ "id_employee": 123,
259
+ "age": 35,
260
+ "genre": "Homme",
261
+ "revenu_mensuel": 4200,
262
+ "statut_marital": "Célibataire",
263
+ "departement": "Ventes",
264
+ "poste": "Commercial",
265
+ "nombre_experiences_precedentes": 2,
266
+ "nombre_heures_travailless": 40,
267
+ "annee_experience_totale": 5,
268
+ "annees_dans_l_entreprise": 2,
269
+ "annees_dans_le_poste_actuel": 1,
270
+ "nombre_participation_pee": 1,
271
+ "nb_formations_suivies": 3,
272
+ "nombre_employee_sous_responsabilite": 0,
273
+ "code_sondage": 7,
274
+ "distance_domicile_travail": 12,
275
+ "niveau_education": 3,
276
+ "domaine_etude": "Marketing",
277
+ "ayant_enfants": "Non",
278
+ "frequence_deplacement": "Rarement",
279
+ "annees_depuis_la_derniere_promotion": 0,
280
+ "annes_sous_responsable_actuel": 1,
281
+ "satisfaction_employee_environnement": 3,
282
+ "note_evaluation_precedente": 4,
283
+ "niveau_hierarchique_poste": 2,
284
+ "satisfaction_employee_nature_travail": 3,
285
+ "satisfaction_employee_equipe": 4,
286
+ "satisfaction_employee_equilibre_pro_perso": 3,
287
+ "eval_number": "E2",
288
+ "note_evaluation_actuelle": 4,
289
+ "heure_supplementaires": "Non",
290
+ "augementation_salaire_precedente": 11
291
+ }]
292
+ }
293
+
294
+
295
+ resp = client.post("/predict", json=payload)
296
+
297
+ print("STATUS:", resp.status_code)
298
+ print("BODY:", resp.text)
299
+
300
+ app.dependency_overrides.clear()
301
+ session.close()
302
+
303
+ assert resp.status_code == 404
304
+ data = resp.json()
305
+ assert data["detail"] == "Modèle introuvable ou inactif"
306
+
307
+
308
+ def test_list_models_returns_500_when_db_fails():
309
+ class BrokenSession:
310
+ def query(self, *a, **kw):
311
+ raise RuntimeError("DB is down")
312
+
313
+ def get_db_override():
314
+ yield BrokenSession()
315
+
316
+ app.dependency_overrides[get_db] = get_db_override
317
+ client = TestClient(app, raise_server_exceptions=False)
318
+
319
+ payload = {
320
+ "model_name": "baseline",
321
+ "inputs": [{
322
+ "id_employee": 123,
323
+ "age": 35,
324
+ "genre": "Homme",
325
+ "revenu_mensuel": 4200,
326
+ "statut_marital": "Célibataire",
327
+ "departement": "Ventes",
328
+ "poste": "Commercial",
329
+ "nombre_experiences_precedentes": 2,
330
+ "nombre_heures_travailless": 40,
331
+ "annee_experience_totale": 5,
332
+ "annees_dans_l_entreprise": 2,
333
+ "annees_dans_le_poste_actuel": 1,
334
+ "nombre_participation_pee": 1,
335
+ "nb_formations_suivies": 3,
336
+ "nombre_employee_sous_responsabilite": 0,
337
+ "code_sondage": 7,
338
+ "distance_domicile_travail": 12,
339
+ "niveau_education": 3,
340
+ "domaine_etude": "Marketing",
341
+ "ayant_enfants": "Non",
342
+ "frequence_deplacement": "Rarement",
343
+ "annees_depuis_la_derniere_promotion": 0,
344
+ "annes_sous_responsable_actuel": 1,
345
+ "satisfaction_employee_environnement": 3,
346
+ "note_evaluation_precedente": 4,
347
+ "niveau_hierarchique_poste": 2,
348
+ "satisfaction_employee_nature_travail": 3,
349
+ "satisfaction_employee_equipe": 4,
350
+ "satisfaction_employee_equilibre_pro_perso": 3,
351
+ "eval_number": "E2",
352
+ "note_evaluation_actuelle": 4,
353
+ "heure_supplementaires": "Non",
354
+ "augementation_salaire_precedente": 11
355
+ }]
356
+ }
357
+
358
+
359
+ resp = client.post("/predict", json=payload)
360
+
361
+ app.dependency_overrides.clear()
362
+
363
+ assert resp.status_code == 500
364
+