Home / Articles

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.

Publisher: REACTAPP.TOP10 min read2331 words

This article is published in English.

Sources: Trier les tickets avec Jev, appliquer ses règles en Python et laisser le LLM préparer la réponse : un tutoriel concret avec NOVA.

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 se nomme NOVA. Il s'agit d'un assistant de premier niveau pour le support d'Atelier Nova, une enseigne inventée 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 expliquent seulement les parties centrales. Pour tout exécuter, il suffit d'ouvrir le notebook et de lancer les cellules dans l'ordre.

Jev décide, un autre modèle rédige

Dans ce montage, Jev, développé par TypeSafe, prend des décisions structurées à partir d'une situation décrite en langage naturel. Rien ne lui est demandé en matière de réponse au client. Les questions qu'on lui pose ressemblent plutôt à ceci :

  • Quel service doit prendre le ticket ?
  • Les faits rapportés exigent-ils une prise en charge immédiate ?
  • Quel degré de frustration le client exprime-t-il ?

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 chemin 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 passe en amont de la boucle de l'agent, de sorte que son exécution ne dépend jamais d'un éventuel choix d'outil fait 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 versée dans le contexte de l'agent, à côté de deux outils de lecture seule.

Aucune action n'est confiée à NOVA : pas d'envoi de message, pas de remboursement, pas de modification de commande. Son rôle consiste à préparer le travail des agents humains, et ce périmètre étroit 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 mieux vaut conserver les versions de la cellule d'installation pour reproduire l'exemple.

Deux clés API sont nécessaires, à ranger 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

Ce fichier ne doit jamais être publié sur GitHub, et les clés ne doivent pas apparaître dans les sorties du notebook. Une fois les variables d'environnement chargées, les deux modèles s'initialisent ainsi :

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 réglages sont ceux du notebook, et rien n'oblige à employer ce LLM ailleurs. L'alias jev-latest peut également évoluer. Pour comparer des résultats dans le temps, il vaut mieux conserver la version exacte du modèle quand c'est possible.

Trois primitives : Choice, Noul et Score

Le plus simple pour comprendre Jev est de partir des questions qui lui sont posées.

Choice : sélectionner un service

Choice choisit une catégorie parmi celles que l'on définit. Ici elles sont au nombre de quatre : livraison, facturation, technique et autre. Chacune porte une description qui précise ce que doit capter le classement. Un colis en retard va à la livraison, un paiement contesté à la facturation, un appareil en panne au support technique. La catégorie autre accueille les demandes ambiguës ou hors périmètre, au lieu de les forcer dans un service qui ne convient pas.

Noul : une question oui ou non

Noul renvoie la probabilité d'une réponse positive, entre 0 et 1. La question posée est de savoir si les faits décrits demandent une prise en charge immédiate plutôt qu'un traitement ordinaire. L'instruction précise qu'il ne faut pas se fier au seul ton, car un client peut être très mécontent sans que sa situation exige une intervention immédiate, et une demande calme peut au contraire être urgente. Le résultat reste une estimation tirée du contexte fourni. Il ne dit pas si un colis arrivera demain tant que des informations fiables sur la livraison manquent.

Score : placer la frustration sur une échelle

Avec Score, des niveaux ordonnés sont définis :

  • niveau bas : aucun signe d'insatisfaction ;
  • niveau intermédiaire : de l'agacement, exprimé avec retenue ;
  • niveau haut : 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 calme ou énervé. Les trois questions sont réunies dans 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'aller chercher la réponse correspondante dans le code. Ce qui est évalué se joue dans les instructions et dans les 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 ?

Côté préparation, requete_triage assemble le message et son contexte, en plafonnant sa longueur. Seuls les trois derniers messages du client dans l'historique partent vers Jev, coupés si nécessaire. L'appel puis la lecture des résultats ressemblent à ce qui 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 viennent d'une exécution sauvegardée et ne garantissent rien pour les appels suivants.

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 que le dispositif soit infaillible. Chez TypeSafe, cette valeur découle de la répartition des probabilités entre les réponses possibles. Elle indique à quel point le modèle favorise une réponse, pas si celle-ci a été confrontée aux faits.

Les règles métier restent dans le programme

Avant tout usage 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 la 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 juste.

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 cohabiter avec une revue humaine. La priorité dépend ici du signal d'urgence, tandis que la frustration peut déclencher la relecture. Les deux dimensions sont traitées séparément par choix.

Des informations à consulter

La démonstration puise dans un simple dictionnaire de fausses commandes. AN-1001 y désigne une lampe de bureau encore en transit, qui accuse trois jours de retard. L'agent dispose de deux outils :

  • consulter_commande(numero) retrouve une commande à partir de sa référence ;
  • consulter_procedure(service) lit la procédure liée au service retenu.

La procédure de livraison demande de regarder le statut, de proposer une vérification par l'équipe logistique en cas de retard et de ne jamais inventer de date de livraison. En facturation, tout remboursement passe par une validation humaine. En technique, l'agent doit relever le produit concerné, le dysfonctionnement constaté et les essais déjà faits.

Le prompt de NOVA expose sa mission et fixe un format de sortie : résumé de la difficulté, informations vérifiées ou manquantes, prochaine action conseillée et brouillon de réponse. Écrire dans un prompt qu'il est interdit de rembourser ne suffit pourtant pas à protéger un outil de paiement. La restriction est donc aussi matérielle : aucun outil de remboursement n'est exposé à l'agent.

Assemblage de l'agent LangChain

La fonction create_agent reçoit le LLM, les outils et 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 glisse dans les instructions de base. La fonction traiter_ticket orchestre le tout : elle appelle Jev, normalise l'analyse, calcule l'orientation, construit 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, mieux vaut l'inscrire dans le workflow que de compter sur une simple consigne.

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 seule : le programme a simplement redonné les messages précédents. Pour un autre client, il faut repartir d'un historique distinct, car mélanger les conversations exposerait les informations d'une personne à une autre.

Avant un vrai déploiement

La démo travaille sur des données fictives et se limite à des brouillons. Avant de la brancher sur un vrai support, plusieurs étapes s'imposent :

  • évaluer le triage sur des tickets annotés, y compris en français, puis décider du comportement à adopter si Jev ou le LLM ne répond pas ;
  • vérifier qui a le droit de lire quelles commandes, et cloisonner 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 actionner un circuit d'approbation effectif.

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 le classement et l'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.