Fermer

juin 6, 2024

Une plongée dans l’API OpenAI Assistant / Blogs / Perficient

Une plongée dans l’API OpenAI Assistant / Blogs / Perficient


Récemment, j’ai eu le temps de m’asseoir et de me plonger dans mon propre petit domaine du Far West numérique, celui de l’intégration de l’IA. Avec l’explosion de l’IA, je voulais donner à mes applications la possibilité d’exploiter ce vaste potentiel.

Même s’il semble que tous les géants de la technologie, startups et leurs mères développent une IA ces jours-ci, j’ai dû en choisir une contre laquelle je développerais. Je voulais quelque chose où je pourrais créer un modèle personnalisé ; dispose d’une API REST robuste ; et a enfin une expérience éprouvée (au moins aussi longue que possible dans un domaine aussi jeune). Compte tenu de ces critères que j’ai énoncés, OpenAI était à l’époque le meilleur fournisseur à cet effet. Plus précisément l’assistant personnalisé via plateforme.openai.com

Mis à part le contexte, voici ce que vous devrez suivre :

• Un compte financé sur platform.openai.com (je me suis débrouillé avec environ 2 $ US au cours des 8 derniers mois)

• Un assistant prêt à l’intérieur de quelque chose autre que le projet par défaut (nous ne faisons que du texte, vous n’avez donc pas besoin du dernier et du meilleur GPT pour une base. GPT-3 fera tout aussi bien le travail et vous fera économiser de l’argent en le processus)

• Une clé API du projet

Alors que la documentation de l’API (Référence API – API OpenAI) donne des exemples de commandes Python, Node.js et Curl, je suis une personne du genre Microsoft Stack, donc je veux pouvoir converser avec mon IA via C# (comme une personne raisonnable). J’ai commencé le processus en traduisant les commandes curl en appels HttpClient. Voici les appels nécessaires pour converser avec votre assistant. J’utiliserai des extraits en cours de route, mais je publierai le fichier complet à la fin de l’article. Alors, allons-y !

Créer le fil de conversation

Les fils de discussion sont la conversation. Un fil de discussion contient tous les messages (voir section Ajout de messages) qui sont exécutés (voir section Exécuter) afin de générer une nouvelle réponse. En plus des directives de votre IA, les fils de discussion peuvent être ensemencés avec les messages attendus de l’IA afin de fournir un plus grand contexte. Nous y reviendrons davantage lorsque nous arriverons aux messages.

Afin de commencer notre conversation avec notre ami numérique, nous devons d’abord lui faire savoir que nous voulons parler. Nous faisons cela en créant un fil de discussion et en récupérant un identifiant de fil de l’IA. Cette pièce d’identité est importante ! Il sera utilisé dans tous les appels ultérieurs, stockez-le ! La documentation pour créer un fil de discussion peut être trouvée ici : https://platform.openai.com/docs/api-reference/threads/createThread

Il y a quelques en-têtes à configurer avant de charger l’URI et de lancer la requête. Le premier est l’en-tête d’autorisation, il s’agit d’un simple schéma de jeton Bearer avec la clé API de votre projet comme jeton. Le second est un nouvel en-tête indiquant que nous nous connectons à la V2 de l’API Assistant Beta.

_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _apiKey);

_client.DefaultRequestHeaders.Add("OpenAI-Beta", "assistants=v2");

La prochaine chose à faire est de charger l’URI et de lancer une requête POST. Cette requête ne nécessite rien dans le corps, nous enverrons donc null :

response = _client.PostAsync("https://api.openai.com/v1/threads", null).Result;

Jusqu’ici, tout va bien. Rien que la plupart des développeurs n’aient fait des dizaines de fois. La partie délicate est que cela vient via la réponse. Malheureusement, chaque point final renvoie un modèle JSON différent. J’ai créé une série de modèles dans mon projet pour désérialiser chaque réponse en POCO, ce qui, à ce stade, me semble exagéré. J’aurais pu le faire via JObjects et économiser quelques dizaines de lignes de code.

var threadIdResponse = response.Content.ReadAsStringAsync().Result;

if (!response.IsSuccessStatusCode)
{
     var errorResponse = JsonConvert.DeserializeObject<ErrorResponse>(threadIdResponse);
     throw new AiClientException(errorResponse?.Error.Message);
}
var threadIdObj = JsonConvert.DeserializeObject<ThreadResponse>(threadIdResponse);
_threadId = threadIdObj?.Id ?? string.Empty;
return _threadId;

Nous avons ici la réponse, et il est temps de vérifier et d’analyser ce que nous avons reçu. Dans mon piège d’erreurs, j’ai une exception appelée AiClientException. Il s’agit d’une nouvelle exception que j’ai créée dans le projet qui enveloppe simplement l’exception pour une meilleure délimitation sur le client. Si nous obtenons une réponse réussie, nous la désérialisons en un objet Thread Response :

public class ThreadResponse
{
   public string Id { get; set; }
   public string Object { get; set; }
   public long CreatedAt { get; set; }
   public object AssistantId { get; set; }
   public string ThreadId { get; set; }
   public object RunId { get; set; }
   public string Role { get; set; }
   public List<AiContent> Content { get; set; }
   public List<object> FileIds { get; set; }
   public Metadata Metadata { get; set; }
}

Comme vous pouvez le constater, une quantité importante de données est renvoyée et nous n’en utiliserons pas. Ce qui nous intéresse à ce stade est le champ Id, il s’agit de l’ID du fil de discussion le plus important.

Nous avons maintenant créé un fil de discussion vide avec notre assistant. Ensuite, nous devons charger un message que l’IA doit lire, c’est également là que nous pouvons insérer des invites prédéfinies.

Ajout de messages

Les messages sont, bien sûr, le moteur de tout ce shebang. Sans eux, nous regardons simplement en silence la table avec notre assistant IA. Alors que le flux de conversation normal va d’invite -> réponse, comme nous le voyons lors de l’utilisation de l’interface d’un fournisseur avec une IA. Ici, nous ne sommes pas limités à un tel va-et-vient immédiat, nous sommes capables de charger plusieurs invites utilisateur ou de démarrer une conversation avec des invites utilisateur et des réponses d’assistant avant de les envoyer à l’IA pour une réponse générée.

La première chose à faire est la validation. À mesure que nous arrivons à ce stade du processus, un certain nombre d’éléments doivent déjà être en place afin d’ajouter des messages :

if (string.IsNullOrEmpty(_apiKey)) throw new AiClientException("OpenAI ApiKey is not set");
if (string.IsNullOrEmpty(_threadId)) CreateThread(); 
if (string.IsNullOrEmpty(message)) throw new AiClientException("Message is empty");

Ici, nous vérifions que nous disposons d’une clé API (pour l’en-tête Authentication) ; que nous avons un fil de discussion sur lequel mettre des messages, sinon, créez-en un ; vérifiez que nous avons un vrai message à ajouter. Ensuite, nous chargeons nos en-têtes comme nous l’avons fait auparavant, mais cette fois, nous devons sérialiser le message dans un objet.

_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _apiKey);
_client.DefaultRequestHeaders.Add("OpenAI-Beta", "assistants=v2");
var messageRequest = new AiRequestMessage { Role = "user", Content = message };

Ici, AiRequestMessage est une autre classe POCO que j’ai créée, simple pour aider à désérialiser la réponse. Pas grand chose à celui-ci :

public class AiRequestMessage
{
   [JsonProperty("role")]
   public string Role { get; set; }
   [JsonProperty("content")]
   public string Content { get; set; }
}

Une fois l’objet message créé, il nous suffit de le chaîner, de le charger dans notre requête et de l’envoyer. Il n’y a pas beaucoup d’informations utiles renvoyées par la demande. Un retour HTTP 200 indique que le message a été ajouté avec succès :

var json = JsonConvert.SerializeObject(messageRequest);
var content = new StringContent(json, Encoding.UTF8,   "application/json");
response = await _client.PostAsync($"https://api.openai.com/v1/threads/{_threadId}/messages", content);
            
var threadIdResponse = response.Content.ReadAsStringAsync().Result;

if (!response.IsSuccessStatusCode)
{
   var errorResponse = JsonConvert.DeserializeObject<ErrorResponse>(threadIdResponse);
   throw new AiClientException(errorResponse?.Error.Message);
}

Comme vous pouvez le constater, il s’agit d’un appel plutôt simple à l’API. Une fois que nous recevons la réponse du serveur, nous vérifions si cela a réussi, si c’est le cas, nous ne faisons rien ; sinon, nous envoyons une exception.

Maintenant que nous savons comment ajouter des messages utilisateur… Attendez, « Comment savons-nous qu’ils proviennent de l’utilisateur ? » pourriez-vous demander. C’est ici:

var messageRequest = new AiRequestMessage { Role = "user", Content = message };

Dans la propriété role, l’IA reconnaît deux valeurs : « user » et « assistant » et peu importe qui les ajoute à la liste. Il devient donc simple d’ajouter un argument ou une nouvelle fonction pour les messages de l’assistant qui simplifie et modifie la ligne ci-dessus afin qu’un message soit créé comme tel (ou un équivalent fonctionnel) :

var messageRequest = new AiRequestMessage { Role = "assistant", Content = message };

Grâce à cette capacité, nous sommes capables d’assembler (ou même de rappeler) une conversation avant même d’accéder à l’IA.

Pour l’instant, nous avons créé le conteneur de la conversation (thread) et notre côté de la conversation (messages) sur le serveur OpenAI. Maintenant, nous aimerions avoir des nouvelles de l’IA. C’est là qu’intervient la phase d’exécution du processus.

Courir

Nous sommes donc prêts à commencer à converser avec notre assistant, génial ! Si vous avez déjà travaillé avec une IA auparavant, vous savez que les temps de réponse peuvent être assez longs. Comment pouvons-nous surveiller cela depuis notre bibliothèque ? Personnellement, j’ai opté pour des sondages courts, comme vous le verrez ci-dessous. J’ai fait cela pour faciliter la mise en œuvre, mais d’autres méthodes sont disponibles, notamment l’ouverture d’un flux avec le serveur OpenAI, mais cela sort du cadre de cet article.

Comme pour les autres requêtes, nous devrons charger nos en-têtes :

_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _apiKey);
_client.DefaultRequestHeaders.Add("OpenAI-Beta", "assistants=v2");

Ensuite, nous avons besoin d’un corps de requête composé uniquement de l’ID de l’assistant. Là encore, j’ai créé un POCO pour faciliter la sérialisation/désérialisation, ce qui en fait probablement trop pour une seule propriété :

var custAsst = new Assistant { assistant_id = _assistantId };
var json = JsonConvert.SerializeObject(custAsst);
var content = new StringContent(json, Encoding.UTF8, "application/json");

Après avoir chargé cela dans le corps de notre demande et envoyé la demande, il est temps d’attendre. Les réponses de l’IA peuvent entraîner une attente assez longue pour une réponse en fonction de l’invite. Cela pourrait entraîner l’expiration d’une seule demande si l’IA met particulièrement longtemps à répondre. Ma solution à cela consistait à utiliser un court sondage vu ici :

response = await _client.PostAsync($"https://api.openai.com/v1/threads/{_threadId}/ru  ns", content);
var responseContent = await response.Content.ReadAsStringAsync();
var responseObj = JsonConvert.DeserializeObject<RunResponse>(responseContent);
var runId = responseObj?.Id;
var runStatus = responseObj?.Status;
//if not completed, poll again
if (runId != null)
{
   while (runStatus != null && !FinalStatuses.Contains(runStatus))
   {
       await Task.Delay(1000);
       response = await _client.GetAsync($"https://api.openai.com/v1/threads/{_threadId}/runs/{runId}");
       responseContent = response.Content.ReadAsStringAsync().Result;
       responseObj = JsonConvert.DeserializeObject<RunResponse>(responseContent);
       runStatus = responseObj?.Status;
        }
    }
}
await GetResponse();
Ici, j’ai les états terminés pour le processus Exécuter (https://platform.openai.com/docs/api-reference/runs/object) qui est vérifié à chaque sondage jusqu’à ce que le travail soit terminé. Après avoir reçu l’indicateur terminé, nous savons qu’il est possible de récupérer en toute sécurité les messages mis à jour qui devraient désormais inclure la réponse de l’assistant.

Obtenez une réponse de l’IA

Afin de récupérer le dernier message du serveur, nous devons rappeler le point de terminaison des messages pour le fil. Cela renverra tous les messages que nous avons envoyés au serveur, avec la dernière réponse. Tout d’abord, nous chargeons nos en-têtes et lançons une requête GET au point de terminaison des messages avec notre ID de fil dans l’URI :

HttpResponseMessage response;
using (_client = new HttpClient())
{
   _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _apiKey);
    _client.DefaultRequestHeaders.Add("OpenAI-Beta", "assistants=v1");
    response = await _client.GetAsync($"https://api.openai.com/v1/threads/{_threadId}/messages");
}

The response that is returned from this request is more complex that we've seen up to this point and requires a bit more handling in order to extract the messages:

var responseContent = response.Content.ReadAsStringAsync().Result;
try
{
  var data = JsonConvert.DeserializeObject<ChatResponse>(responseContent);
  _messages.Clear();
  _messages = data?.Data.Select(x => new AiContent() { Type = x.Role, Text = x.Content[0].Text }).ToList() ?? new List<AiContent>();
}
catch (Exception ex)
{
  throw new AiClientException("Error retrieving messages");
}

J’analyse la réponse dans un objet ChatRepsonse qui contient les messages ainsi que les métadonnées. Les messages sont imbriqués dans une classe au sein de la classe ChatResponse. Afin de simplifier le code d’un article de blog, je remplace simplement la liste complète des messages du service par chaque réponse. Voici la classe ChatResponse avec sa classe nest pour les messages :

public class ChatResponse
{
   public List<Data> Data { get; set; }
   public string FirstId { get; set; }
   public string LastId { get; set; }
   public bool HasMore { get; set; }
}

public class Data
{
   public string Id { get; set; }
   public string Object { get; set; }
   public long CreatedAt { get; set; }
   public string AssistantId { get; set; }
   public string ThreadId { get; set; }
   public string RunId { get; set; }
   public string Role { get; set; }
   public List<AiContent> Content { get; set; }
   public List<object> FileIds { get; set; }
   public Metadata Metadata { get; set; }
}

Dans la classe ChatResponse, vous pouvez voir que les champs de niveau supérieur fournissent une liste de conversations, des données (généralement il n’y en a qu’une), ainsi que l’ID du premier et du dernier message. (Vous pouvez utiliser le dernier identifiant afin d’obtenir la réponse de l’assistant si cela convient mieux à votre cas d’utilisation.) Bien que la classe Data contienne les métadonnées de la conversation, les messages sont stockés dans la propriété Content de Data. Cette propriété n’est toujours pas la fin car le JSON est décomposé en un objet avec le rôle et une autre classe pour le texte de réponse que j’ai appelé AiContent.

public class AiContent
{
  public string Type { get; set; }
  public Text Text { get; set; }
}
public class Text
{
  public string Value { get; set; }
  public List<object> Annotations { get; set; }
}

Une fois que vous avez extrait les messages de la réponse, vous êtes libre d’en faire ce que vous voulez. Mon simple client MVC envoie simplement la nouvelle liste de messages à l’utilisateur.

Faire avancer le projet

Outre les points que j’ai mentionnés ci-dessus, il y a certainement place à l’amélioration avec ce code. J’ai créé ces extraits à partir d’un POC sur lequel j’ai travaillé, ils ne sont donc très probablement pas prêts pour la production tels quels. Il y a plusieurs domaines dans lesquels je pense que cela peut être amélioré. Des domaines tels que

• Streaming entre l’appel et OpenAI – OpenAI offre une réponse en streaming plutôt que HTTP. Suivre cette voie supprimerait le code d’interrogation du projet et fournirait une réponse plus proche du temps réel à la bibliothèque

• SignalR au lieu du client HTTP : utilisé conjointement avec le streaming d’OpenAI, cela fournirait des réponses partielles au fur et à mesure que l’assistant les génère

• Ajouter le téléchargement de fichiers – À mesure que les IA deviennent plus complexes, de simples invites risquent de ne plus suffire. Fournir un fichier a le potentiel de fournir à l’assistant un contexte plus complet

• Ajouter la génération de photos – Qui n’aime pas jouer avec le générateur de photos fourni par la plupart des IA ?

Dossier complet

using AiClients.Exceptions;
using AiClients.Interfaces;
using AiClients.Models;
using Microsoft.Extensions.Configuration;
using Newtonsoft.Json;
using System.Net.Http.Headers;
using System.Text;
namespace CustomGptClient.Services
{
    public class AssitantService : IAiService
    {
        private string _threadId;
        private IConfiguration _config;
        private string _apiKey;
        private string _assistantId;
        private List<AiContent> _messages;
        private string _assistantName;
        private HttpClient _client;
        private List<string> FinalStatuses = new List<string> { "completed", "failed", "cancelled", "expired" };
        public AssitantService(IConfiguration configuration)
        {
            _config = configuration;
            _apiKey = _config.GetSection("OpenAI:ApiKey")?.Value ?? string.Empty;
            _assistantId = _config.GetSection("OpenAI:AssistantId")?.Value ?? string.Empty;
            _messages = new List<AiContent>();
        }

        private string CreateThread()
        {
            if (string.IsNullOrEmpty(_apiKey)) throw new AiClientException("OpenAI ApiKey is not set");
            HttpResponseMessage response;
            using (var _client = new HttpClient())
            {
                _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _apiKey);
                _client.DefaultRequestHeaders.Add("OpenAI-Beta", "assistants=v2");
                response = _client.PostAsync("https://api.openai.com/v1/threads", null).Result;
            }
            var threadIdResponse = response.Content.ReadAsStringAsync().Result;
            if (!response.IsSuccessStatusCode)
            {
                var errorResponse = JsonConvert.DeserializeObject<ErrorResponse>(threadIdResponse);
                throw new AiClientException(errorResponse?.Error.Message);
            }
            var threadIdObj = JsonConvert.DeserializeObject<ThreadResponse>(threadIdResponse);
            _threadId = threadIdObj?.Id ?? string.Empty;
            return _threadId;
        }
        public async Task AddMessage(string message)
        {
            if (string.IsNullOrEmpty(_apiKey)) throw new AiClientException("OpenAI ApiKey is not set");
            if (string.IsNullOrEmpty(_threadId)) CreateThread(); 
            if (string.IsNullOrEmpty(message)) throw new AiClientException("Message is empty");
            HttpResponseMessage response;
            using (_client = new HttpClient())
            {
                _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _apiKey);
                _client.DefaultRequestHeaders.Add("OpenAI-Beta", "assistants=v1");
                var messageRequest = new AiRequestMessage { Role = "user", Content = message };
                var json = JsonConvert.SerializeObject(messageRequest);
                var content = new StringContent(json, Encoding.UTF8, "application/json");
                response = await _client.PostAsync($"https://api.openai.com/v1/threads/{_threadId}/messages", content);
            }
            var threadIdResponse = response.Content.ReadAsStringAsync().Result;
            if (!response.IsSuccessStatusCode)
            {
                var errorResponse = JsonConvert.DeserializeObject<ErrorResponse>(threadIdResponse);
                throw new AiClientException(errorResponse?.Error.Message);
            }
            var threadIdObj = JsonConvert.DeserializeObject<ThreadResponse>(threadIdResponse);
            await CreateRun();
        }
        public async Task CreateRun()
        {
            HttpResponseMessage response;
            using (_client = new HttpClient())
            {
                _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _apiKey);
                _client.DefaultRequestHeaders.Add("OpenAI-Beta", "assistants=v2");
                var custAsst = new Assistant { assistant_id = _assistantId };
                var json = JsonConvert.SerializeObject(custAsst);
                var content = new StringContent(json, Encoding.UTF8, "application/json");
                response = await _client.PostAsync($"https://api.openai.com/v1/threads/{_threadId}/runs", content);
                var responseContent = await response.Content.ReadAsStringAsync();
                var responseObj = JsonConvert.DeserializeObject<RunResponse>(responseContent);
                var runId = responseObj?.Id;
                var runStatus = responseObj?.Status;
                //if not completed, poll again
                if (runId != null)
                {
                    while (runStatus != null && !FinalStatuses.Contains(runStatus))
                    {
                        await Task.Delay(1000);
                        response = await _client.GetAsync($"https://api.openai.com/v1/threads/{_threadId}/runs/{runId}");
                        responseContent = response.Content.ReadAsStringAsync().Result;
                        responseObj = JsonConvert.DeserializeObject<RunResponse>(responseContent);
                        runStatus = responseObj?.Status;
                    }
                }
            }
            await GetResponse();
        }
        public async Task GetResponse()
        {
            HttpResponseMessage response;
            using (_client = new HttpClient())
            {
                _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _apiKey);
                _client.DefaultRequestHeaders.Add("OpenAI-Beta", "assistants=v1");
                response = await _client.GetAsync($"https://api.openai.com/v1/threads/{_threadId}/messages");
            }
            var responseContent = response.Content.ReadAsStringAsync().Result;
            try
            {
                var data = JsonConvert.DeserializeObject<ChatResponse>(responseContent);
                _messages.Clear();
                _messages = data?.Data.Select(x => new AiContent() { Type = x.Role, Text = x.Content[0].Text }).ToList() ?? new List<AiContent>();
            }
            catch (Exception ex)
            {
                throw new AiClientException("Error retrieving messages");
            }
        }
}






Source link