7min.

Du langage naturel à un DTO grâce à l’IA

L’IA est sur toutes les lèvres, et souvent en tant que développeur, on peut avoir du mal à voir des cas concrets de son utilité.

D’autant plus que le sigle IA a tendance à regrouper tout un tas de technologies participant à la confusion générale dans des buts mercantiles (ne nous le cachons pas).

Pour autant, aujourd’hui je vais vous présenter un cas intéressant que nous avons développé en tant que prototype pour un client.

C’est un prototype qui est basé sur une LLM développé avec OpenAI, mais il est tout à fait possible d’utiliser d’autre LLM, y compris hébergée chez vous (par exemple avec Ollama). Ceci permet de régler les problématiques de confidentialité ou de maîtrise des coûts.

Notre but sera de traiter une requête en langage naturel et de la transformer en modèle objet que nous pourrons utiliser dans notre code.

C’est utile, par exemple, pour un moteur de recherche à facettes. Le modèle représente nos facettes, et l’utilisateur effectue sa recherche en langage naturel.

Section intitulée cas-pratiqueCas pratique

Pour être honnête, je vais me baser sur le même exemple que nos amis de chez Premier Octet : Filtres intelligents avec le SDK Vercel AI et Next.js.

On parlera donc d’un moteur de recherche qui interroge une base de données contenant les statistiques de visites effectuées sur un site donné. La recherche contient des facettes telles que le type de navigateur (browser), le système d’exploitation utilisé (operatingSystem) et le pays de provenance (country).

Nos facettes sont représentées dans notre code via un DTO

class SearchDTO
{
    public function __construct(
        public ?string $browser = null,
        public ?string $operatingSystem = null,
        public ?string $country = null,
    ) {
    }

    public static function fromLLM(array $data): static
    {
        return new self(
            browser: $data['browser'] ?? null,
            operatingSystem: $data['operatingSystem'] ?? null,
            country: $data['country'] ?? null,
        );
    }

    public static function jsonSchema(): array
    {
        return [
            'properties' => [
                'operatingSystem' => ['type' => ['string', 'null']],
                'country' => ['type' => ['string', 'null']],
                'browser' => [
                    'type' => ['string', 'null'],
                    // Note : ici les propriétés de nos objets sont très parlantes, le LLM pourra déduire leur signification
                    // mais si elles étaient plus obscures, on pourrait ajouter une description
                    'description' => 'The browser used to access the website.',
                ],
            ]];
    }
}

Dans notre interface utilisateur, on propose un champ libre que la personne peut remplir (au niveau de l’interface, on laissera aussi les facettes habituelles, comme ça il y aura un retour visuel de ce qui s’est passé).

L’utilisateur entre « I want japan users under Chrome » et ce qu’on attend en retour, c’est que les facettes correspondantes soient remplies avec les données : Japan pour pays et Chrome pour navigateur.

Section intitulée implementationImplémentation

Section intitulée interagir-avec-les-llmInteragir avec les LLM

L’interaction avec une LLM se fait via un prompt (des instructions en langage naturel) c’est un nouveau paradigme déroutant, mais qu’il faut accepter et apprendre à maîtriser. Heureusement pour nous, OpenAI a développé plusieurs concepts qui vont nous aider à récupérer des réponses adaptées à notre développement.

  • Le premier est le mode de réponse JSON, une fois ce mode activé, le LLM va répondre en JSON. Pratique.
  • Le second est appelé tools (outils) et plus particulièrement functions (fonctions) qu’on va détourner à notre avantage.

ℹ️ Que sont les functions ? Les fonctions sont une façon de donner au LLM des nouvelles capacités qui sont prévues par votre fournisseur API ou que vous pouvez implémenter vous-même. Vous donnez des signatures de fonctions (les votres, ou celles proposées par votre fournisseur API), et lorsque le LLM pense qu’il lui sera utile d’utiliser telle ou telle fonction pour donner une réponse plus précise, il le fera. Directement, via un appel à la fonction si elle est intégrée chez le fournisseur, ou indirectement, en vous retournant l’appel à votre fonction qu’il veut faire (en respectant la signature que vous avez donné).

En résumé, si la fonction est fournie par la plateforme, elle est utilisée directement. Si c’est votre propre fonction, vous recevez l’appel que le LLM veut faire et vous devez l’exécuter.

Un exemple : Vous demandez à un LLM de convertir des dollars en euro. Et vous lui fournissez une définition de fonction moneyConvert(string deviseFrom, string deviseTo, int amountInCent): int avec une description this function converts a currency to another and gives you the resulting value in cents. Le LLM va déduire l’intention et préparer un appel valide à la fonction fournie pour affiner sa réponse (à vous d’implémenter la fonction et de l’exécuter pour récupérer le résultat). Donc à la place de répondre du texte, le LLM va produire un appel à la fonction dans un format normalisé (et ça va grandement nous aider).

INFO : La plupart des autres LLM ont repris ces deux fonctionnalités.

Section intitulée passons-au-codePassons au code

Notre astuce pour récupérer une réponse normalisée est de détourner ces deux modes pour contraindre le LLM à nous donner une réponse normalisée qui respectera le format qu’on va lui demander.

Et comme du code commenté vaut mille mots :

class AiService
{
    public function __construct(private readonly OpenAI $client) { }

    public function searchToDTO(string $userQuery): SearchDTO
    {
        $result = $this->client->chat()->create([
            'model' => 'gpt-4o-mini',
            // Notre première astuce est de demander à l'IA
            // de nous renvoyer un objet JSON
            'response_format' => ['type' => 'json_object'],
            'messages' => [[
                'role' => 'system',
                // Voici le pre-prompt que nous allons envoyer à l'IA.
                'content' =>
                    'This is a database with records about visits on a website,
                    it has info like browser type, operating system, country,
                    page visited, time, etc. Match the query with our model.
                    Respond with a JSON object.',
            ], [
                'role' => 'user',
                // Ici c'est la requête de l'utilisateur en langage naturel
                'content' => $userQuery,
            ]],
            'tools' => [[
                // On va donner à l'IA la liste des fonctions disponibles
                // Notre fonction donc
                'type' => 'function',
                'function' => [
                    'name' => 'buildJsonForQueryModel',
                    'description' => 'Use this function when the user asks for a search.',
                    // On lui donne le schéma JSON des paramètres de la fonction,
                    // qui est en fait le schéma JSON de notre DTO
                    // Le but n'est pas tant d'appeler notre fonction,
                    // c'est en fait un placeholder pour définir
                    // le format de nos arguments
                    // 1️⃣ C'est là la première partie de notre astuce
                    'parameters' => SearchDTO::jsonSchema(),
                ]],
            ],
            // Bonus OpenAI : leur API permet de forcer le LLM d'utiliser la fonction pour sa réponse
            'tool_choice' => [
                'type' => 'function',
                'function' => [
                    'name' => 'buildJsonForQueryModel',
                ],
            ],
        ]);

        // On traite la réponse. Ici on suppose que tout s'est bien passé,
        // en production on aurait plusieurs lignes de vérification de propriété et de gestion d'erreur
        $choice = $result['choices'][0];
        $message = $choice['message'];
        $toolCall = $message['tool_calls'][0];

        // On récupère les arguments de la fonction,
        // ces arguments respectent le schéma JSON de notre DTO,
        // on va donc pouvoir le convertir en objet DTO
        // 2️⃣ Seconde partie de notre astuce
        $functionArguments = $toolCall['function']['arguments'];

        return SearchDTO::fromLLM(json_decode($functionArguments, true));
    }
}
Pour info voici à quoi ressemble la réponse d’API
OpenAI\Responses\Chat\CreateResponse {
  +object: "chat.completion"
  +model: "gpt-4o-mini-2024-07-18"
  +choices: array:1 [
	0 => OpenAI\Responses\Chat\CreateResponseChoice {
  	+index: 0
  	+message: OpenAI\Responses\Chat\CreateResponseMessage {
    	+role: "assistant"
    	+content: null
    	+toolCalls: array:1 [
      	0 => OpenAI\Responses\Chat\CreateResponseToolCall {
        	+id: "call_xxx"
        	+type: "function"
        	+function: OpenAI\Responses\Chat\CreateResponseToolCallFunction {
          	+name: "buildJsonForQueryModel"
          	+arguments: "{"country":"japan","browser":"chrome"}"
        	}
      	}
    	]
    	+functionCall: null
  	}
  	+finishReason: "stop"
	}
  ]
  ...
}

Section intitulée resultatRésultat

L’appel $aiService->searchToDTO(‘I want only japan users under Chrome’); Retourne

App\DTO\BlogPostDTO {
  +browser: "Chrome"
  +operatingSystem: null
  +country: "Japan"
}

Objet qu’on peut ensuite utiliser dans notre code pour lancer notre recherche !

Section intitulée conclusionConclusion

On voit qu’en détournant un peu les API des LLM, on peut finalement récupérer des données formatées qui répondent à des requêtes utilisateur en langage naturel. Les possibilités sont intéressantes.

Ça reste certainement dans bien des cas, un bazooka pour écraser une mouche, mais je suis certain que vous trouverez une utilité à cette approche.

ℹ️ Aller plus loin
Bien sûr ceci n’est qu’un prototype, et j’ai eu l’occasion de pousser l’idée, avec l’implémentation de DTO plus complexe incluant la notion d’opérateurs, et la gestion de l’intention de l’utilisateur, entre autres.

Aller juste pour teaser, dans le cadre du prototype, je travaillais sur une base de données spécialisée dans le basket et le but était que l’utilisateur puisse chercher en langage naturel ce qu’il veut dans la base. Après quelques itérations, j’en étais arrivé à ça :

Someone called John who is 23 or more

{
    "intention": "SEARCH_PLAYER",
    "players": [{
        "firstName": {
            "value": "John",
            "operator": "="
        },
        "lastName": null,
        "age": {
            "value": "23",
            "operator": ">="
        },
    }],
    "places": null,
    "games": null
}

C’est le fun, non ? 🙂

ℹ️ Note sur la sécurité
Attention à ce qu’on appel le prompt injection. Même si dans cet exemple on est moins concerné grâce au mode json et aux multiples validations effectuées sur la réponse.

Mise à jour : Le 6 aout 2024 OpenAI à fait une annonce concernant leur API qui permet de forcer le LLM à répondre en suivant un JSON Schema. Ce qui est en quelque sorte la même chose que ce qui expliqué dans cet article. Ceci dit, l’article peut servir pour d’autres fournisseurs de LLM, et aussi pour comprendre tools et functions plus en détails.

Commentaires et discussions

Nos articles sur le même sujet

Ces clients ont profité de notre expertise