AI Tools

Concevoir les modèles Django d’une application d’entretien IA

Concevez des modèles Django clairs pour une application d’entretien IA : relations entre entretien, questions et réponses, prêtes à évoluer.

Photo de profil de Long Nguyen

Long Nguyen

Développeur fullstack · Ingénieur IA · Chercheur

3 min de lecture
Schéma de base de données d’une application d’entretien IA : modèles Interview, InterviewQuestion et InterviewReport, ainsi que leurs relations

Ce qu’un entretien produit réellement

Avant d’écrire le moindre champ, il est utile de concevoir les modèles Django d’une application d’entretien IA à partir de ce qu’est réellement un entretien, plutôt que de choisir la solution la plus rapide à coder. Dans la première partie, nous avons choisi la stack et planifié les fonctionnalités. Nous allons maintenant donner à ce plan une véritable structure de données.

Réfléchissez à ce qu’une session d’entretien génère :

  • un entretien associé à un intitulé de poste et à un CV, qui suit un cycle de vie allant de l’état en attente à l’état terminé ;
  • un ensemble de questions, chacune associée à un thème, à un niveau de difficulté et à une position dans l’entretien ;
  • une réponse du candidat et une note associées à chaque question ;
  • un rapport final qui résume l’ensemble de la session.

Commencez par modéliser le domaine et les champs s’imposeront presque d’eux-mêmes. Si vous passez directement aux champs, vous devrez remanier votre schéma plus tard — et c’est le type de changement qui coûte le plus cher une fois que les données réelles se sont accumulées.

Une base commune pour les horodatages

Presque tous les modèles ont besoin des mêmes champs de gestion : la date de création et la date de dernière modification. Plutôt que de les répéter dans chaque modèle, définissez-les une seule fois dans une base abstraite, puis héritez-en. Cette petite discipline garantit la cohérence de l’ensemble du schéma.

from django.db import models
from django.utils.timezone import localtime


class TimeInfo(models.Model):
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)
    metadata = models.JSONField(default=dict, null=True, blank=True)

    class Meta:
        ordering = ("-created_at",)
        abstract = True

    @property
    def created_at_display(self):
        return localtime(self.created_at).strftime("%d-%m-%Y %H:%M:%S")

Comme abstract = True, Django ne crée pas de table pour TimeInfo lui-même : ses champs sont simplement ajoutés à chaque modèle qui en hérite. Le champ JSON metadata est une soupape d’extension volontaire : il permet d’ajouter des données supplémentaires et évolutives sans devoir créer une migration à chaque nouveau besoin.

Les modèles principaux

Le flux d’entretien repose sur trois modèles : Interview, InterviewQuestion et InterviewReport. L’entretien est la session parente ; les questions lui appartiennent et le rapport la résume. Un petit modèle complémentaire, DeviceToken, permet à l’application de reconnaître un appareil déjà utilisé sans imposer de connexion.

import uuid
from django.db import models


class DeviceToken(TimeInfo):
    token_hash = models.CharField(max_length=64, unique=True, db_index=True)

    class Meta:
        db_table = "device_tokens"


class Interview(TimeInfo):
    INTERVIEW_STATUS = [
        (1, "Pending"),
        (2, "Processing Resume"),
        (3, "Preparing Interview"),
        (4, "In Progress"),
        (5, "Started"),
        (6, "Generating Report"),
        (7, "Completed"),
        (8, "Failed"),
    ]

    job_title = models.CharField(max_length=255, null=True, blank=True)
    interview_uuid = models.UUIDField(default=uuid.uuid4, unique=True, editable=False)
    cv_file = models.FileField(upload_to="storage/files/", null=True, blank=True)
    cv_text = models.TextField(null=True, blank=True)
    status = models.SmallIntegerField(default=1, choices=INTERVIEW_STATUS)
    candidate_name = models.CharField(max_length=100, null=True, blank=True)
    candidate_email = models.EmailField(null=True, blank=True)
    device = models.ForeignKey(
        DeviceToken, on_delete=models.SET_NULL, null=True, blank=True
    )
    client_ip = models.GenericIPAddressField(
        null=True, blank=True, db_index=True
    )  # for per-IP daily rate limit

    class Meta:
        db_table = "interviews"

    def __str__(self):
        return str(self.interview_uuid)


class InterviewQuestion(TimeInfo):
    QUESTION_LEVEL_CHOICES = (
        ("easy", "Easy"),
        ("medium", "Medium"),
        ("hard", "Hard"),
    )

    interview = models.ForeignKey(Interview, on_delete=models.CASCADE)
    topic = models.CharField(max_length=255, null=True, blank=True)
    position = models.IntegerField(default=1)
    question = models.TextField(null=True, blank=True)
    sample_answer = models.TextField(null=True, blank=True)  # suggested model answer
    user_answer = models.TextField(null=True, blank=True)
    user_score = models.SmallIntegerField(null=True, blank=True)
    reason_score = models.TextField(null=True, blank=True)
    level = models.CharField(
        max_length=10, null=True, blank=True, choices=QUESTION_LEVEL_CHOICES
    )
    record = models.FileField(
        upload_to="storage/audio/", null=True, blank=True
    )  # voice answer recording

    class Meta:
        db_table = "interview_questions"

    def __str__(self):
        return f"{self.question}"


class InterviewReport(TimeInfo):
    interview = models.OneToOneField(Interview, on_delete=models.CASCADE)
    content = models.TextField(null=True, blank=True)
    is_passed = models.BooleanField(default=False)

    class Meta:
        db_table = "interview_reports"

Quelques choix méritent d’être soulignés. Le champ status utilise un cycle de vie à choix entiers afin que l’étape exacte de l’entretien — du traitement du CV à la génération du rapport — soit toujours explicite et jamais laissée à l’interprétation. Chaque question possède son propre topic, son level et sa position : l’entretien peut ainsi être reconstitué dans le bon ordre, avec une difficulté progressive, tout en conservant côte à côte un sample_answer (une réponse solide suggérée) et la user_answer du candidat. Le champ record stocke l’enregistrement audio d’une réponse orale, ce qui permet ensuite de proposer un véritable entraînement vocal. Enfin, interview_uuid attribue à chaque session un identifiant public non séquentiel : les URL ne révèlent donc pas le nombre d’entretiens existants et ne permettent pas de deviner le suivant.

Remarquez également que device et client_ip sont rattachés à l’entretien. L’application est volontairement conçue sans connexion : elle s’appuie donc sur un token d’appareil haché et sur l’adresse IP du client pour appliquer des limites quotidiennes équitables par appareil et par IP. Les utilisateurs peuvent s’entraîner sérieusement, tandis que l’utilisation abusive des endpoints d’IA reste limitée.

Relations et choix de on_delete

Les relations ont une véritable portée fonctionnelle. Elles méritent donc une décision réfléchie plutôt qu’un réglage copié par défaut :

Relation Type de champ Signification on_delete
Interview → InterviewQuestion ForeignKey Un entretien possède plusieurs questions CASCADE
Interview → InterviewReport OneToOneField Chaque entretien possède exactement un rapport CASCADE
Interview → DeviceToken ForeignKey Un entretien est associé à l’appareil qui l’a démarré SET_NULL

on_delete=CASCADE est le bon choix pour les questions et le rapport : il s’agit de données liées à la session, donc la suppression d’un entretien doit entraîner leur suppression. Laisser des lignes orphelines serait un bug, pas une fonctionnalité. Utiliser OneToOneField pour le rapport fait également respecter cette règle au niveau de la base de données : un entretien ne peut jamais se retrouver accidentellement associé à deux rapports. La relation avec l’appareil utilise plutôt SET_NULL, car un appareil et un entretien ont des cycles de vie indépendants : supprimer un token d’appareil ne doit pas supprimer les entretiens déjà réalisés.

Exécuter vos premières migrations

Les modèles ne sont que du Python tant que vous ne les avez pas transformés en tables de base de données. Django procède en deux étapes : la première génère le fichier de migration, la seconde l’applique :

python manage.py makemigrations
python manage.py migrate

makemigrations lit vos modèles et écrit une migration qui décrit la modification ; migrate l’exécute sur la base de données. Il est utile d’ouvrir au moins une fois le fichier de migration généré pour voir le SQL que Django s’apprête à exécuter. Comprendre ce qui se passe en coulisses évite que les migrations ressemblent à de la magie lorsqu’un problème survient.

Un avertissement pratique : SQLite est pratique pour le développement local, mais il n’applique pas toutes les contraintes comme PostgreSQL — la longueur des champs et certaines validations se comportent différemment. Si vous prévoyez un déploiement sur PostgreSQL, développez assez tôt avec cette base afin de ne pas découvrir ces différences en production.

C’est le cœur du modèle de données de l’entretien. L’application complète ajoute plusieurs couches : un système de tâches en arrière-plan pour exécuter le traitement en dehors du cycle de la requête, la suppression logique des anciens entretiens, ainsi que les éléments nécessaires pour rendre les réponses vocales et la protection contre les abus adaptées à la production. Ces composants, ainsi que le reste du projet, sont réunis dans le kit de démarrage. Sinon, la troisième partie est consacrée à l’importation du CV et à l’extraction de texte propre depuis des CV PDF avec pdfplumber.

FAQ

Questions fréquentes

Pourquoi séparer Question et Answer en deux modèles ?

Cette séparation permet à chaque question de posséder ses propres métadonnées et à chaque réponse sa propre note et ses propres commentaires. Les regrouper obligerait à utiliser des champs pouvant être nuls et compliquerait les requêtes par question à mesure que l’application évolue.

SQLite ou PostgreSQL pour ce projet ?

SQLite convient au développement local, mais son comportement diffère de celui de PostgreSQL en matière de validations et de contraintes. Si vous prévoyez un déploiement, développer dès le début avec PostgreSQL permet d’éviter les mauvaises surprises.

Que fait on_delete=CASCADE ici ?

La suppression d’un entretien supprime automatiquement ses questions et ses réponses, ce qui évite de laisser des lignes orphelines. Pour une application organisée autour de sessions, c’est généralement le comportement recherché.

Restez informé avec Netalith

Recevez des ressources de développement, des mises à jour produit et des offres spéciales directement dans votre boîte mail.