Création d’un service de comparaison de documents basé sur les LLM avec Spring Boot

Comparer deux documents volumineux pour en identifier les différences peut s’avérer une tâche fastidieuse et chronophage. En s’appuyant sur les grands modèles de langage (LLM), il devient possible d’automatiser ce processus et de générer des résumés lisibles mettant en évidence les différences entre les documents. Ce guide technique présente comment construire un service REST avec Spring Boot qui utilise des LLM pour comparer automatiquement des documents. L’accent est mis sur une architecture modulaire : la configuration des modèles IA et une configuration de prompts externalisée dans un fichier YAML, permettant une personnalisation facile et une intégration fluide avec différents fournisseurs d’IA sans modifier le code. Cette flexibilité permet aux développeurs de basculer entre différents modèles (OpenAI, modèles locaux via Ollama, Claude d’Anthropic, etc.) ou de modifier les prompts simplement via la configuration, rendant le système extensible et facile à maintenir.

Configuration via les propriétés (IA & Prompts)

Le comportement du service repose largement sur sa configuration, définie dans le fichier application.yml. Tous les paramètres essentiels relatifs au modèle d’IA et aux prompts y sont renseignés et liés à des classes de configuration Spring Boot. Cette approche centralise les paramètres critiques, si bien que les changements (par exemple, l’utilisation d’un autre modèle ou l’ajustement d’un paramètre comme la température) ne nécessitent aucune modification du code.

La section ai du fichier YAML définit les détails du fournisseur et du modèle IA. Les champs clés sont :

  • client – Le nom du bean Spring correspondant à l’implémentation du client IA à utiliser (par ex. openaiClient, ollamaClient, anthropicClient).
  • api-url – L’URL de l’API LLM (comme le point de terminaison de complétion “endpoint completion” d’OpenAI ou un serveur Ollama local).
  • api-key – La clé ou le jeton d’authentification (si requis par le fournisseur).
  • model – Le nom ou l’identifiant du modèle (ex. gpt-4-turbo, mistral, claude-v2).
  • parameters / values – Listes de paramètres API supplémentaires et leurs valeurs. Cela permet d’ajouter des réglages facultatifs (comme temperature, top_p, etc.) sans toucher au code.

Exemple de configuration YAML pour différents fournisseurs :
Exemple OpenAI (application.yml) :

 


ai:
  client: openaiClient
  model: gpt-4-turbo
  api-url: https://api.openai.com/v1/chat/completions
  api-key: sk-...
  parameters:
    - temperature
  values:
    - 0.7

Exemple avec Ollama (modèle local Mistral) :


ai:
  client: ollamaClient
  model: mistral
  api-url: http://localhost:11434/api/chat
  parameters:
    - temperature
  values:
    - 0.2

Exemple Anthropic Claude :


ai:
  client: anthropicClient
  model: claude-2.1
  api-url: https://api.anthropic.com/v1/complete
  api-key: <VOTRE_CLE_API_ANTHROPIC>
  parameters:
    - temperature
  values:
    - 0.5

Dans le même fichier YAML, une section de prompt distincte définit les modèles de prompt que le service utilisera pour interroger le LLM. Ce point est détaillé dans la section dédiée, mais en résumé, il permet de prédéfinir un prompt système (qui définit le contexte ou le rôle de l'IA) et un modèle de prompt utilisateur (qui inclut des espaces réservés pour le contenu des deux documents et d'autres paramètres dynamiques comme la langue ou le format).
Toutes ces propriétés sont mappées à des classes POJO via @ConfigurationProperties de Spring Boot. Par exemple, une classe AIProperties est liée au préfixe ai, et une classe PromptProperties est liée au préfixe de prompt. Cette liaison facilite l'injection de valeurs de configuration dans le service et les clients. Par exemple, la classe AIProperties pourrait ressembler à ceci :


@Component
@ConfigurationProperties(prefix = "ai")
public class AIProperties {
    private String model;
    private String apiUrl;
    private String apiKey;
    private String client;
    private List<String> parameters;
    private List<String> values;

    // Getters and setters...
}

Grâce à cette approche pilotée par la configuration, vous pouvez modifier les fournisseurs d'IA ou les paramètres du modèle en modifiant simplement le fichier application.yml, sans modifier le code Java. Nous verrons ensuite comment les modèles de prompts sont définis dans la configuration et utilisés.

Abstraction des clients IA personnalisés

Afin de prendre en charge plusieurs fournisseurs de LLM (OpenAI, Ollama, Anthropic, etc.) de manière claire, le système est construit avec une abstraction client IA enfichable. La conception utilise une interface commune et une classe de base abstraite afin que les spécificités de chaque fournisseur soient isolées dans des classes de connecteur minimales. Les principaux composants de cette conception incluent :

  • Interface AIClient – déclare la méthode principale askModel(String systemPrompt, String userPrompt) qui envoie les prompts au modèle et renvoie la réponse.
  • AbstractAIClient – une classe abstraite qui implémente AIClient et fournit des fonctionnalités communes pour envoyer des requêtes HTTP au LLM. Elle utilise WebClient de Spring pour envoyer les requêtes et gère la construction du corps de la requête (y compris l'ID du modèle, les messages et tous les paramètres de configuration). Elle définit également des méthodes abstraites pour la logique spécifique au fournisseur, par exemple pour extraire la réponse du modèle de la réponse HTTP.
  • Classes client concrètes – par exemple OpenAIClient, OllamaClient, AnthropicClient, etc. Ces extensions étendent AbstractAIClient et ne remplacent généralement que les éléments nécessaires à la gestion du format de réponse ou de l'authentification de l'API spécifique. Cela implique souvent l'implémentation de la méthode permettant d'extraire le contenu pertinent de la réponse JSON du fournisseur, et éventuellement l'ajustement de la structure de la requête (par exemple, l'ajout d'en-têtes d'authentification).

Grâce à cette abstraction en couches, la prise en charge d'un nouveau fournisseur d'IA est simple et nécessite un minimum de modifications de code. Toute la configuration, comme les clés d'API, les URL, les noms de modèles et les paramètres, est fournie via YAML, ce qui permet à une nouvelle classe de connecteur de les exploiter sans avoir à coder en dur.

Voici un modèle de client d'IA personnalisé. Cet exemple présente un MyCustomClient hypothétique qui étend AbstractAIClient. Un développeur peut s'en servir comme point de départ pour intégrer un nouveau fournisseur LLM en personnalisant les méthodes extractContentFromResponse() et build() :


@Service("myCustomClient")
public class MyCustomClient extends AbstractAIClient {

    public MyCustomClient(AIProperties props) {
        super(props);
    }

    @Override
    protected String extractContentFromResponse(Map<?, ?> response) {
        List<Map<String, Object>> choices = (List<Map<String, Object>>) response.get("choices");
        Map<String, Object> message = (Map<String, Object>) choices.get(0).get("message");
        return (String) message.get("content");
    }

    @Override
    protected void build() {
        webClient = WebClient.builder()
            .baseUrl(aiProperties.getApiUrl())
            .defaultHeader("Authorization", "Bearer " + aiProperties.getApiKey())
            .build();
    }
}

 

Dans l'extrait ci-dessus, l'annotation @Service("myCustomClient") enregistre cette classe comme un bean Spring nommé "myCustomClient". En remplaçant la propriété YAML ai.client par "myCustomClient", le système utilisera ce nouveau client. La méthode extractContentFromResponse suppose que le JSON est structuré de manière similaire à celui d'OpenAI (avec une liste de choices contenant un message et un content), et devrait être adapté au format d'API du nouveau fournisseur. La méthode build() initialise le WebClient, en ajoutant ici un en-tête Authorization, par exemple pour les API nécessitant un jeton porteur. 

Pour plus de contexte, voici à quoi ressemblent l'interface AIClient et une version simplifiée d'AbstractAIClient :

 


public interface AIClient {
    String askModel(String systemPrompt, String userPrompt);
}

Et une version simplifiée de la classe abstraite :


public abstract class AbstractAIClient implements AIClient {
    protected AIProperties aiProperties;
    protected WebClient webClient;

    @Override
    public String askModel(String systemPrompt, String userPrompt) {
        Map<String, Object> body = buildBody(systemPrompt, userPrompt);
        return webClient.post()
                .bodyValue(body)
                .retrieve()
                .bodyToMono(Map.class)
                .map(this::extractContentFromResponse)
                .block();
    }

    private Map<String, Object> buildBody(String systemPrompt, String userPrompt) {
        Map<String, Object> body = new HashMap<>();
        body.put("model", aiProperties.getModel());
        List<Map<String, String>> messages = new ArrayList<>();
        messages.add(createMessage("system", systemPrompt));
        messages.add(createMessage("user", userPrompt));
        body.put("messages", messages);

        List<String> params = aiProperties.getParameters();
        List<String> vals = aiProperties.getValues();
        if (params != null && vals != null) {
            for (int i = 0; i < params.size(); i++) {
                body.put(params.get(i), convertValue(vals.get(i)));
            }
        }

        return body;
    }

    private Object convertValue(String value) {
        if ("true".equalsIgnoreCase(value) || "false".equalsIgnoreCase(value)) {
            return Boolean.parseBoolean(value);
        }
        try {
            return value.contains(".") ? Double.parseDouble(value) : Integer.parseInt(value);
        } catch (NumberFormatException e) {
            return value;
        }
    }

    protected abstract String extractContentFromResponse(Map<?, ?> response);
    protected abstract void build();

    private Map<String, String> createMessage(String role, String content) {
        Map<String, String> message = new HashMap<>();
        message.put("role", role);
        message.put("content", content);
        return message;
    }
}

En résumé, AbstractAIClient se charge de construire la requête (y compris l'insertion des prompts système et utilisateur dans une liste de messages et l'ajout de paramètres, comme la température, à partir de la configuration). Il utilise ensuite WebClient pour envoyer un POST à ​​l'API URL et attend une réponse, attendue sous forme de mappage JSON. Le client concret se contente d'implémenter l'analyse de ce JSON (extractContentFromResponse) et de configurer WebClient (build). Cette architecture sépare clairement la logique spécifique au fournisseur du reste de l'application. Elle centralise également tous les identifiants et paramètres du fournisseur dans la configuration YAML, ce qui simplifie le changement ou l'ajout de nouvelles intégrations LLM.

Configuration et utilisation des prompts

Outre la configuration du client IA, les prompts eux-mêmes sont externalisés dans la configuration application.yml. Séparer les prompts du code permet aux non-développeurs d'ajuster la formulation et la structure de la requête IA sans toucher au code Java. Cela facilite également la maintenance de plusieurs modèles de prompts pour différents cas d'utilisation.

Dans le fichier YAML, les prompts sont généralement organisés dans une section « prompt ». Par exemple, nous pourrions avoir une section « prompt.compare » contenant les définitions de nos prompts de comparaison de documents. Chaque définition de prompt comprend au moins deux parties : system et utilisateur. Le système est une chaîne qui amorce l'IA avec une identité ou un comportement (par exemple, « Vous êtes une IA spécialisée dans l'analyse de documents. »), et l’utilisateur est une chaîne de modèle avec des espaces réservés où le contenu du document (et d'autres entrées dynamiques) sera inséré.

Par exemple, notre configuration pourrait définir deux variantes du prompt de comparaison : une générique (“generic”) et une détaillée (“detailed”). Le prompt générique peut permettre de spécifier dynamiquement une langue de sortie, tandis que le prompt détaillé peut être adapté à un domaine spécifique ou utiliser systématiquement une langue par défaut. Voici un extrait illustratif du fichier application.yml :


prompt:
  compare:
    detailed:
      id: detailedContract
      system: "Vous êtes une IA spécialisée dans l'analyse de documents."
      user: |
        Veuillez être exhaustif et fournir une comparaison très détaillée, point par point. Incluez toute différence subtile, même mineure.
        Comparez les deux documents suivants :

        Document A :
        %s

        Document B :
        %s
    generic:
      id: genericComparison
      system: "Vous êtes une IA spécialisée dans l'analyse de documents."
      user: |
        Répondez en %s. Comparez les deux documents suivants :

        Document A :
        %s

        Document B :
        %s

Le service remplace dynamiquement les placeholders (%s) avec le contenu textuel des documents (et éventuellement d’autres paramètres ex. : la langue).

 

Couche Contrôleur et Service

Une fois la configuration et les clients en place, le contrôleur REST et la couche de service de l'application assurent la cohérence.
Le contrôleur REST expose un point de terminaison (par exemple, GET /ai-difference) qui déclenche la comparaison des documents. La couche de service contient la logique permettant de récupérer le contenu du document, d'appliquer le prompt et d'appeler le client IA. Contrôleur : Dans Spring Boot, une simple classe @RestController définit l'API. Par exemple :


@RestController
@RequestMapping("/api")
public class DocumentCompareController {

    @Autowired
    private ApplicationContext applicationContext;

    @Autowired
    private AIProperties aiProperties;

    @Autowired
    private CompareService compareService;

    @GetMapping("/ai-difference")
    public ResponseEntity<String> getAIDifference(
            @RequestParam DocumentId leftDocumentId,
            @RequestParam DocumentId rightDocumentId) {

        AIClient client = initClient();
        String result = compareService.compare(leftDocumentId, rightDocumentId, client);
        return ResponseEntity.ok(result);
    }

    private AIClient initClient() {
        AIClient selectedClient = (AIClient) applicationContext.getBean(aiProperties.getClient());
        ((AbstractAIClient) selectedClient).init(aiProperties);
        return selectedClient;
    }
}

Dans ce contrôleur :

  • Le point de terminaison getAIDifference accepte deux identifiants de document comme paramètres de requête (il peut s'agir d'identifiants pointant vers des documents stockés dans une base de données ou un système de fichiers).
  • Il appelle initClient() pour obtenir un bean d'implémentation AIClient correspondant au fournisseur configuré. aiProperties.getClient() fournit le nom du bean (par exemple « openaiClient » ou « ollamaClient » tel que défini dans YAML), et nous utilisons Spring ApplicationContext pour obtenir l'instance de bean appropriée. Nous le castons ensuite en AbstractAIClient pour appeler une méthode init, en transmettant les propriétés AIProperties actuelles (cette méthode init assigne simplement la configuration et appelle la méthode build() sur le client pour configurer le WebClient avec les dernières valeurs de configuration).
  • Le contrôleur délègue ensuite à une méthode compareService.compare(...), fournissant les identifiants de document gauche et droit, ainsi que le AIClient initialisé. Le résultat du service est renvoyé directement dans le corps de la réponse HTTP.

Service : Le service de comparaison (appelons-le CompareService) contient la logique principale de récupération des données documentaires et d'interaction avec le LLM. Le pseudo-code de la méthode de comparaison du service pourrait ressembler à ceci :

 


@Service
public class CompareService {

    @Autowired
    private PromptProperties promptProperties;

    @Autowired
    private DocumentRepository documentRepository;

    public String compare(DocumentId leftId, DocumentId rightId, AIClient client)
            throws IOException {

        // 1. Retrieve documents
        String leftText = documentRepository.getText(leftId);
        String rightText = documentRepository.getText(rightId);

        // 2. Build prompt
        Prompt prompt = promptProperties.getPromptMap().get("generic");
        String systemPrompt = prompt.getSystem();
        String userTemplate = prompt.getUser();
        String userPrompt = String.format(userTemplate, "English", leftText, rightText);

        // 3. Query the AI
        String aiResponse = client.askModel(systemPrompt, userPrompt);

        // 4. Return comparison
        return aiResponse;
    }
}

Quelques points à noter dans cette logique de service :

  • Extraction de document : Dans cet exemple, documentRepository.getText(path) représente une méthode permettant de récupérer le contenu textuel du document. Cela peut impliquer la lecture d'un PDF, d'un document Word, etc., et l'extraction de texte brut. L'implémentation de cette méthode sort du cadre de cet article et l'important ici est qu'au moment de la création de prompts, nous disposions de deux grandes chaînes : le contenu du document A et du document B.

  • Assemblage de prompts : Nous utilisons les modèles de prompts configurés dans PromptProperties. Nous formatons la chaîne de prompt utilisateur avec son contenu réel. Ici, nous insérons explicitement « English » pour indiquer au modèle de répondre en anglais. Cette méthode peut être rendue dynamique en fonction d'une requête utilisateur (par exemple, si l'API possède un paramètre lang, vous pouvez le transmettre à cette méthode et l'utiliser à la place du paramètre « English » codé en dur ou éventuellement créer une propriété  « langue »).
  • Requête du modèle IA : Nous appelons client.askModel(systemPrompt, userPrompt), qui, grâce au polymorphisme, appelle l'implémentation appropriée pour le fournisseur d'IA choisi. Le code du service n'a pas besoin de savoir s'il appelle OpenAI, un modèle local ou autre – cette opération est gérée par l'abstraction AIClient. Il renvoie simplement une chaîne qui constitue la réponse du modèle (la description des différences entre les deux documents).

Cette conception assure une séparation claire des tâches : le contrôleur gère le protocole HTTP et le flux applicatif de haut niveau, le service gère la logique du domaine (récupération de documents et composition des prompts), et la couche client IA gère la communication avec le service IA externe.

Le contenu de prompts est externalisé dans la configuration et les spécificités du client IA sont encapsulées, ce qui facilite la maintenance et l'extension.


Conclusion

En suivant cette architecture, nous avons construit un service de comparaison de documents robuste et flexible alimenté par des LLM. La solution sépare proprement la configuration, l'incitation et l'intégration spécifique au fournisseur :

  • Extensibilité : La prise en charge d'un nouveau modèle d'IA ou d'un nouveau fournisseur est aussi simple que l'ajout d'une nouvelle configuration et d'une petite classe client. Aucune modification du code n'est nécessaire pour essayer un LLM différent.
  • Personnalisation : Les prompts sont définis en YAML, ce qui permet une itération facile sur la formulation et le style des instructions de l'IA. Il est possible de les adapter à différents domaines documentaires ou langues sans toucher au code Java.
  • Facilité d'intégration : la liaison de configuration et l'injection de dépendances de Spring Boot permettent d'injecter facilement les bons composants (messages-guides, clients de l'IA) là où ils sont nécessaires. L'utilisation d'une interface (AIClient) signifie que la logique du service n'est pas liée à une API particulière.

Dans la pratique, cette approche peut réduire considérablement le travail manuel d'analyse des documents. Par exemple, les équipes juridiques qui comparent des versions de contrats, les chercheurs qui comparent des articles savants ou les auditeurs qui comparent des rapports peuvent obtenir rapidement des informations générées par l'IA qui mettent en évidence les changements et les différences.

Prochaines étapes : Il existe plusieurs façons d'améliorer encore ce système. La mise en cache des réponses de l'IA pourrait permettre d'éviter des coûts répétés lors de la comparaison des mêmes documents à plusieurs reprises. En outre, l'introduction de modes d'évaluation - tels qu'un mode verbeux dans lequel l'IA explique son raisonnement, ou un mode résumé pour les différences de haut niveau - peut offrir une plus grande polyvalence aux utilisateurs finaux. En s'appuyant sur la base modulaire décrite ici, de telles améliorations peuvent être ajoutées avec un minimum de friction, garantissant que le service reste adaptable à l'évolution des besoins et aux progrès de la technologie LLM.