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
À 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")
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
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-...
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}"
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
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
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 """
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.') """
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()
Et le __init__.py existant :
# boreal/__init__.py from .celery import app as celery_app __all__ = ("celery_app",)
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"])
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}), }
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
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"), ]
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>
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
À 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
Allons maintenant sur l'URL http://127.0.0.1:8000/support/.
Je vais envoyer une demande :
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
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.")
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
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
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
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
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
Vous pouvez les lancer depuis le terminal avec la commande pytest.
-
agent_tri.override(model=...)remplace le modèle de l'agent dans le blocwith -
TestModelrenvoie la réponse que nous voulons viacustom_output_args -
FunctionModelpermet 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)
-
categoriser()appelle le vrai modèle -
Caseprend un message du jeu de test en entrée et la catégorie attendue en réponse -
EqualsExpectedfait 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" } ]
É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 │ └──────────┴─────────────────┴─────────────┴────────────┴──────────┘
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 │ └──────────┴─────────────────┴─────────────┴────────────┴──────────┘
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.