Aller au contenu

TP Final — AIModelHub

Spring Boot · Thymeleaf · JPA/Hibernate · PostgreSQL · DTO · Validation

Énoncé


Contexte

Vous êtes développeur.euse Java au sein d’une startup spécialisée dans l’évaluation des modèles d’intelligence artificielle. Vous devez développer AIModelHub, une application web Spring Boot permettant de cataloguer, noter et commenter les modèles IA disponibles sur le marché (GPT-4, Llama 3, Mistral, Claude, Gemini…).


Prérequis


Étape 0 — Base de données

CREATE DATABASE ai_model_hub WITH ENCODING 'UTF8';
\c ai_model_hub

CREATE TABLE fournisseur (
    id            BIGSERIAL PRIMARY KEY,
    nom           VARCHAR(150) NOT NULL UNIQUE,
    description   TEXT,
    site_web      VARCHAR(255),
    pays          VARCHAR(100),
    date_creation TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE modele_ia (
    id             BIGSERIAL PRIMARY KEY,
    nom            VARCHAR(200) NOT NULL,
    version        VARCHAR(50),
    description    TEXT,
    type_modele    VARCHAR(50) NOT NULL,
    statut         VARCHAR(20) NOT NULL DEFAULT 'ACTIF',
    parametres     BIGINT,
    contexte_max   INTEGER,
    open_source    BOOLEAN NOT NULL DEFAULT FALSE,
    gratuit        BOOLEAN NOT NULL DEFAULT FALSE,
    fournisseur_id BIGINT NOT NULL REFERENCES fournisseur(id),
    date_sortie    DATE,
    date_creation  TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    date_modif     TIMESTAMP
);

CREATE TABLE tag (
    id  BIGSERIAL PRIMARY KEY,
    nom VARCHAR(50) NOT NULL UNIQUE
);

CREATE TABLE modele_tag (
    modele_id BIGINT NOT NULL REFERENCES modele_ia(id) ON DELETE CASCADE,
    tag_id    BIGINT NOT NULL REFERENCES tag(id) ON DELETE CASCADE,
    PRIMARY KEY (modele_id, tag_id)
);

CREATE TABLE evaluation (
    id              BIGSERIAL PRIMARY KEY,
    modele_id       BIGINT NOT NULL REFERENCES modele_ia(id) ON DELETE CASCADE,
    evaluateur      VARCHAR(100) NOT NULL,
    note            INTEGER NOT NULL CHECK (note BETWEEN 1 AND 5),
    commentaire     TEXT,
    cas_utilisation VARCHAR(200),
    date_evaluation TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

-- Données initiales
INSERT INTO fournisseur (nom, description, site_web, pays) VALUES
    ('OpenAI',     'Créateur de GPT et DALL-E',           'https://openai.com',      'États-Unis'),
    ('Meta AI',    'Division IA de Meta (Facebook)',       'https://ai.meta.com',     'États-Unis'),
    ('Mistral AI', 'Startup française spécialisée en LLM', 'https://mistral.ai',      'France'),
    ('Google',     'DeepMind et Google Brain',             'https://deepmind.google', 'États-Unis'),
    ('Anthropic',  'Créateur de Claude',                   'https://anthropic.com',   'États-Unis');

INSERT INTO tag (nom) VALUES
    ('NLP'),('Vision'),('Code'),('Multimodal'),('Open Source'),
    ('RLHF'),('RAG'),('Fine-tunable'),('API disponible'),('Temps réel');

Étape 1 — Créer le projet

Sur https://start.spring.io :

Paramètre Valeur
Project Maven
Language Java
Spring Boot 3.2.x
Group fr.formation
Artifact ai-model-hub
Package fr.formation.aimodelhub
Java 17

Dépendances :

Ajouter manuellement dans pom.xml :

<!-- Layout Thymeleaf pour les templates partagés -->
<dependency>
    <groupId>nz.net.ultraq.thymeleaf</groupId>
    <artifactId>thymeleaf-layout-dialect</artifactId>
</dependency>

Étape 2 — Configuration

# src/main/resources/application.properties
spring.datasource.url=jdbc:postgresql://localhost:5432/ai_model_hub
spring.datasource.username=VOTRE_USER
spring.datasource.password=VOTRE_MOT_DE_PASSE

spring.jpa.hibernate.ddl-auto=validate
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true

logging.level.fr.formation=DEBUG
# src/test/resources/application.properties
spring.datasource.url=jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;MODE=PostgreSQL
spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=
spring.jpa.hibernate.ddl-auto=create-drop

Mission 1 — Entités JPA

1.1. Enums

public enum TypeModele    { TEXTE, IMAGE, AUDIO, MULTIMODAL, CODE, EMBEDDING }
public enum StatutModele  { ACTIF, DEPRECATED, EXPERIMENTAL }

1.2. Entités à créer

Fournisseur — Table fournisseur

Champ Java Colonne SQL Contrainte
id id PK, IDENTITY
nom nom NOT NULL, UNIQUE, max 150
description description TEXT
siteWeb site_web max 255
pays pays max 100
dateCreation date_creation @CreationTimestamp
modeles @OneToMany(mappedBy=”fournisseur”) LAZY

ModeleIa — Table modele_ia

Champ Java Colonne SQL Contrainte
id id PK, IDENTITY
nom nom NOT NULL, max 200
version version max 50
description description TEXT
typeModele type_modele @Enumerated(STRING), NOT NULL
statut statut @Enumerated(STRING), DEFAULT ACTIF
parametres parametres BIGINT nullable
contexteMax contexte_max INTEGER nullable
openSource open_source NOT NULL, default false
gratuit gratuit NOT NULL, default false
fournisseur fournisseur_id @ManyToOne LAZY NOT NULL
dateSortie date_sortie DATE nullable
dateCreation date_creation @CreationTimestamp
dateModif date_modif @UpdateTimestamp
tags @ManyToMany → Tag
evaluations @OneToMany LAZY CascadeALL

Tag — Table tag

Champ Contrainte
id PK
nom UNIQUE, NOT NULL, max 50
modeles @ManyToMany(mappedBy=”tags”)

Evaluation — Table evaluation

Champ Contrainte
id PK
modele @ManyToOne LAZY NOT NULL
evaluateur NOT NULL, max 100
note CHECK 1-5
commentaire TEXT
casUtilisation max 200
dateEvaluation @CreationTimestamp

Utilisez @EqualsAndHashCode(of = "id") et @ToString(exclude = {...}) sur toutes les entités ayant des associations.


Mission 2 — DTO et Mappers

2.1. DTOs à créer

ModeleForm.java (Lombok @Data @NoArgsConstructor) :

id          Long           nullable (null = création)
nom         String         @NotBlank @Size(min=2, max=200)
version     String         @Size(max=50)
description String         @NotBlank @Size(min=10)
typeModele  TypeModele     @NotNull
statut      StatutModele   default ACTIF
parametres  Long           @Min(0)
contexteMax Integer        @Min(0)
openSource  boolean
gratuit     boolean
fournisseurId Long         @NotNull
dateSortie  LocalDate
tagIds      Set<Long>      IDs des tags sélectionnés

EvaluationForm.java (Lombok @Data @NoArgsConstructor) :

evaluateur      String  @NotBlank @Size(min=2, max=100)
note            Integer @NotNull @Min(1) @Max(5)
commentaire     String  @Size(max=2000)
casUtilisation  String  @Size(max=200)

ModeleReponse.java (record Java 17) :

id, nom, version, description, typeModele, statut,
parametres, contexteMax, openSource, gratuit,
fournisseurNom, fournisseurPays, dateSortie,
tags (Set<String>), noteMoyenne (Double), nombreEvaluations (int)

ModeleResume.java (record Java 17 — pour la liste) :

id, nom, version, typeModele, fournisseurNom,
openSource, gratuit, noteMoyenne, nombreEvaluations, statut

2.2. Mappers à créer

ModeleMapper.java (@Component) :

ModeleReponse versReponse(ModeleIa modele);
ModeleResume  versResume(ModeleIa modele);
ModeleIa      versEntite(ModeleForm form);
void          mettreAJourEntite(ModeleForm form, ModeleIa modele);
ModeleForm    versFormulaire(ModeleIa modele);

EvaluationMapper.java (@Component) :

Evaluation versEntite(EvaluationForm form, ModeleIa modele);

2.3. Tests obligatoires du mapper

Créez ModeleMapperTest avec minimum 5 tests :


Mission 3 — CRUD des modèles

3.1. Repository

@Repository
public interface ModeleIaRepository
        extends JpaRepository<ModeleIa, Long>,
                JpaSpecificationExecutor<ModeleIa> {

    // Chargement avec toutes les associations (éviter le N+1)
    @Query("SELECT DISTINCT m FROM ModeleIa m " +
           "LEFT JOIN FETCH m.fournisseur " +
           "LEFT JOIN FETCH m.tags " +
           "WHERE m.id = :id")
    Optional<ModeleIa> findByIdAvecDetails(@Param("id") Long id);

    // Pour la liste avec pagination
    @Query("SELECT m FROM ModeleIa m LEFT JOIN FETCH m.fournisseur ORDER BY m.nom")
    List<ModeleIa> findAllAvecFournisseur();

    boolean existsByNomIgnoreCaseAndFournisseur(String nom, Fournisseur fournisseur);
}

3.2. Service

@Service @Transactional
public class ModeleIaService {

    // Méthodes à implémenter :
    Page<ModeleIa>   trouverTous(Pageable pageable);
    ModeleIa         trouverParId(Long id);         // → ResourceNotFoundException si absent
    ModeleIa         creer(ModeleForm form);
    ModeleIa         mettreAJour(Long id, ModeleForm form);
    void             desactiver(Long id);            // soft delete : statut = DEPRECATED
    List<ModeleIa>   trouverParType(TypeModele type);
    List<ModeleIa>   trouverOpenSource();
    List<ModeleIa>   trouverGratuits();
}

3.3. Contrôleur

URL Méthode Description
/modeles GET Liste paginée (10 par page)
/modeles/{id} GET Détail + évaluations + note moyenne
/modeles/nouveau GET Formulaire vide
/modeles/nouveau POST Créer + validation
/modeles/{id}/modifier GET Formulaire pré-rempli
/modeles/{id}/modifier POST Modifier + validation
/modeles/{id}/supprimer POST Désactiver

3.4. Vues Thymeleaf obligatoires

La page de liste doit afficher :


Mission 4 — Évaluations

4.1. Sur la page de détail

Afficher :

4.2. Service des évaluations

@Service @Transactional
public class EvaluationService {

    // Ajouter une évaluation
    // → Vérifier qu'un évaluateur n'a pas déjà évalué ce modèle
    // → Lève ValidationMetierException si doublon
    Evaluation evaluer(Long modeleId, EvaluationForm form);

    // Note moyenne d'un modèle
    Double calculerNoteMoyenne(Long modeleId);

    // Évaluations d'un modèle, triées par date desc
    List<Evaluation> trouverParModele(Long modeleId);
}

Règle métier obligatoire : si evaluateur a déjà soumis une évaluation pour ce modèle, retourner au formulaire avec le message d’erreur : “Vous avez déjà évalué ce modèle.”

4.3. Endpoint

POST /modeles/{id}/evaluer

Mission 5 — Gestion des erreurs

5.1. Exceptions à créer

public class ResourceNotFoundException extends RuntimeException {
    public ResourceNotFoundException(String ressource, Object id) {
        super(ressource + " introuvable : " + id);
    }
}

public class ValidationMetierException extends RuntimeException {
    private final String champ;
    public ValidationMetierException(String champ, String message) { ... }
    public void injecterDans(BindingResult result) { ... }
}

5.2. GlobalExceptionHandler

@ControllerAdvice
public class GlobalExceptionHandler {
    // Gérer : ResourceNotFoundException → 404
    // Gérer : IllegalArgumentException  → 400
    // Gérer : IllegalStateException     → 409
    // Gérer : Exception (générique)     → 500
}

5.3. Pages Thymeleaf

Créez templates/error/404.html, templates/error/500.html, templates/error/erreur.html avec un design soigné.

Test obligatoire : naviguer sur /modeles/9999 doit afficher la page 404 avec le message “Modèle IA introuvable : 9999”.


Mission 6 — Tests

6.1. ModeleMapperTest — 5 tests

6.2. ModeleFormValidationTest — 6 tests

// Tests à implémenter :
void modeleForm_valide_aucuneViolation()
void modeleForm_nomVide_violationSurNom()
void modeleForm_descriptionTropCourte_violation()
void modeleForm_fournisseurIdNull_violation()
void evaluationForm_note0_violation()
void evaluationForm_note6_violation()

6.3. ModeleControllerTest — 6 tests

// Tests à implémenter :
void getListe_retourneVue200()
void getDetail_modeleExistant_retourne200()
void getDetail_modeleInexistant_retourne404()
void postNouveau_formValide_redirige()
void postNouveau_nomVide_retourneFormulaireAvecErreurs()
void postEvaluer_noteHorsLimites_retourneErreur()

Critères de réussite

Bonus : Recherche multicritère avec JpaSpecificationExecutor — filtrer par type, fournisseur, open source, gratuit, recherche textuelle sur le nom.


Conseils

Commencez par Mission 1 (entités) — une fois les entités correctes, le reste se construit dessus. Testez la connexion BDD dès la Mission 1 (ddl-auto=validate doit passer sans erreur). ️N’exposez JAMAIS une entité JPA directement dans un Model Thymeleaf — utilisez toujours un DTO. Pour Thymeleaf : le DTO dans Model.addAttribute("modeleForm", new ModeleForm()) doit avoir le même nom que dans @ModelAttribute("modeleForm") du contrôleur.


Philippe Bouget