NOVA : trier des tickets avec Jev, des règles Python et un agent LangChain
Un assistant de support de niveau 1 qui sépare l’analyse du ticket avec Jev, les règles métier en Python et la rédaction d’un brouillon par un agent LangChain.
Un client écrit : « Ma commande n'est toujours pas arrivée. Cela fait déjà trois relances, et il la faut pour demain. » Avant de rédiger une réponse, un agent de support doit trancher plusieurs questions. À quelle équipe revient la demande ? Faut-il la traiter sans attendre ? Le ton trahit-il une vraie exaspération ? Et surtout, que peut-on réellement vérifier au sujet de cette commande ?
Un seul gros prompt confié à un LLM pourrait tout gérer. Une autre approche découpe le travail en trois temps : Jev lit le ticket et en tire une analyse, du code Python applique les règles de l'entreprise, puis un agent LangChain va chercher des informations avec ses outils et compose un brouillon.
Le projet décrit ici s’appelle NOVA. Il s’agit d’un assistant de premier niveau pour le support d’Atelier Nova, une enseigne fictive qui vend des accessoires de bureau. Le code complet et le notebook se trouvent dans le dépôt GitHub Langchain-Jev, et les extraits qui suivent ne présentent que les parties essentielles. Pour faire fonctionner tout le système, il suffit d’ouvrir le notebook et d’exécuter les cellules dans l’ordre.
Jev prend la décision, un autre modèle rédige
Dans ce système, Jev, développé par TypeSafe, prend des décisions structurées à partir d’une description en langue naturelle. Aucune réponse au client n’est requise de sa part. Les questions qui lui sont posées ressemblent plutôt à celles-ci :
- Quel service doit gérer ce ticket ?
- Les faits rapportés nécessitent-ils une intervention immédiate ?
- Quel niveau de frustration exprime le client ?
Les réponses arrivent dans des champs typés que le programme lit directement. Le LLM génératif n'intervient qu'ensuite, pour consulter les informations disponibles et préparer un texte adapté.
L'intérêt de cette séparation tient à la visibilité des règles. Changer le seuil qui déclenche une relecture humaine revient à modifier une condition Python, sans réécrire tout le prompt de l'agent. En revanche, rien ne prouve à lui seul que l'ensemble gagne en rapidité ou en coût. Seule une mesure du workflow complet, appel à Jev et appels au LLM compris, permettrait de l'affirmer.
Le parcours d'un ticket avant d'écrire du code
Voici le parcours suivi par un message.
Message client + historique
↓
Jev : service, urgence, frustration
↓
Python : validation et règles métier
↓
Agent LangChain + LLM
↓
Consultation des commandes et procédures
↓
Dossier pour l’équipe support + brouillon de réponse
Jev s’exécute avant le cycle de l’agent, de sorte que son exécution ne dépend jamais d’un éventuel choix d’outil effectué par le LLM. Le programme calcule ensuite une orientation composée du service retenu, de la priorité du ticket et de la nécessité d’une revue humaine. Cette orientation est intégrée dans le contexte de l’agent, aux côtés de deux outils de lecture seule.
Aucune action n’est confiée à NOVA : ni envoi de message, ni remboursement, ni modification de commande. Son rôle consiste à préparer le travail des agents humains, et ce périmètre restreint est délibéré.
Mise en place de l’environnement
Le notebook s’appuie notamment sur langchain, langchain-core, langchain-openai, langchain-typesafe et python-dotenv. L’intégration TypeSafe est figée à la version 0.0.1a3, et il est préférable de conserver les versions de la cellule d’installation pour reproduire l’exemple.
Deux clés API sont nécessaires, à placer dans un fichier .env :
TYPESAFE_API_KEY=ta_cle_typesafe
OPENAI_API_KEY=ta_cle_openai
JEV_MODEL=jev-latest
OPENAI_MODEL=gpt-4.1-mini
Celui-ci ne doit en aucun cas être publié sur GitHub, et les clés ne doivent pas apparaître dans les résultats du notebook. Une fois les variables d’environnement chargées, les deux modèles s’initialisent comme suit :
jev = TypeSafeClassifier(
model=os.getenv("JEV_MODEL") or "jev-latest",
timeout=30,
)
modele = ChatOpenAI(
model=os.getenv("OPENAI_MODEL") or "gpt-4.1-mini",
timeout=60,
max_retries=1,
)
Ces paramètres correspondent à ceux du notebook, et rien ne force à utiliser ce LLM ailleurs. L’alias jev-latest peut également changer. Afin de comparer les résultats dans le temps, il est préférable de conserver la version exacte du modèle lorsque c’est possible.
Trois primitives : Choice, Noul et Score
Le moyen le plus simple pour comprendre Jev est de partir des questions qui lui sont posées.
Choice : sélectionner un service
Choice sélectionne une catégorie parmi celles que l’on définit. Ici, il y en a quatre : livraison, facturation, technique et autre. Chacune est accompagnée d’une description précisant ce que doit couvrir la classification. Un colis en retard est classé dans la catégorie livraison, un paiement contesté dans facturation, un appareil en panne dans support technique. La catégorie autre regroupe les demandes ambiguës ou hors champ, afin de ne pas les forcer dans un service qui ne leur convient pas.
Noul : une question oui ou non
Noul indique la probabilité d’une réponse positive, comprise entre 0 et 1. La question posée est de déterminer si les faits décrits exigent une prise en charge immédiate plutôt qu’un traitement ordinaire. L’instruction précise qu’il ne faut pas se fier uniquement au ton, car un client peut être très mécontent sans que sa situation nécessite une intervention immédiate, tandis qu’une demande calme peut au contraire être urgente. Le résultat reste une estimation basée sur le contexte fourni. Il n’indique pas si un colis arrivera demain, faute d’informations fiables concernant la livraison.
Score : évaluer le degré de frustration
Avec Score, des niveaux ordonnés sont définis :
- niveau bas : aucun signe d’insatisfaction ;
- niveau intermédiaire : un certain agacement, exprimé avec retenue ;
- niveau élevé : colère marquée ou réclamations qui s’accumulent.
L’échelle utilisée couvre l’intervalle de 0 à 2, avec des valeurs décimales possibles : la réponse n’est pas une simple étiquette indiquant calme ou énervé. Les trois questions sont regroupées au sein d’une seule définition.
from langchain_typesafe import Choice, Noul, Score
def questions_triage():
return {
"service": Choice(
instructions=(
"Quel service doit traiter en priorité la dernière demande du client ? "
"Utilise le contexte seulement pour comprendre cette demande."
),
criteria={
"livraison": "Retard, suivi ou réception d'une commande.",
"facturation": "Paiement, facture ou remboursement.",
"technique": "Panne ou utilisation d'un produit.",
"autre": "Demande ambiguë ou sans rapport avec les catégories précédentes.",
},
),
"urgence": Noul(
instructions=(
"Les faits décrits nécessitent-ils une prise en charge immédiate, "
"plutôt qu'un traitement normal ? Ne te fonde pas seulement sur le ton."
)
),
"frustration": Score(
instructions="Quel niveau de frustration le client exprime-t-il ?",
criteria=[
"Le client s'exprime calmement, sans insatisfaction.",
"Le client exprime une insatisfaction tout en restant mesuré.",
"Le client exprime une forte colère ou des réclamations répétées.",
],
),
}
Chaque clé (service, urgence, frustration) permet d’obtenir la réponse correspondante dans le code. Ce qui est évalué dépend des instructions et des critères associés.
Un premier ticket à analyser
Le message utilisé dans le notebook est le suivant : AN-1001 n’est toujours pas livré, il s’agit de la 3e relance, il le faut pour demain, que peut-on faire ?
En ce qui concerne la préparation, la fonction requete_triage assemble le message ainsi que son contexte, en limitant sa longueur. Seuls les trois derniers messages du client dans l’historique sont envoyés à Jev, éventuellement tronqués si nécessaire. L’appel ainsi que la lecture des résultats se déroulent comme suit.
reponse_jev = jev.invoke(requete_triage(ticket))
print("Service :", reponse_jev.choices["service"].choice)
print("Urgence :", reponse_jev.nouls["urgence"].noul)
print("Frustration sur 2 :", reponse_jev.scores["frustration"].score)
La sortie enregistrée dans le notebook donne :
Service : livraison
Urgence : 0.76
Frustration sur 2 : 1.93
Ces valeurs proviennent d’une exécution sauvegardée et ne garantissent rien pour les appels ultérieurs.
Un autre champ mérite attention : la confiance. Dans le premier exemple, le classement en livraison reçoit une confiance de 1,0, ce qui ne signifie pas pour autant que le dispositif soit infaillible. Chez TypeSafe, cette valeur résulte de la répartition des probabilités entre les réponses possibles. Elle indique dans quelle mesure le modèle privilégie une réponse, et non si celle-ci correspond aux faits.
Les règles métier restent intégrées au programme
Au préalable de toute utilisation des résultats, normaliser_analyse isole les champs nécessaires et vérifie notamment que le service figure bien dans les procédures et que le niveau de frustration respecte l’échelle prévue. Le résultat est un dictionnaire Python facile à lire. Le score de frustration n’est pas converti vers une autre échelle, et rien ici ne démontre que l’analyse est exacte.
Les règles s’appliquent ensuite :
def orienter_ticket(analyse, seuil_urgence=0.8, seuil_confiance=0.6):
raisons = []
if analyse["urgence"] >= seuil_urgence:
raisons.append("urgence élevée")
if analyse["frustration"] >= 1.5:
raisons.append("forte frustration")
if analyse["confiance_service"] < seuil_confiance:
raisons.append("service incertain")
if analyse["service"] == "autre":
raisons.append("demande à clarifier") return {
"service": analyse["service"],
"priorite": "haute" if analyse["urgence"] >= seuil_urgence else "normale",
"revue_humaine": bool(raisons),
"raisons": raisons or ["traitement courant"],
}
Les seuils sont pédagogiques. Une équipe réelle les calibrerait sur un échantillon de tickets représentatifs, en relevant les erreurs d'aiguillage et les urgences passées inaperçues. L'orientation obtenue lors de l'essai complet enregistré est la suivante :
{
"service": "livraison",
"priorite": "normale",
"revue_humaine": true,
"raisons": ["forte frustration"]
}
Une priorité normale peut donc coexister avec une révision humaine. La priorité dépend ici du signal d’urgence, tandis que la frustration peut déclencher une relecture. Ces deux aspects sont traités séparément par choix.
Informations à consulter
La démonstration s’appuie sur un simple dictionnaire de commandes erronées. AN-1001 y désigne une lampe de bureau encore en transit, avec un retard de trois jours. L’agent dispose de deux outils :
- consulter_commande(numero) permet de retrouver une commande à partir de sa référence ;
- consulter_procedure(service) affiche la procédure correspondant au service choisi.
La procédure de livraison exige de vérifier le statut, de demander une vérification par l’équipe logistique en cas de retard et de ne jamais inventer de date de livraison. Pour la facturation, tout remboursement doit faire l’objet d’une validation humaine. Sur le plan technique, l’agent doit consigner le produit concerné, le dysfonctionnement constaté ainsi que les essais déjà effectués.
Le prompt de NOVA définit sa mission et impose un format de sortie : résumé de la difficulté, informations vérifiées ou manquantes, prochaine action recommandée et brouillon de réponse. Cependant, indiquer dans un prompt qu’un remboursement est interdit ne suffit pas à protéger un outil de paiement. La restriction est donc également matérielle : aucun outil de remboursement n’est mis à disposition de l’agent.
Assemblage de l’agent LangChain
La fonction create_agent reçoit le LLM, les outils ainsi qu’un prompt enrichi par l’analyse de Jev et l’orientation calculée.
def creer_agent(modele, analyse, orientation):
contexte = json.dumps(
{"analyse_jev": analyse, "orientation": orientation},
ensure_ascii=False,
)
return create_agent(
model=modele,
tools=[consulter_commande, consulter_procedure],
system_prompt=(
ROLE_NOVA
+ "\nContexte de traitement fourni par le programme :\n"
+ contexte
),
)
L’analyse et l’orientation ne sont donc pas des paramètres propres à create_agent, puisque la fonction maison les intègre dans les instructions de base. La fonction traiter_ticket coordonne tout : elle appelle Jev, normalise l’analyse, calcule l’orientation, crée l’agent et lance sa boucle avec une limite de récursion.
Le code impose l’ordre des étapes, mais l’appel aux outils de consultation pendant la boucle reste à la discrétion du LLM. Si une consultation doit devenir obligatoire en production, il vaut mieux l’inclure dans le workflow plutôt que de compter sur une simple instruction.
Ce que montrent les essais
Le journal conservé pour le premier ticket montre que l’agent a utilisé consulter_commande puis consulter_procedure. Il identifie la lampe, constate qu’elle est en transit et note les trois jours de retard. Il recommande une vérification par la logistique sans avancer de date de livraison inventée, puis rédige un brouillon pour l’équipe support.
Ce brouillon demande à être relu. Une formule comme « nous allons transmettre votre dossier » engage l’équipe sur une action que le programme n’a pas réalisée, et une recommandation ne vaut pas transfert effectué.
Le second essai porte sur une demande de suivi, où le client demande quel article contient cette commande. Il ne répète pas la référence, donc l’historique du premier traitement est transmis.
suivi = traiter_ticket(
"Quel article contient cette commande ?",
modele,
jev,
historique=dossier["messages"],
)
print(suivi["reponse"])
Dans la sortie enregistrée, la référence AN-1001 est retrouvée sans que le client la répète, et la réponse indique une lampe de bureau. Aucune mémoire persistante n’est apparue d’elle-même : le programme a simplement reproduit les messages précédents. Pour un autre client, il faut reprendre un historique distinct, car mélanger les conversations exposerait les informations d’une personne à une autre.
Avant un vrai déploiement
La démo fonctionne avec des données fictives et se limite à des brouillons. Avant de la connecter à un système réel, plusieurs étapes sont nécessaires :
- évaluer le tri des tickets annotés, y compris en français, puis déterminer la procédure à suivre en cas de non-réponse de Jev ou du LLM ;
- vérifier qui a le droit de lire quelles commandes, et séparer les conversations ;
- réduire au strict nécessaire les données personnelles qui partent vers les modèles ;
- conserver des traces exploitables sans exposer les clés API.
Le drapeau revue_humaine à true n'est ici qu'une information donnée à l'agent. Si le dispositif expédie réellement des messages ou accomplit des opérations sensibles, cette information doit déclencher un processus d'approbation réel.
Le principe à retenir dès maintenant est le partage des tâches. L'analyse structurée vient de Jev, la décision sur son emploi revient à Python, et le LLM s'occupe de rédiger la réponse en s'appuyant sur les outils. Pour aller plus loin, il suffit d'ajouter des cas variés : des tickets de facturation, une demande équivoque, un message poli mais pressant. On observe la classification et on vérifie que les règles mènent au traitement attendu, ce qui révèle à la fois les capacités de l'agent et ce qui reste à corriger.