Skip to content

razamwende/springboot-llm-chatbot

Repository files navigation

Spring Boot LLM Relay API

API REST Spring Boot servant de relais entre un client et un fournisseur LLM (Ollama / OpenAI).


Démarrage rapide

Avec Docker (recommandé)

Le build du JAR se fait en local (là où le réseau est disponible), Docker ne fait qu'empaqueter le résultat — aucun téléchargement dans le container.

# 1. Compiler le JAR (nécessite Java 21)
./gradlew bootJar

# 2. Lancer l'API + Ollama
docker-compose up --build

En local sans Docker

# Ollama doit tourner sur localhost:11434
./gradlew bootRun

Tester l'API

# Envoyer un prompt
curl -X POST http://localhost:8080/api/v1/ask \
  -H "Content-Type: application/json" \
  -d '{"prompt": "Explique le design pattern Strategy en Java"}'

# Consulter l'historique
curl http://localhost:8080/api/v1/history?limit=5

# Vérifier la santé de l'API
curl http://localhost:8080/api/v1/health

Concepts techniques couverts

Ce projet illustre les pratiques fondamentales du développement backend avec Spring Boot. Chaque section explique pourquoi une décision technique a été prise, pas seulement comment.


1. Architecture en couches (Layered Architecture)

Client HTTP
    │
    ▼
Controller       ← reçoit la requête HTTP, valide, délègue
    │
    ▼
Service          ← contient la logique métier
    │
    ▼
LlmClient        ← communique avec le fournisseur externe
    │
    ▼
Ollama / OpenAI

Pourquoi cette séparation ?

Chaque couche a une seule responsabilité (principe SRP — Single Responsibility Principle).

  • Le Controller ne sait pas comment fonctionne Ollama.
  • Le Service ne sait pas que les données arrivent via HTTP.
  • Le Client ne connaît pas la logique métier.

Cela rend chaque classe plus facile à comprendre, tester et modifier indépendamment.


2. L'interface comme contrat : LlmClient

public interface LlmClient {
    String ask(String prompt, String model, double temperature);
    boolean isAvailable();
    String getProviderName();
}

OllamaClient et OpenAiClient implémentent cette interface.

Pourquoi une interface ?

Sans interface :

// Le service est couplé à Ollama → impossible de changer de provider
private OllamaClient ollamaClient;

Avec interface :

// Le service dépend d'une abstraction → on peut injecter Ollama OU OpenAI
private LlmClient llmClient;

C'est le principe de substitution de Liskov et le principe d'inversion de dépendances (D dans SOLID) : dépendre d'abstractions, pas d'implémentations concrètes.

Pour changer de provider, il suffit de modifier application.yml :

llm:
  provider: openai  # ou ollama

Aucune modification du code Java nécessaire.


3. Configuration externalisée : @ConfigurationProperties

@ConfigurationProperties(prefix = "llm")
public class LlmProperties {
    private String provider = "ollama";
    private String baseUrl = "http://localhost:11434";
    private String defaultModel = "llama3";
    // ...
}

Pourquoi ne pas mettre ces valeurs en dur dans le code ?

Le code ne doit pas changer selon l'environnement. Les valeurs de configuration doivent vivre dans des fichiers de config ou des variables d'environnement, pas dans le code source.

@ConfigurationProperties mappe automatiquement application.yml vers un objet Java typé. Les variables d'environnement surchargent les valeurs du fichier :

LLM_BASE_URL=http://ollama:11434  →  llm.base-url  →  LlmProperties.baseUrl

Cela permet de déployer le même JAR en dev, staging et production avec des configs différentes.


4. Injection de dépendances et Bean Spring

@Configuration
public class LlmConfig {

    @Bean
    public LlmClient llmClient(LlmProperties props) {
        return switch (props.getProvider().toLowerCase()) {
            case "openai" -> new OpenAiClient(props);
            default      -> new OllamaClient(props);
        };
    }
}

Spring gère un conteneur de beans. Quand AskService déclare LlmClient dans son constructeur, Spring lui injecte automatiquement le bon objet (Ollama ou OpenAI selon la config).

Inversion of Control (IoC) : ce n'est pas le code qui crée ses dépendances, c'est le framework qui les fournit. Cela découple les composants et facilite les tests (on peut injecter un mock).


5. Validation des entrées : Bean Validation

public record AskRequest(
    @NotBlank(message = "Le prompt ne peut pas être vide") String prompt,
    String model,
    @DecimalMin("0.0") @DecimalMax("2.0") Double temperature
) {}
@PostMapping("/ask")
public ResponseEntity<AskResponse> ask(@Valid @RequestBody AskRequest request) { ... }

@Valid déclenche la validation automatique avant d'entrer dans la méthode. Si elle échoue, Spring lève une MethodArgumentNotValidException — capturée par le GlobalExceptionHandler.

Pourquoi valider ?

Ne jamais faire confiance aux données entrantes. Valider à la frontière du système (entrée HTTP) évite de propager des données invalides dans la logique métier ou vers le LLM.


6. Gestion centralisée des erreurs : @RestControllerAdvice

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ErrorResponse> handleValidation(...) { ... }  // 400

    @ExceptionHandler(LlmUnavailableException.class)
    public ResponseEntity<ErrorResponse> handleLlmUnavailable(...) { ... }  // 503

    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleGeneral(...) { ... }  // 500
}

Sans ce handler, Spring retournerait la stacktrace Java brute au client — c'est illisible et dangereux (fuite d'informations internes).

@RestControllerAdvice intercepte toutes les exceptions levées par les controllers et les transforme en réponses JSON structurées :

{
  "status": 400,
  "error": "Bad Request",
  "message": "prompt: Le prompt ne peut pas être vide",
  "timestamp": "2024-03-15T10:30:00Z"
}

7. Client HTTP moderne : RestClient

var response = restClient.post()
        .uri("/api/generate")
        .contentType(MediaType.APPLICATION_JSON)
        .body(request)
        .retrieve()
        .body(GenerateResponse.class);

RestClient (Spring Boot 3.2+) remplace RestTemplate avec une API fluente (chaînage de méthodes). Jackson sérialise/désérialise automatiquement les objets Java en JSON.

Quand le serveur distant est injoignable, RestClient lève une ResourceAccessException. On la rattrape pour lancer une LlmUnavailableException → le GlobalExceptionHandler retourne un 503.


8. Java Records

public record AskResponse(
    String id,
    String prompt,
    String response,
    String model,
    long durationMs,
    Instant timestamp
) {}

Un record est une classe immuable avec constructeur, getters, equals, hashCode et toString générés automatiquement. Idéal pour les objets de transport de données (DTOs) qui ne changent pas après leur création.


9. Concurrence : ConcurrentLinkedDeque

private final Deque<Interaction> history = new ConcurrentLinkedDeque<>();

Une API web reçoit des requêtes simultanées de plusieurs clients. Si deux requêtes écrivent en même temps dans une ArrayList, des données peuvent se corrompre (race condition).

ConcurrentLinkedDeque est une structure de données thread-safe : plusieurs threads peuvent lire et écrire simultanément sans corruption. C'est la bonne structure pour un historique partagé en mémoire.


10. Conteneurisation : Docker & Docker Compose

Dockerfile — build multi-stage :

# Étape 1 : compiler le projet
FROM eclipse-temurin:21-jdk-alpine AS build
WORKDIR /app
COPY . .
RUN ./gradlew bootJar --no-daemon

# Étape 2 : image finale légère (sans le JDK)
FROM eclipse-temurin:21-jre-alpine
COPY --from=build /app/build/libs/*.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]

Le build multi-stage produit une image finale petite (JRE seulement, sans les sources ni les outils de build).

Docker Compose — orchestration locale :

services:
  api:
    build: .
    environment:
      - LLM_BASE_URL=http://ollama:11434  # résolution DNS interne Docker
    depends_on:
      - ollama

  ollama:
    image: ollama/ollama

docker-compose up démarre les deux services ensemble. Le nom ollama dans LLM_BASE_URL est résolu automatiquement par le réseau interne Docker — pas besoin d'adresse IP.


Structure du projet

src/main/java/com/razamwende/springbootllmrelay/
├── config/
│   ├── LlmProperties.java      Mapping application.yml → objet Java
│   └── LlmConfig.java          Sélection du client LLM (factory bean)
├── model/
│   ├── AskRequest.java         DTO entrant (validé)
│   ├── AskResponse.java        DTO sortant
│   └── Interaction.java        Entrée historique (stocké en mémoire)
├── client/
│   ├── LlmClient.java          Interface (port de sortie)
│   ├── OllamaClient.java       Adaptateur Ollama
│   └── OpenAiClient.java       Adaptateur OpenAI
├── service/
│   └── AskService.java         Logique métier + gestion de l'historique
├── controller/
│   └── AskController.java      Endpoints REST
└── exception/
    ├── LlmUnavailableException.java
    └── GlobalExceptionHandler.java

Variables d'environnement

Variable Défaut Description
LLM_PROVIDER ollama Provider LLM (ollama ou openai)
LLM_BASE_URL http://localhost:11434 URL du provider
LLM_DEFAULT_MODEL llama3 Modèle utilisé si non précisé dans la requête
OPENAI_API_KEY (vide) Clé API OpenAI (requis si provider = openai)

Endpoints

Méthode URL Description
POST /api/v1/ask Envoie un prompt au LLM
GET /api/v1/history?limit=10 Dernières interactions (max 50)
GET /api/v1/health État de l'API et du provider LLM

Codes d'erreur

Code Cas
400 Prompt vide ou température hors de [0.0, 2.0]
503 Provider LLM injoignable
500 Erreur interne

About

API REST Spring Boot servant de relais entre un client et un fournisseur LLM (Ollama / OpenAI). TP d'apprentissage : WebClient, gestion d'erreurs, Docker Compose.

Topics

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors