TP 1 - Accès direct à l'API d'un LLM, avec Jakarta EE et en particulier JSF

Ce TP reprend l'interface utilisateur Web du TP 0. Cette fois-ci le serveur répond à la question tapée par l'utilisateur en l'envoyant à un LLM et en retournant la réponse du LLM. L'interface utilisateur Web affiche cette réponse.

Ce TP vous montre comment accéder directement aux endpoints REST d'un LLM avec le format JSON pour le corps des requêtes et des réponses HTTP.

Support de cours

Ressources sur l'API du LLM

Pour ce TP, Gemini a été choisi.

Si vous êtes intéressé, voici des ressources pour OpenAI ; vous pourrez aussi trouver une correction pour OpenAI.

Informations pratiques sur les APIs

Gemini

Vous allez utiliser l'API de Gemini dans les TPs suivants. Il faut commencer par récupérer une clé secrète l'API de Gemini.

OpenAI

Si vous voulez travailler plus tard avec OpenAI, il faut commencer par récupérer une clé secrète pour l'API d'OpenAI.

Avant de commencer d'utiliser l'API de OpenAI, il est important de prendre connaissance sur les coûts de cette utilisation.

Création de l'application

Comme pour le TP 0, créez une application Web avec Maven. Si vous utilisez IntelliJ, changez le nom de la configuration d'exécution comme dans le TP 0.

Copiez les fichiers suivants du TP 0 :

  • Page JSF index.xhtml, avec les fichiers Javascript et CSS.
  • Classe Bb.java.
  • Filtre pour les accents, avec sa déclaration dans web.xml.

Modifiez pom.xml et web.xml comme vous l'avez fait dans le TP 0.

N'oubliez pas d'utiliser Git et GitHub quand il le faut, et de nommer votre projet et vos packages comme il a été demandé dans le TP 0 (avec votre nom).

Description de l'application

L'application que vous allez écrire reproduit les actions de base d'une application comme ChatGPT.

L'utilisateur pose des questions et l'application lui donne des réponses. Il peut voir la structure des documents JSON échangés avec l'API de Gemini, s'il choisit le mode "debug".

Libre à vous d'étoffer cette application avec des fonctionnalités supplémentaires ou bien d'utiliser l'API dans d'autres applications que vous aurez écrites. Si vous avez de l'imagination, je pense que vous pourrez faire de belles choses.

Interface utilisateur Web de l'application

Voici à quoi va ressembler l'interface utilisateur de votre application (en mode "debug", avec le JSON des requêtes affiché ; l'API d'OpenAI a été utilisée pour cette copie d'écran ; c'est très semblable avec Gemini, excepté le contenu des textareas du mode debug) :

Interface de l'application "ChtGPT"

Vous retrouvez l'interface utilisateur du TP 0, avec des textareas pour la question de l'utilisateur, la réponse donnée par le serveur et la conversation entre le serveur et l'utilisateur depuis le début de la session. Vous aurez 2 textareas en plus si l'utilisateur veut passer en mode "debug" (ou en mode "normal").

Un nouveau bouton permet d'activer ou non le mode "debug".

Code de l'application

L'application n'utilisera pas de librairie pour Gemini. Il n'existe pas à ce jour de librairie Java officielle pour faciliter l'utilisation de l'API de Gemini, ni non plus pour OpenAI. L'intérêt de ce TP est de bien comprendre comment l'API de Gemini fonctionne. L'utilisation d'une librairie aurait caché les détails du fonctionnment. Toutes les APIs des LLMs se ressemblent, mais sans être exactement les mêmes, d'où l'intérêt de LangChain4j que vous utiliserez dans les autres TPs. Ce sont des APIs REST avec utilisation de JSON pour les formats des requêtes et des réponses.

Le code crée un client REST qui va envoyer des requêtes REST à l'API. L'application extrait la réponse à afficher à l'utilisateur de la réponse de l'API. Le mode "debug" permet de voir le format des documents JSON échangés.

Comme pour le TP 0, cette application n'aura qu'une seule page JSF. Elle permettra à l'utilisateur de poser ses questions et de voir les réponses de l'API.

Cette page sera épaulée par un backing bean. Vous allez reprendre le backing bean du TP 0 mais, cette fois-ci, le serveur va faire appel à l'API du LLM pour retourner une réponse plus utile que ce qui a été fait dans le TP 0.

Les backing beans sont faits pour épauler les pages JSF. S'ils doivent effectuer un travail qui n'est pas lié à l'interface utilisateur, la bonne architecture est de déléguer ce travail à d'autres classes. C'est ce que vous allez faire pour ce TP avec les 2 classes JsonUtil et LlmClient.

Interface utilisateur

Page index.xhtml

La page index.xhtml doit être modifiée pour ajouter les 2 textareas du mode debug ainsi que le bouton qui permet d'activer ou non le mode debug.

Dans le code de la page, dans "panelgauche" et juste après le panel "questionetreponse", ajoutez ce panel "debug". Vous remarquerez que ce code utilise 2 propriétés texteRequeteJson et texteReponseJson que vous devez ajouter dans le backing bean (vous devez ajouter un getter et un setter par chacune de ces propriétés ; vous devrez aussi ajouter une variable d'instance pour chacune de ces propriétés).

Ajoutez le code pour le bouton à la fin du panel "questionetreponse" :

<h:commandButton id="debugbutton" value="#{bb.debug?'Mode Normal':'Mode Debug'}"
                 action="#{bb.toggleDebug()}"/>

Backing bean

On voit dans le code du bouton ci-dessus qu'il faut ajouter dans le backing bean une propriété debug de type boolean (ajoutez un getter isDebug et un setter setDebug ; vous verrez qu'il faudra aussi ajouter une variable d'instance) et une méthode toggleDebug() :

public void toggleDebug() {
  this.setDebug(!isDebug());
}

Il faudra aussi remplacer l'action effectuée par le serveur pour retourner une réponse à l'utilisateur. Cette fois-ci la réponse doit être fournie par l'API du LLM. Comme expliqué ci-dessous, le backing bean va utiliser la classe JSonUtil qui, elle-même, utilisera la classe LlmClient. En fait, vous aurez 2 versions de ces classes ; pour Gemini, les 2 classes se nomment JSonUtilPourGemini et LlmClientPourGemini.

Classe JSonUtil

La difficulté de ce TP vient essentiellement de la manipulation du JSON :

  1. La question posée par l'utilisateur doit être insérée dans un texte JSON.
  2. Ce texte JSON est ensuite mis dans la requête envoyée à l'API du LLM.
  3. La réponse fournie par l'API est au format JSON.
  4. La réponse à afficher à l'utilisateur doit être extraite de ce texte JSON.

Comme le format JSON dépend du LLM, il est préférable d'isoler le traitement du JSON dans une classe JsonUtil à part. Le reste dépendra beaucoup moins du choix de l'API, ce qui facilitera un éventuel changement d'API.

Voici le code de la classe JsonUtil. (en fait JsonUtilPourGemini ; il y a aussi une autre version pour OpenAI)

Cette classe contient les méthodes suivantes :

  • LlmInteraction envoyerRequete(String question). C'est la seule méthode public et c'est la seule que vous allez utiliser par ailleurs. Elle sera appelée par le backing bean pour envoyer la question de l'utilisateur à l'API du LLM. Les autres méthodes sont private et vous pouvez les ignorer, sauf si vous êtes intéressé par la manipulation du JSON ; elles sont utilisées par cette méthode envoyerRequete. Voyez comment cette méthode lance une exception pour le cas où il y aurait eu un problème avec la requête à l'API.
    Cette méthode retourne une instance du record Java LlmInteraction que vous devez créer. Si vous ne connaissez pas le type record introduit par Java 16, consultez, par exemple, ce lien ; un record facilite la création et l'utilisation de classe dont le but est de représenter de manière concise des objets simples, souvent utilisés comme des conteneurs de données. Si l'application n'affichait pas les textes JSON des requêtes et réponses à l'API du LLM, la méthode envoyerRequete aurait retourné tout simplement la réponse de l'API sous la forme de String. Le record LlmInteraction permet de retourner de façon simple et concise le JSON de la requête et de la réponse en même temps que la réponse. Pour découvrir les champs/membres que vous devez mettre dans ce record, étudiez ci-dessous comment le record est utilisé ; il doit contenir 3 membres.
  • creerRequeteJson met le "rôle système" dans un texte JSON, ainsi que la 1ère question posée par l'utilisateur. Le rôle système va être envoyé à l'API du LLM pour lui dire comment se comporter. Pour cet exercice, 3 rôles sont pré-créés : "helpful assistant" (rôle par défaut), "traducteur anglais-français" et "guide touristique". Voir le code du backing bean pour le détail de ces rôles système.
  • ajouteQuestionDansJsonRequete ajoute une question dans le texte JSON envoyé au LLM. Une application REST est sans état. Si on veut bavarder avec un LLM, il faut lui envoyer le début de la conversation.
  • extractReponse extrait la réponse à afficher à l'utilisateur du texte JSON retourné par l'API du LLM.
  • prettyPrinting permet d'afficher un texte JSON d'une façon plus lisible.

Le code de ces méthodes utilise la spécification "Jakarta JSON Processing" de Jakarta EE.

Utilisation de la classe par le backing bean

Le traitement du serveur utilise la classe JsonUtil avec l'appel de la méthode envoyerRequete à qui on passe la question posée par l'utilisateur.

Voici le code de cet appel (que vous devez mettre au bon endroit dans le backing bean) :

try {
  LlmInteraction interaction = jsonUtil.envoyerRequete(question);
  this.reponse = interaction.reponseExtraite();
  this.texteRequeteJson = interaction.questionJson();
  this.texteReponseJson = interaction.reponseJson();
} catch (Exception e) {
   FacesMessage message = 
       new FacesMessage(FacesMessage.SEVERITY_ERROR,
                        "Problème de connexion avec l'API du LLM", 
                        "Problème de connexion avec l'API du LLM" + e.getMessage();
   facesContext.addMessage(null, message);
}

Classe LlmClient

Elle gère l'interface avec l'API du LLM. Cette classe récupère la clé secrète et envoie une requête à l'API du LLM.

La clé secrète est passée en paramètre de l'URL du endpoint (pour OpenAI, elle est placée dans le header "Authorization", avec la valeur "Bearer <la clé secrète>"). Ajoutez une variable d'environnement dans votre système d'exploitation ; vous pouvez, par exemple l'appeler GEMINI_KEY. La clé est récupérée dans le constructeur de la classe et utilisée au début de la méthode envoyerRequete qui envoie les questions à l'API. C'est un bon moyen pour que sa valeur n'apparaisse pas dans votre code source. Pensez que ce code va être partagée par tous les membres de l'équipe de développement et que vous allez pousser le code sur GitHub, dans un entrepôt peut-être public, ou peut-être visible par des personnes de GitHub.

Elle utilise la clé secrète que vous avez récupérée sur votre compte chez OpenAI. Vous devez copier sa valeur dans la variable d'environnement de votre système d'exploitation LLM_KEY (vous pouvez choisir un autre nom si vous voulez) et elle sera lue dans le constructeur de la classe.

Voici un squelette de code pour la classe LlmClient. Vous avez 2 endroits à compléter, juste pour dire que vous avez participé. ;-)

Classe RequeteException

Ecrivez une classe d'exception très simple. Regardez comment elle est utilisée dans la méthode envoyerRequete de JsonUtil et dans le backing bean pour savoir quelles méthodes et constructeurs y mettre.

A vous de jouer maintenant pour comprendre ce code et en tirer parti pour terminer votre application. Vous n'avez qu'à modifier le traitement effectué par le serveur pour répondre à la question de l'utilisateur.

Exécution de l'application

Lancez l'exécution.

Si tout va bien commit et push.

Ensuite, si tout va bien, modifiez le code en 2 temps (testez après chaque modification) pour provoquez des exceptions (ne faites pas de commit) :

  • Modifiez votre clé dans le code de LlmClient.
  • Remplacez le rôle "user" dans creerRequeteJson par le rôle "system".

Dans l'email que vous enverrez quand vous aurez fini le TP, copiez les 2 messages d'erreur qui vont s'afficher.

Repassez au code avant les modifications en utilisant Git.

Exercice pour avoir un bonus pour ce TP

Ajoutez un rôle système. Faites preuve d'imagination. Vous pouvez demander au LLM de répondre sur un certain ton ou bien tout autre chose. Dans l'email que vous allez envoyer, expliquez le rôle système que vous avez imaginé et copiez une conversation que vous avez eu avec ce rôle système (limitez-vous à 2 échanges).

Correction

Pour Gemini :

Page JSF (n'oubliez pas les fichiers JavaScript et CSS)
Backing bean
JsonUtil
LlmClient
RequeteException

Correction pour OpenAI :

Page JSF (n'oubliez pas les fichiers JavaScript et CSS)
Backing bean
JsonUtil
LlmClient
RequeteException

Retour TPs