Comment intégrer l'IA dans une application Django ?

Découvrez comment intégrer l'IA dans une application Django avec l'API OpenAI, Pydantic AI et Celery.

Publié le par Gabriel Trouvé (mis à jour le )

83 minutes

En lisant le titre du guide, je sais que vous vous attendez à la création d'un chatbot. C'est raté ! Quand je parle d'intégrer l'IA dans une application web, ça peut être toutes sortes d'applications différentes : résumer/classer des fichiers, corriger des exercices, etc.

Dans ce guide, nous prendrons l'exemple de Boréal Systèmes, un éditeur de logiciels de gestion qui reçoit énormément de messages via son formulaire de contact. Sauf que Patrick est tout seul dans l'entreprise, et il ne sait plus où donner de la tête. Chaque demande sera classée par catégorie, par urgence, avec un résumé. Mais attention, l'IA peut se tromper, alors Patrick pourra jeter un œil dans l'admin Django.

On utilisera Django, OpenAI, Pydantic AI, Celery et Redis (pour le côté asynchrone).

Pourquoi confier le tri à un LLM ?

Pas besoin d'entraîner quoi que ce soit : on décrit la tâche et après il travaille pour nous.

Quelques points à connaître :

  • chaque appel à l'API est payant

  • l'API peut tomber en panne (souvenez-vous de début septembre 2026)

  • le modèle peut se tromper, bien qu'il soit de plus en plus fiables

À noter

Pour pousser le concept (cela fera l'objet d'un prochain article), on pourrait faire relire le travail du modèle par un autre LLM. C'est ce que l'on appelle le LLM as a judge. Pour faire simple : un modèle vérifie le travail d'un autre modèle. On pourrait prendre par exemple un modèle Claude pour relire le travail de GPT. Partez du principe qu'un LLM a tendance à mieux noter ses propres textes que ceux des autres…

Préparation du projet Django

Vous commencez à avoir l'habitude : dans la plupart des derniers guides, je passe le plus souvent par uv :

uv init --no-package boreal_support --python 3.14
cd boreal_support
uv add django "pydantic-ai-slim[openai]" celery redis python-dotenv
uv add --dev pytest pytest-django pydantic-evals
uv run django-admin startproject boreal .
uv run python manage.py startapp demandes
SHELL

À noter

pydantic-ai-slim[openai] permet d'installer Pydantic AI avec uniquement ce qu'il faut pour OpenAI.

Dans boreal/settings.py, on ajoute l'application demandes, puis on passe l'application en français et on déclare deux réglages pour Celery et le modèle d'IA utilisé.

Pensez aussi à charger le fichier .env que l'on va créer par la suite.

# boreal/settings.py
import os
from pathlib import Path

from dotenv import load_dotenv

BASE_DIR = Path(__file__).resolve().parent.parent

# Charge les variables du fichier .env (clé API...)
load_dotenv(BASE_DIR / ".env")

# ...

INSTALLED_APPS = [
    # ...
    "demandes",
]

LANGUAGE_CODE = "fr-fr"
TIME_ZONE = "Europe/Paris"

# Celery
CELERY_BROKER_URL = "redis://localhost:6379/0"

# IA
IA_MODELE = os.environ.get("IA_MODELE", "openai:gpt-5.6-luna")
PYTHON

Le nom du modèle sera lu dans une variable d'environnement, avec une valeur par défaut, ce qui donne davantage de flexibilité si on veut modifier le modèle utilisé.

À noter

CELERY_BROKER_URL indique à Celery où déposer les tâches. Nous utilisons Redis. Bien que le client Python soit installé, pensez à installer Redis lui-même.

# macOS
brew install redis

# Ubuntu / Debian
sudo apt install redis-server

# Ou avec Docker
docker run -d --name redis -p 6379:6379 redis
SHELL

Vous pouvez vérifier que Redis répond avec redis-cli ping.

On utilisera GPT-5.6 Luna car il est pensé pour les gros volumes à moindre prix.

Concernant la clé API, Pydantic AI la lit automatiquement dans la variable d'environnement OPENAI_API_KEY que l'on place dans le .env à la racine du projet.

La clé API est à récupérer chez OpenAI.

# .env
OPENAI_API_KEY=sk-...
SHELL

load_dotenv() copie le contenu du fichier dans les variables d'environnement dès le chargement du fichier de settings.

Attention

Je me répète par rapport à d'autres guides, mais le .env ne doit jamais être versionné : pensez à l'ajouter au .gitignore.

Le modèle Demande

Direction demandes/models.py :

# demandes/models.py
from django.db import models


class Demande(models.Model):
    class Categorie(models.TextChoices):
        FACTURATION = "facturation", "Facturation"
        TECHNIQUE = "technique", "Problème technique"
        COMPTE = "compte", "Accès au compte"
        COMMERCIAL = "commercial", "Demande commerciale"
        AUTRE = "autre", "Autre"

    class Urgence(models.TextChoices):
        BASSE = "basse", "Basse"
        NORMALE = "normale", "Normale"
        HAUTE = "haute", "Haute"

    class Statut(models.TextChoices):
        EN_ATTENTE = "en_attente", "En attente"
        CLASSEE = "classee", "Classée"
        A_TRIER = "a_trier", "À trier à la main"

    email = models.EmailField()
    message = models.TextField()
    cree_le = models.DateTimeField(auto_now_add=True)
    statut = models.CharField(max_length=20, choices=Statut, default=Statut.EN_ATTENTE)
    # Champs remplis par l'IA
    categorie = models.CharField(max_length=20, choices=Categorie, blank=True)
    urgence = models.CharField(max_length=20, choices=Urgence, blank=True)
    resume = models.CharField(max_length=200, blank=True)

    class Meta:
        ordering = ["-cree_le"]

    def __str__(self):
        return f"Demande de {self.email}"
PYTHON

On va gérer les catégories, les niveaux d'urgence et les statuts avec des TextChoices. Une demande va arriver en statut en_attente et passera en classee une fois triée par l'IA, ou en a_trier si l'IA n'a pas réussi.

Si vous regardez bien resume, on limite ce champ à 200 caractères (très important pour la suite 😛).

C'est parti pour les migrations et la création du superuser :

uv run python manage.py makemigrations demandes
uv run python manage.py migrate
uv run python manage.py createsuperuser
SHELL

Comment structurer la réponse de l'IA avec Pydantic AI ?

Nous ne voulons pas simplement du texte comme un LLM pourrait en produire. Nous voulons des données : une catégorie existante, une urgence et un résumé qui fait maximum 200 caractères. C'est ici que Pydantic AI entre en jeu : on va lui faire valider la réponse de notre IA.

Créons un module ia.py :

# demandes/ia.py
from django.conf import settings
from pydantic import BaseModel, Field
from pydantic_ai import Agent
from pydantic_ai.settings import ModelSettings

from .models import Demande


class Classification(BaseModel):
    categorie: Demande.Categorie
    urgence: Demande.Urgence
    resume: str = Field(max_length=200, description="La demande résumée en une phrase")


agent_tri = Agent(
    settings.IA_MODELE,
    output_type=Classification,
    instructions=(
        "Tu tries les demandes reçues par le support de Boréal Systèmes, "
        "un éditeur de logiciels de gestion. "
        "Choisis la catégorie la plus adaptée, évalue l'urgence "
        "(haute si le client est bloqué dans son travail) "
        "et résume la demande en une phrase."
    ),
    # Trier des demandes ne demande pas un long raisonnement
    model_settings=ModelSettings(thinking="low"),
    # Ne vérifie pas la clé API au démarrage (tests, migrations...)
    defer_model_check=True,
)


def classer_demande(message: str) -> Classification:
    """Envoie le message au LLM et renvoie une classification validée."""
    resultat = agent_tri.run_sync(message)
    return resultat.output
PYTHON

Classification décrit la réponse attendue et ses champs réutilisent directement les TextChoices. output_type=Classification permet à l'agent de connaître la forme de la réponse via Pydantic AI, qui transmet le schéma JSON. Le reste est assez basique : des instructions et un réglage sur l'effort de raisonnement.

La seule façon pour le reste de notre programme de communiquer avec l'IA : classer_demande().

À noter

defer_model_check=True permet à Pydantic AI de ne chercher la clé qu'au moment où l'agent est utilisé avec classer_demande(). Sans ça, Pydantic AI chercherait la clé API directement au chargement de Django, ce qui est source d'erreur. C'est un problème de chargement de modules.

C'est le même principe que reverse_lazy(), que nous utiliserons dans la suite du guide : le calcul de l'URL ne se fait que quand on s'en sert vraiment.

Si la réponse ne passe pas la validation côté Pydantic AI, ce dernier renvoie l'erreur au modèle pour correction. Vous pouvez déjà faire un test dans le shell Django avec uv run python manage.py shell :

from pydantic import ValidationError

from demandes.ia import Classification

try:
    Classification(categorie="urgent", urgence="haute", resume="x")
except ValidationError as erreur:
    print(erreur)

"""
1 validation error for Classification
categorie
  Input should be 'facturation', 'technique', 'compte', 'commercial' or 'autre' [type=enum, input_value='urgent', input_type=str]
    For further information visit https://errors.pydantic.dev/2.13/v/enum
"""
PYTHON

On peut maintenant faire un petit test, à condition que votre clé OpenAI soit bien dans le .env 🧑‍🏫.

from demandes.ia import classer_demande

classer_demande(
    "Depuis la mise à jour de ce matin, l'export comptable plante à chaque fois. "
    "Toute l'équipe est bloquée."
)

"""
Classification(categorie=Demande.Categorie.TECHNIQUE, urgence=Demande.Urgence.HAUTE, resume='Depuis la mise à jour de ce matin, l’export comptable plante systématiquement et bloque toute l’équipe.')
"""
PYTHON

Comment exécuter l'IA avec Celery ?

On pourrait très bien appeler la fonction classer_demande() dans la vue du formulaire de la demande. Mais… c'est une mauvaise idée pour plusieurs raisons :

  • l'utilisateur va devoir attendre devant le chargement de la page tant qu'OpenAI n'a pas répondu

  • un worker Gunicorn est bloqué : en production, ça fait mal 😅

  • et si jamais l'API est en panne, c'est carrément le formulaire et la demande qui plantent

On va utiliser ma stack de production habituelle : Celery + Redis.

Configurer Celery

N'ayez pas peur, il n'y a vraiment rien de plus simple ! Dans le dossier boreal (là où il y a les settings), créez un fichier celery.py :

# boreal/celery.py
import os

from celery import Celery

os.environ.setdefault("DJANGO_SETTINGS_MODULE", "boreal.settings")

app = Celery("boreal")
# Toutes les clés Celery des settings commencent par CELERY_
app.config_from_object("django.conf:settings", namespace="CELERY")
# Cherche un module tasks.py dans chaque application installée
app.autodiscover_tasks()
PYTHON

Et le __init__.py existant :

# boreal/__init__.py
from .celery import app as celery_app

__all__ = ("celery_app",)
PYTHON

L'import dans le fichier boreal/__init__.py est important, sinon Celery ne sera pas chargé au démarrage de Django.

Créer la tâche de tri

Dans l'application demandes, créez le fichier tasks.py :

# demandes/tasks.py
from celery import shared_task
from pydantic_ai.exceptions import AgentRunError

from .ia import classer_demande
from .models import Demande


@shared_task(rate_limit="120/m")
def trier_demande(demande_id: int) -> None:
    demande = Demande.objects.get(pk=demande_id)

    try:
        classification = classer_demande(demande.message)
    except AgentRunError:
        # L'IA n'a pas pu trier la demande : un humain prend le relais
        demande.statut = Demande.Statut.A_TRIER
        demande.save(update_fields=["statut"])
        return

    demande.categorie = classification.categorie
    demande.urgence = classification.urgence
    demande.resume = classification.resume
    demande.statut = Demande.Statut.CLASSEE
    demande.save(update_fields=["categorie", "urgence", "resume", "statut"])
PYTHON

AgentRunError permet de gérer les cas où il y a un problème avec l'API. Dans ce cas, la demande passe en a_trier. Dans tous les autres cas, l'IA fait son boulot.

Le formulaire de contact

Pour le formulaire, nous n'aurons besoin que de l'adresse e-mail et du message. Pour ça, dans l'application demandes, je crée un fichier forms.py :

# demandes/forms.py
from django import forms

from .models import Demande


class DemandeForm(forms.ModelForm):
    class Meta:
        model = Demande
        fields = ["email", "message"]
        # Classes Bootstrap sur les champs du formulaire
        widgets = {
            "email": forms.EmailInput(attrs={"class": "form-control"}),
            "message": forms.Textarea(attrs={"class": "form-control", "rows": 5}),
        }
PYTHON

Puis dans le fichier de vues :

# demandes/views.py
from django.contrib.messages.views import SuccessMessageMixin
from django.urls import reverse_lazy
from django.views.generic import CreateView

from .forms import DemandeForm
from .models import Demande
from .tasks import trier_demande


class NouvelleDemandeView(SuccessMessageMixin, CreateView):
    model = Demande
    form_class = DemandeForm
    success_url = reverse_lazy("nouvelle_demande")
    success_message = "Merci, votre demande a bien été envoyée."

    def form_valid(self, form):
        reponse = super().form_valid(form)
        # Le tri part en tâche de fond, une fois la demande enregistrée en base
        trier_demande.delay_on_commit(self.object.pk)
        return reponse
PYTHON

La vue enregistre la demande et envoie la tâche. J'ai découvert, pendant la rédaction de ce guide, la méthode delay_on_commit(). Contrairement à un simple delay(), elle attend la fin de la transaction avant de transmettre la tâche.

Pour cet exemple simple, on va se passer du fichier d'URL de l'application et déclarer la route directement dans le fichier central du projet.

# boreal/urls.py
from django.contrib import admin
from django.urls import path

from demandes.views import NouvelleDemandeView

urlpatterns = [
    path("admin/", admin.site.urls),
    path("support/", NouvelleDemandeView.as_view(), name="nouvelle_demande"),
]
PYTHON

Maintenant, le template dans demandes/templates/demandes/demande_form.html. Pour faire un peu de mise en forme, je vais utiliser Bootstrap, que je charge depuis un CDN.

<!DOCTYPE html>
<html lang="fr">
    <head>
        <meta charset="utf-8">
        <meta name="viewport" content="width=device-width, initial-scale=1">
        <title>Contacter le support</title>
        <link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css"
              rel="stylesheet"
              integrity="sha384-sRIl4kxILFvY47J16cr9ZwB07vP4J8+LH7qKQnuqkuIAvNWLzeN8tE5YBujZqJLB"
              crossorigin="anonymous">
    </head>
    <body>
        <main class="container py-5">
            <div class="col-md-8 mx-auto">
                <h1 class="h3 mb-4">Contacter le support de Boréal Systèmes</h1>
                {% for message in messages %}<div class="alert alert-success">{{ message }}</div>{% endfor %}
                <form method="post">
                    {% csrf_token %}
                    {% for field in form %}<div class="mb-3">{{ field.as_field_group }}</div>{% endfor %}
                    <button type="submit" class="btn btn-primary">Envoyer</button>
                </form>
            </div>
        </main>
    </body>
</html>
HTML

field.as_field_group permet d'afficher chaque champ avec son libellé, le champ lui-même et les éventuelles erreurs.

Lancer l'application

Pour lancer l'application il faut trois terminaux (on ne rigole pas chez Docstring 🤣) : pour Redis, le worker Celery et le serveur Django.

redis-server

# Terminal 2 : le worker Celery
uv run celery -A boreal worker --loglevel=info --pool=threads

# Terminal 3 : Django
uv run python manage.py runserver
SHELL

À noter

--pool=threads permet de contourner le bug de Celery 5.6.3 sur macOS. Sur Linux, on peut s'en passer.

Cela permet à Django de déposer les tâches sur le serveur Redis, puis le worker Celery les récupère.

D'ailleurs, dans le terminal où vous lancez Celery, vous devriez voir apparaître la tâche :

[tasks]
  . demandes.tasks.trier_demande
SHELL

Allons maintenant sur l'URL http://127.0.0.1:8000/support/.

Je vais envoyer une demande :

Page de support

Page de support

La page de confirmation s'affiche et le worker a bien reçu la tâche :

[2026-09-24 07:46:39,301: INFO/MainProcess] Task demandes.tasks.trier_demande[8f8c729d-11a8-4796-9a0e-f4ca0ecd72b0] received
[2026-09-24 07:46:43,284: INFO/MainProcess] HTTP Request: POST https://api.openai.com/v1/responses "HTTP/1.1 200 OK"
[2026-09-24 07:46:43,420: INFO/MainProcess] Task demandes.tasks.trier_demande[8f8c729d-11a8-4796-9a0e-f4ca0ecd72b0] succeeded in 4.118378082999698s: None
SHELL

Tâche réalisée avec succès !

Corriger le tri depuis l'administration Django

Jusqu'ici, on ne peut pas contrôler le résultat simplement. C'est pour cette raison que nous allons mettre en place ce qu'il faut dans l'administration Django.

Rendez-vous dans demandes/admin.py :

# demandes/admin.py
from django.contrib import admin

from .models import Demande
from .tasks import trier_demande


@admin.register(Demande)
class DemandeAdmin(admin.ModelAdmin):
    list_display = ["email", "categorie", "urgence", "resume", "statut", "cree_le"]
    list_filter = ["statut", "categorie", "urgence"]
    # Un humain corrige la catégorie ou l'urgence directement dans la liste
    list_editable = ["categorie", "urgence"]
    search_fields = ["email", "message"]
    actions = ["relancer_le_tri"]

    @admin.action(description="Relancer le tri par l'IA")
    def relancer_le_tri(self, request, queryset):
        for demande_id in queryset.values_list("pk", flat=True):
            trier_demande.delay(demande_id)
        self.message_user(request, f"{queryset.count()} demande(s) renvoyée(s) au tri.")
PYTHON

list_filter pour filtrer sur les attributs passés dans la liste, et list_editable pour modifier depuis la liste. L'action relancer_le_tri permet de renvoyer les demandes si jamais l'API était en panne.

Pour revenir à la demande précédente, voici ce que j'ai dans l'admin Django :

Admin Django

Admin Django

Si plusieurs demandes n'étaient pas traitées car l'IA n'était pas encore implémentée, il suffirait de filtrer les demandes en attente, de les cocher et d'exécuter notre action :

Action personnalisée

Action personnalisée

Comment tester sans appeler l'API OpenAI ?

Appeler OpenAI pour les tests serait trop compliqué : les tests seraient lents et jamais identiques entre les exécutions.

Heureusement, Pydantic AI arrive à la rescousse.

Commençons par le fichier de configuration de pytest. À la racine du projet, je crée le fichier pytest.ini :

[pytest]
DJANGO_SETTINGS_MODULE = boreal.settings
YAML

Maintenant, le fichier conftest.py à la racine du projet :

# conftest.py
from pydantic_ai import models

# Bloque tout appel réel à un LLM pendant les tests
models.ALLOW_MODEL_REQUESTS = False
PYTHON

models.ALLOW_MODEL_REQUESTS bloque les requêtes vers les modèles. Si un test oublie de remplacer le modèle, il échoue.

Maintenant, les tests :

# demandes/test_tri.py
import pytest
from pydantic_ai import ModelResponse, ToolCallPart
from pydantic_ai.exceptions import ModelHTTPError
from pydantic_ai.models.function import FunctionModel
from pydantic_ai.models.test import TestModel

from demandes.ia import agent_tri
from demandes.models import Demande
from demandes.tasks import trier_demande


@pytest.fixture
def demande():
    return Demande.objects.create(
        email="[email protected]",
        message="Bonjour, j'ai reçu deux fois la facture de septembre.",
    )


@pytest.mark.django_db
def test_demande_classee(demande):
    """
    Given une demande en attente
    When le LLM renvoie une classification valide
    Then la demande est classée
    """
    modele = TestModel(
        custom_output_args={
            "categorie": "facturation",
            "urgence": "normale",
            "resume": "Facture de septembre reçue en double.",
        }
    )

    with agent_tri.override(model=modele):
        trier_demande(demande.pk)

    demande.refresh_from_db()
    assert demande.statut == Demande.Statut.CLASSEE
    assert demande.categorie == Demande.Categorie.FACTURATION
    assert demande.resume == "Facture de septembre reçue en double."


@pytest.mark.django_db
def test_reponse_invalide_passe_en_a_trier(demande):
    """
    Given une demande en attente
    When le LLM invente une catégorie à chaque tentative
    Then Pydantic AI relance le modèle, puis la demande passe à trier à la main
    """
    appels = []

    def llm_farfelu(messages, info):
        appels.append(messages)
        outil = info.output_tools[0].name
        arguments = {"categorie": "urgent!!!", "urgence": "haute", "resume": "?"}
        return ModelResponse(parts=[ToolCallPart(outil, arguments)])

    with agent_tri.override(model=FunctionModel(llm_farfelu)):
        trier_demande(demande.pk)

    demande.refresh_from_db()
    assert demande.statut == Demande.Statut.A_TRIER
    assert len(appels) == 2


@pytest.mark.django_db
def test_erreur_definitive_passe_en_a_trier(demande):
    """
    Given une demande en attente
    When l'API répond par une erreur définitive (400)
    Then la demande passe à trier à la main, sans nouvelle tentative
    """

    def api_en_erreur(messages, info):
        raise ModelHTTPError(status_code=400, model_name="gpt-5.6-luna")

    with agent_tri.override(model=FunctionModel(api_en_erreur)):
        trier_demande(demande.pk)

    demande.refresh_from_db()
    assert demande.statut == Demande.Statut.A_TRIER
PYTHON

Vous pouvez les lancer depuis le terminal avec la commande pytest.

  • agent_tri.override(model=...) remplace le modèle de l'agent dans le bloc with

  • TestModel renvoie la réponse que nous voulons via custom_output_args

  • FunctionModel permet de remplacer le LLM par une fonction que nous écrivons : llm_farfelu() dans notre cas, qui renvoie toujours "urgent!!!", une catégorie qui n'existe pas

  • avec appels, on vérifie qu'il y a eu la réponse initiale puis une relance (len(appels) == 2)

Comment tester la fiabilité du tri ?

Nous venons de tester le code ; maintenant, testons le jugement du modèle. Pour savoir si le tri de l'IA est bon, il faut le confronter à un tri fait à la main. Pydantic a prévu un module : pydantic-evals !

On va créer une commande nous-mêmes. Il faut créer l'arborescence suivante : demandes/management/commands/, sans oublier un fichier __init__.py dans management/ et dans commands/.

# demandes/management/commands/evaluer_tri.py
import json
from pathlib import Path

from django.core.management.base import BaseCommand
from pydantic_evals import Case, Dataset
from pydantic_evals.evaluators import EqualsExpected

from demandes.ia import classer_demande

JEU_DE_TEST = Path(__file__).resolve().parents[2] / "evals" / "jeu_de_test.json"


def categoriser(message: str) -> str:
    return classer_demande(message).categorie


class Command(BaseCommand):
    help = "Mesure la fiabilité du tri sur le jeu de test."

    def handle(self, *args, **options):
        exemples = json.loads(JEU_DE_TEST.read_text(encoding="utf-8"))
        dataset = Dataset(
            name="tri_des_demandes",
            cases=[
                Case(inputs=exemple["message"], expected_output=exemple["categorie"])
                for exemple in exemples
            ],
            evaluators=[EqualsExpected()],
        )

        rapport = dataset.evaluate_sync(categoriser, max_concurrency=5)
        rapport.print(include_output=True, include_expected_output=True)
PYTHON
  • categoriser() appelle le vrai modèle

  • Case prend un message du jeu de test en entrée et la catégorie attendue en réponse

  • EqualsExpected fait la comparaison entre notre catégorie et celle choisie par l'IA

Dans demandes/evals/, créons le fichier jeu_de_test.json :

[
    {
        "email": "[email protected]",
        "message": "Bonjour, nous avons reçu deux fois la facture de septembre. Pouvez-vous annuler le doublon ?",
        "categorie": "facturation"
    },
    {
        "email": "[email protected]",
        "message": "Depuis la mise à jour de ce matin, l'export comptable plante à chaque fois. Toute l'équipe est bloquée.",
        "categorie": "technique"
    },
    {
        "email": "[email protected]",
        "message": "Je n'arrive plus à me connecter, le lien de réinitialisation du mot de passe a expiré.",
        "categorie": "compte"
    },
    {
        "email": "[email protected]",
        "message": "Nous aimerions ajouter 15 licences pour notre agence de Lille. Pouvez-vous nous envoyer un devis ?",
        "categorie": "commercial"
    },
    {
        "email": "[email protected]",
        "message": "Bonjour, recrutez-vous des alternants en développement pour la rentrée ?",
        "categorie": "autre"
    },
    {
        "email": "[email protected]",
        "message": "Le prélèvement de ce mois est de 480 € au lieu des 360 € prévus au contrat.",
        "categorie": "facturation"
    },
    {
        "email": "[email protected]",
        "message": "Les graphiques du tableau de bord ne s'affichent plus sur Firefox. Sur Chrome, tout fonctionne.",
        "categorie": "technique"
    },
    {
        "email": "[email protected]",
        "message": "Notre comptable a quitté l'entreprise. Comment transférer son compte administrateur à Élodie ?",
        "categorie": "compte"
    },
    {
        "email": "[email protected]",
        "message": "Proposez-vous une remise pour les associations ?",
        "categorie": "commercial"
    },
    {
        "email": "[email protected]",
        "message": "Impossible de télécharger ma facture depuis l'espace client, le bouton ne répond pas.",
        "categorie": "technique"
    },
    {
        "email": "[email protected]",
        "message": "Je voudrais passer à l'offre supérieure, mais je ne trouve pas l'option dans mon compte.",
        "categorie": "commercial"
    },
    {
        "email": "[email protected]",
        "message": "Merci pour votre réactivité hier, le problème est réglé !",
        "categorie": "autre"
    }
]
JSON

Évaluons ça : uv run python manage.py evaluer_tri. Voici le résultat que j'ai obtenu :

                  Evaluation Summary: categoriser
┏━━━━━━━━━━┳━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━┳━━━━━━━━━━━━┳━━━━━━━━━━┓
┃ Case ID  ┃ Expected Output ┃ Outputs     ┃ Assertions ┃ Duration ┃
┡━━━━━━━━━━╇━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━╇━━━━━━━━━━━━╇━━━━━━━━━━┩
│ Case 1   │ facturation     │ facturation │ ✔          │     3.9s │
├──────────┼─────────────────┼─────────────┼────────────┼──────────┤
│ Case 2   │ technique       │ technique   │ ✔          │     2.9s │
├──────────┼─────────────────┼─────────────┼────────────┼──────────┤
│ Case 3   │ compte          │ compte      │ ✔          │     3.2s │
├──────────┼─────────────────┼─────────────┼────────────┼──────────┤
│ Case 4   │ commercial      │ commercial  │ ✔          │     3.6s │
├──────────┼─────────────────┼─────────────┼────────────┼──────────┤
│ Case 5   │ autre           │ commercial  │ ✗          │     2.9s │
├──────────┼─────────────────┼─────────────┼────────────┼──────────┤
│ Case 6   │ facturation     │ facturation │ ✔          │     2.0s │
├──────────┼─────────────────┼─────────────┼────────────┼──────────┤
│ Case 7   │ technique       │ technique   │ ✔          │     2.5s │
├──────────┼─────────────────┼─────────────┼────────────┼──────────┤
│ Case 8   │ compte          │ compte      │ ✔          │     2.4s │
├──────────┼─────────────────┼─────────────┼────────────┼──────────┤
│ Case 9   │ commercial      │ commercial  │ ✔          │     1.6s │
├──────────┼─────────────────┼─────────────┼────────────┼──────────┤
│ Case 10  │ technique       │ facturation │ ✗          │     1.8s │
├──────────┼─────────────────┼─────────────┼────────────┼──────────┤
│ Case 11  │ commercial      │ commercial  │ ✔          │     2.0s │
├──────────┼─────────────────┼─────────────┼────────────┼──────────┤
│ Case 12  │ autre           │ autre       │ ✔          │     1.7s │
├──────────┼─────────────────┼─────────────┼────────────┼──────────┤
│ Averages │                 │             │ 83.3% ✔    │     2.5s │
└──────────┴─────────────────┴─────────────┴────────────┴──────────┘
SHELL

Le cas 10 : « Impossible de télécharger ma facture depuis l'espace client, le bouton ne répond pas ». On est plutôt dans l'ordre du problème technique ici, mais Luna a choisi facturation. La question peut paraître un peu ambiguë, c'est fait exprès (c'est très filou, je sais).

Le cas 5 : « Recrutez-vous des alternants en développement pour la rentrée ? » Aucune de nos catégories ne parle de recrutement en soi. Et peut-être que si nos instructions étaient plus précises, il l'aurait rangé dans autre.

Donc testez avec de meilleures instructions afin de garder des réponses fiables et un modèle peu coûteux.

Pour l'exemple ici, nous allons modifier le modèle utilisé en prenant Terra, l'intermédiaire de la gamme : IA_MODELE=openai:gpt-5.6-terra (dans le fichier .env).

                  Evaluation Summary: categoriser
┏━━━━━━━━━━┳━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━┳━━━━━━━━━━━━┳━━━━━━━━━━┓
┃ Case ID  ┃ Expected Output ┃ Outputs     ┃ Assertions ┃ Duration ┃
┡━━━━━━━━━━╇━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━╇━━━━━━━━━━━━╇━━━━━━━━━━┩
│ Case 1   │ facturation     │ facturation │ ✔          │     2.5s │
├──────────┼─────────────────┼─────────────┼────────────┼──────────┤
│ Case 2   │ technique       │ technique   │ ✔          │     2.5s │
├──────────┼─────────────────┼─────────────┼────────────┼──────────┤
│ Case 3   │ compte          │ compte      │ ✔          │     2.5s │
├──────────┼─────────────────┼─────────────┼────────────┼──────────┤
│ Case 4   │ commercial      │ commercial  │ ✔          │     2.9s │
├──────────┼─────────────────┼─────────────┼────────────┼──────────┤
│ Case 5   │ autre           │ autre       │ ✔          │     2.7s │
├──────────┼─────────────────┼─────────────┼────────────┼──────────┤
│ Case 6   │ facturation     │ facturation │ ✔          │     1.7s │
├──────────┼─────────────────┼─────────────┼────────────┼──────────┤
│ Case 7   │ technique       │ technique   │ ✔          │     2.0s │
├──────────┼─────────────────┼─────────────┼────────────┼──────────┤
│ Case 8   │ compte          │ compte      │ ✔          │     2.4s │
├──────────┼─────────────────┼─────────────┼────────────┼──────────┤
│ Case 9   │ commercial      │ commercial  │ ✔          │     2.0s │
├──────────┼─────────────────┼─────────────┼────────────┼──────────┤
│ Case 10  │ technique       │ technique   │ ✔          │     3.2s │
├──────────┼─────────────────┼─────────────┼────────────┼──────────┤
│ Case 11  │ commercial      │ commercial  │ ✔          │     2.3s │
├──────────┼─────────────────┼─────────────┼────────────┼──────────┤
│ Case 12  │ autre           │ autre       │ ✔          │     2.8s │
├──────────┼─────────────────┼─────────────┼────────────┼──────────┤
│ Averages │                 │             │ 100.0% ✔   │     2.5s │
└──────────┴─────────────────┴─────────────┴────────────┴──────────┘
SHELL

Sans modifier les instructions, sans-faute pour Terra ! Mais attention, comme Terra est plus coûteux, essayez d'abord de préciser vos instructions avec Luna. Et surtout, on vient de tester sur 12 demandes : c'est trop peu, il faudrait un jeu de test plus conséquent réellement.

Bravo, tu es prêt à passer à la suite

Rechercher sur le site

Inscris-toi à Docstring

Pour commencer ton apprentissage.

Tu as déjà un compte ? Connecte-toi.