Ajout de la partie IA

This commit is contained in:
2026-04-20 14:52:20 +02:00
parent 94bbf8beff
commit 5b133aa2fe
50 changed files with 3236 additions and 11 deletions

View File

@@ -29,6 +29,13 @@
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Spring WebFlux : requis pour WebClient (streaming SSE vers le Brain).
RestTemplate (Web MVC) reste pour les appels synchrones one-shot. -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
<!-- Spring Boot Data JPA -->
<dependency>
<groupId>org.springframework.boot</groupId>

View File

@@ -0,0 +1,123 @@
package com.loremind.application.generationcontext;
import com.loremind.domain.generationcontext.GenerationContext;
import com.loremind.domain.generationcontext.GenerationResult;
import com.loremind.domain.generationcontext.ports.AiProvider;
import com.loremind.domain.lorecontext.Lore;
import com.loremind.domain.lorecontext.LoreNode;
import com.loremind.domain.lorecontext.Page;
import com.loremind.domain.lorecontext.Template;
import com.loremind.domain.lorecontext.ports.LoreNodeRepository;
import com.loremind.domain.lorecontext.ports.LoreRepository;
import com.loremind.domain.lorecontext.ports.PageRepository;
import com.loremind.domain.lorecontext.ports.TemplateRepository;
import org.springframework.stereotype.Service;
import java.util.Map;
/**
* Use case applicatif : génère des suggestions de valeurs pour les champs
* d'une Page via l'IA.
*
* Orchestrateur (couche Application de l'hexagonal). C'est le seul endroit
* qui touche simultanément au LoreContext (chargement) et au GenerationContext
* (appel IA). Le domaine reste isolé.
*
* Décision produit : ce use case NE PERSISTE PAS les valeurs générées.
* Il renvoie des suggestions que l'utilisateur validera manuellement via
* le endpoint PUT /api/pages/{id} existant.
*/
@Service
public class GeneratePageValuesUseCase {
private final PageRepository pageRepository;
private final TemplateRepository templateRepository;
private final LoreRepository loreRepository;
private final LoreNodeRepository loreNodeRepository;
private final AiProvider aiProvider;
public GeneratePageValuesUseCase(
PageRepository pageRepository,
TemplateRepository templateRepository,
LoreRepository loreRepository,
LoreNodeRepository loreNodeRepository,
AiProvider aiProvider) {
this.pageRepository = pageRepository;
this.templateRepository = templateRepository;
this.loreRepository = loreRepository;
this.loreNodeRepository = loreNodeRepository;
this.aiProvider = aiProvider;
}
/**
* Génère les valeurs suggérées pour les champs dynamiques d'une Page.
*
* @param pageId identifiant de la Page à enrichir
* @return map fieldName -> valeur suggérée (jamais null, peut contenir des chaînes vides)
* @throws IllegalArgumentException si la Page est introuvable
* @throws IllegalStateException si le Template, le Lore ou le dossier parent sont
* incohérents (intégrité BDD cassée) ou si le Template
* n'a aucun champ à générer
*/
public Map<String, String> execute(String pageId) {
Page page = loadPage(pageId);
Template template = loadTemplate(page.getTemplateId(), pageId);
Lore lore = loadLore(page.getLoreId(), pageId);
LoreNode folder = loadFolder(page.getNodeId(), pageId);
requireNonEmptyFields(template);
GenerationContext context = GenerationContext.builder()
.loreName(lore.getName())
.loreDescription(lore.getDescription())
.folderName(folder.getName())
.templateName(template.getName())
.templateFields(template.getFields())
.pageTitle(page.getTitle())
.build();
GenerationResult result = aiProvider.generatePage(context);
return result.getValues();
}
// --- Helpers de chargement (un lookup = un message d'erreur clair) ------
private Page loadPage(String pageId) {
return pageRepository.findById(pageId)
.orElseThrow(() -> new IllegalArgumentException(
"Page non trouvée avec l'ID: " + pageId));
}
private Template loadTemplate(String templateId, String pageId) {
if (templateId == null || templateId.isBlank()) {
throw new IllegalStateException(
"La page " + pageId + " n'a pas de template associé.");
}
return templateRepository.findById(templateId)
.orElseThrow(() -> new IllegalStateException(
"Template introuvable (id=" + templateId
+ ") pour la page " + pageId));
}
private Lore loadLore(String loreId, String pageId) {
return loreRepository.findById(loreId)
.orElseThrow(() -> new IllegalStateException(
"Lore introuvable (id=" + loreId
+ ") pour la page " + pageId));
}
private LoreNode loadFolder(String nodeId, String pageId) {
return loreNodeRepository.findById(nodeId)
.orElseThrow(() -> new IllegalStateException(
"Dossier parent introuvable (id=" + nodeId
+ ") pour la page " + pageId));
}
private void requireNonEmptyFields(Template template) {
if (template.getFields() == null || template.getFields().isEmpty()) {
throw new IllegalStateException(
"Le template '" + template.getName()
+ "' n'a aucun champ à générer.");
}
}
}

View File

@@ -0,0 +1,181 @@
package com.loremind.application.generationcontext;
import com.loremind.domain.generationcontext.ChatMessage;
import com.loremind.domain.generationcontext.ChatRequest;
import com.loremind.domain.generationcontext.LoreStructuralContext;
import com.loremind.domain.generationcontext.LoreStructuralContext.FolderPage;
import com.loremind.domain.generationcontext.PageContext;
import com.loremind.domain.generationcontext.ports.AiChatProvider;
import com.loremind.domain.lorecontext.Lore;
import com.loremind.domain.lorecontext.LoreNode;
import com.loremind.domain.lorecontext.Page;
import com.loremind.domain.lorecontext.Template;
import com.loremind.domain.lorecontext.ports.LoreNodeRepository;
import com.loremind.domain.lorecontext.ports.LoreRepository;
import com.loremind.domain.lorecontext.ports.PageRepository;
import com.loremind.domain.lorecontext.ports.TemplateRepository;
import org.springframework.stereotype.Service;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Consumer;
import java.util.stream.Collectors;
/**
* Use case applicatif : chat conversationnel avec Structural Context d'un Lore.
*
* Orchestrateur — charge la carte structurelle (dossiers + pages + templates
* + tags) depuis le LoreContext, la traduit vers le GenerationContext, puis
* délègue au port AiChatProvider pour le streaming.
*
* Zéro persistance : la conversation est éphémère (responsabilité du frontend).
*/
@Service
public class StreamChatForLoreUseCase {
private final LoreRepository loreRepository;
private final LoreNodeRepository loreNodeRepository;
private final PageRepository pageRepository;
private final TemplateRepository templateRepository;
private final AiChatProvider aiChatProvider;
public StreamChatForLoreUseCase(
LoreRepository loreRepository,
LoreNodeRepository loreNodeRepository,
PageRepository pageRepository,
TemplateRepository templateRepository,
AiChatProvider aiChatProvider) {
this.loreRepository = loreRepository;
this.loreNodeRepository = loreNodeRepository;
this.pageRepository = pageRepository;
this.templateRepository = templateRepository;
this.aiChatProvider = aiChatProvider;
}
/**
* Streame la réponse du LLM pour le Lore donné avec la conversation fournie.
*
* Méthode bloquante : retourne une fois le stream terminé (onComplete ou onError).
* L'appelant (controller SSE) doit l'exécuter dans un thread dédié.
*
* @param loreId obligatoire — l'univers concerné
* @param pageId optionnel (nullable) — si fourni, focalise l'IA sur cette page
* précise (template, champs, valeurs actuelles).
* @throws IllegalArgumentException si le Lore (ou la Page si pageId fourni) est introuvable
*/
public void execute(
String loreId,
String pageId,
List<ChatMessage> messages,
Consumer<String> onToken,
Runnable onComplete,
Consumer<Throwable> onError) {
LoreStructuralContext loreContext = buildLoreContext(loreId);
PageContext pageContext = (pageId == null || pageId.isBlank())
? null
: buildPageContext(pageId);
ChatRequest request = ChatRequest.builder()
.messages(messages)
.loreContext(loreContext)
.pageContext(pageContext)
.build();
aiChatProvider.streamChat(request, onToken, onComplete, onError);
}
// --- Construction du contexte d'une page précise ------------------------
/**
* Charge la Page + son Template et construit un PageContext prêt à injecter.
* Si le template est absent (page orpheline), on renvoie un PageContext
* minimal (titre + template "?", champs vides) — l'IA reste contextualisée
* sur la page sans pouvoir proposer de champs précis.
*/
private PageContext buildPageContext(String pageId) {
Page page = pageRepository.findById(pageId)
.orElseThrow(() -> new IllegalArgumentException(
"Page non trouvée avec l'ID: " + pageId));
String templateName = "?";
List<String> templateFields = Collections.emptyList();
if (page.hasTemplate()) {
Template template = templateRepository.findById(page.getTemplateId()).orElse(null);
if (template != null) {
templateName = template.getName();
templateFields = template.getFields() != null
? template.getFields()
: Collections.emptyList();
}
}
Map<String, String> values = page.getValues() != null
? page.getValues()
: Collections.emptyMap();
return PageContext.builder()
.title(page.getTitle())
.templateName(templateName)
.templateFields(templateFields)
.values(values)
.build();
}
// --- Construction de la carte structurelle ------------------------------
private LoreStructuralContext buildLoreContext(String loreId) {
Lore lore = loreRepository.findById(loreId)
.orElseThrow(() -> new IllegalArgumentException(
"Lore non trouvé avec l'ID: " + loreId));
List<LoreNode> nodes = loreNodeRepository.findByLoreId(loreId);
List<Page> pages = pageRepository.findByLoreId(loreId);
List<Template> templates = templateRepository.findByLoreId(loreId);
Map<String, List<FolderPage>> folders = buildFoldersMap(nodes, pages, templates);
List<String> tags = extractUniqueTags(pages);
return LoreStructuralContext.builder()
.loreName(lore.getName())
.loreDescription(lore.getDescription())
.folders(folders)
.tags(tags)
.build();
}
private Map<String, List<FolderPage>> buildFoldersMap(
List<LoreNode> nodes, List<Page> pages, List<Template> templates) {
Map<String, String> templateNameById = templates.stream()
.collect(Collectors.toMap(Template::getId, Template::getName, (a, b) -> a));
// LinkedHashMap : préserve l'ordre d'insertion pour un prompt lisible.
Map<String, List<FolderPage>> folders = new LinkedHashMap<>();
for (LoreNode node : nodes) {
folders.put(node.getName(), pagesInFolder(node.getId(), pages, templateNameById));
}
return folders;
}
private List<FolderPage> pagesInFolder(
String nodeId, List<Page> allPages, Map<String, String> templateNameById) {
return allPages.stream()
.filter(p -> nodeId.equals(p.getNodeId()))
.map(p -> FolderPage.builder()
.title(p.getTitle())
.templateName(templateNameById.getOrDefault(p.getTemplateId(), "?"))
.build())
.collect(Collectors.toList());
}
private List<String> extractUniqueTags(List<Page> pages) {
return pages.stream()
.filter(p -> p.getTags() != null)
.flatMap(p -> p.getTags().stream())
.distinct()
.collect(Collectors.toList());
}
}

View File

@@ -0,0 +1,16 @@
package com.loremind.domain.generationcontext;
import lombok.Value;
/**
* Un message d'une conversation avec le LLM.
*
* Rôles acceptés : "user", "assistant", "system".
* Object de valeur immuable — cohérent avec le reste du domaine.
*/
@Value
public class ChatMessage {
String role;
String content;
}

View File

@@ -0,0 +1,22 @@
package com.loremind.domain.generationcontext;
import lombok.Builder;
import lombok.Value;
import java.util.List;
/**
* Object de valeur encapsulant une requête de chat streamé.
*
* Regroupe l'historique de la conversation et le contexte structurel du
* Lore — les deux informations dont l'IA a besoin pour répondre.
*/
@Value
@Builder
public class ChatRequest {
List<ChatMessage> messages;
LoreStructuralContext loreContext;
/** Optionnel : contexte d'une page précise en cours d'édition. Null = chat générique au Lore. */
PageContext pageContext;
}

View File

@@ -0,0 +1,28 @@
package com.loremind.domain.generationcontext;
import lombok.Builder;
import lombok.Value;
import java.util.List;
/**
* Object de valeur (immuable) représentant une demande de génération IA
* pour remplir une Page à partir d'un Template.
*
* Équivalent Java du PageGenerationContext Python (brain/app/domain/models.py).
* Entité pure du domaine : aucune dépendance technique.
*
* Immuable via @Value (Lombok) : pas de setters, tous les champs final.
* C'est un DTO de domaine entrant dans le port AiProvider.
*/
@Value
@Builder
public class GenerationContext {
String loreName;
String loreDescription;
String folderName; // Nom du LoreNode parent (ex: "PNJ", "Lieux")
String templateName;
List<String> templateFields; // Champs à générer (clés attendues dans la réponse)
String pageTitle;
}

View File

@@ -0,0 +1,18 @@
package com.loremind.domain.generationcontext;
import lombok.Value;
import java.util.Map;
/**
* Résultat d'une génération IA : une map fieldName -> valeur générée.
*
* Équivalent Java du PageGenerationResult Python.
* Immuable : une fois reçu, pas de modification (l'UI pourra faire du merge,
* mais pas en mutant cet objet).
*/
@Value
public class GenerationResult {
Map<String, String> values;
}

View File

@@ -0,0 +1,39 @@
package com.loremind.domain.generationcontext;
import lombok.Builder;
import lombok.Singular;
import lombok.Value;
import java.util.List;
import java.util.Map;
/**
* Carte structurelle d'un Lore pour nourrir l'IA sans tout lui envoyer.
*
* Équivalent Java du LoreStructuralContext Python. Pas de contenu des pages,
* uniquement la structure (dossiers, titres, templates, tags). Suffit pour
* que l'IA propose des suggestions cohérentes avec l'existant.
*
* La map `folders` est indexée par nom de dossier et mappe vers la liste
* des pages qu'il contient (liste vide autorisée pour les dossiers vides).
*/
@Value
@Builder
public class LoreStructuralContext {
String loreName;
String loreDescription;
Map<String, List<FolderPage>> folders;
@Singular List<String> tags;
/**
* Résumé minimaliste d'une page : juste son titre et son template.
* Pas de valeurs, pas de notes, pas de tags (pour garder le prompt léger).
*/
@Value
@Builder
public static class FolderPage {
String title;
String templateName;
}
}

View File

@@ -0,0 +1,27 @@
package com.loremind.domain.generationcontext;
import lombok.Builder;
import lombok.Value;
import java.util.List;
import java.util.Map;
/**
* Contexte d'une page spécifique en cours d'édition.
*
* Complément du LoreStructuralContext : l'un donne la carte générale du
* Lore, l'autre zoome sur la page précise en cours de discussion. Permet
* à l'IA de focaliser ses suggestions sur les bons champs sans déborder
* sur d'autres pages/templates.
*
* Object de valeur immuable, pur domaine — aucune dépendance technique.
*/
@Value
@Builder
public class PageContext {
String title;
String templateName;
List<String> templateFields;
Map<String, String> values;
}

View File

@@ -0,0 +1,41 @@
package com.loremind.domain.generationcontext.ports;
import com.loremind.domain.generationcontext.ChatRequest;
import java.util.function.Consumer;
/**
* Port de sortie pour le chat streamé avec un LLM.
*
* Distinct de AiProvider (one-shot) par Interface Segregation Principle :
* le streaming est une capacité séparée avec un contrat propre. Un même
* adapter concret peut satisfaire les deux ports s'il le souhaite.
*
* API par callbacks (plutôt que Flux/Stream) pour garder le domaine libre
* de toute dépendance à Reactor. Les couches supérieures (controller SSE)
* s'adaptent naturellement à ce style.
*/
public interface AiChatProvider {
/**
* Streame la réponse du LLM en invoquant les callbacks au fil de l'eau.
*
* Cette méthode est bloquante : elle ne rend la main qu'après la fin
* du stream (appel à onComplete ou onError). L'appelant est responsable
* de l'exécuter dans un thread adapté (ex: thread dédié à la requête
* HTTP côté controller SSE).
*
* @param request messages + contexte Lore
* @param onToken invoqué à chaque token reçu du LLM (peut être appelé
* de nombreuses fois)
* @param onComplete invoqué une fois le stream terminé avec succès
* @param onError invoqué en cas d'erreur (Brain injoignable, timeout,
* réponse invalide). Exclusif avec onComplete.
*/
void streamChat(
ChatRequest request,
Consumer<String> onToken,
Runnable onComplete,
Consumer<Throwable> onError
);
}

View File

@@ -0,0 +1,25 @@
package com.loremind.domain.generationcontext.ports;
import com.loremind.domain.generationcontext.GenerationContext;
import com.loremind.domain.generationcontext.GenerationResult;
/**
* Port de sortie pour la génération IA.
*
* Le domaine ne connaît pas l'implémentation (HTTP vers Brain Python,
* appel direct à OpenAI, mock en test, etc.). Il manipule uniquement
* cette interface.
*
* C'est l'équivalent Java du Protocol LLMProvider côté Python —
* même pattern hexagonal des deux côtés de la frontière réseau.
*/
public interface AiProvider {
/**
* Génère les valeurs des champs d'une Page à partir du contexte fourni.
*
* @throws AiProviderException si le fournisseur IA est indisponible,
* renvoie une réponse invalide ou dépasse le timeout.
*/
GenerationResult generatePage(GenerationContext context) throws AiProviderException;
}

View File

@@ -0,0 +1,22 @@
package com.loremind.domain.generationcontext.ports;
/**
* Exception de domaine signalant un échec du fournisseur IA.
*
* Équivalent Java de LLMProviderError (Python). Hérite de RuntimeException
* pour rester cohérent avec le reste du code (pas d'exceptions checked
* qui polluent les signatures de méthodes).
*
* L'Adapter (BrainAiClient) traduira toute erreur technique (timeout,
* 5xx, JSON invalide) en AiProviderException avant de la propager.
*/
public class AiProviderException extends RuntimeException {
public AiProviderException(String message) {
super(message);
}
public AiProviderException(String message, Throwable cause) {
super(message, cause);
}
}

View File

@@ -0,0 +1,178 @@
package com.loremind.infrastructure.ai;
import com.loremind.domain.generationcontext.ChatMessage;
import com.loremind.domain.generationcontext.ChatRequest;
import com.loremind.domain.generationcontext.LoreStructuralContext;
import com.loremind.domain.generationcontext.LoreStructuralContext.FolderPage;
import com.loremind.domain.generationcontext.PageContext;
import com.loremind.domain.generationcontext.ports.AiChatProvider;
import com.loremind.domain.generationcontext.ports.AiProviderException;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.MediaType;
import org.springframework.http.codec.ServerSentEvent;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Flux;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Consumer;
import java.util.stream.Collectors;
/**
* Adapter de sortie : implémente AiChatProvider en appelant
* le Brain Python via WebClient + SSE (Server-Sent Events).
*
* Responsabilités :
* 1. Traduire ChatRequest (domaine) -> JSON attendu par /chat/stream.
* 2. Consommer le flux SSE token par token.
* 3. Invoquer onToken / onComplete / onError au bon moment.
* 4. Traduire toute erreur technique en AiProviderException.
*
* Le domaine ne voit JAMAIS WebClient, Flux, ni la moindre URL.
*/
@Component
public class BrainAiChatClient implements AiChatProvider {
private static final String CHAT_STREAM_PATH = "/chat/stream";
private static final ParameterizedTypeReference<ServerSentEvent<String>> SSE_STRING_TYPE =
new ParameterizedTypeReference<>() {};
private final WebClient webClient;
public BrainAiChatClient(
WebClient.Builder builder,
@Value("${brain.base-url}") String baseUrl) {
this.webClient = builder.baseUrl(baseUrl).build();
}
@Override
public void streamChat(
ChatRequest request,
Consumer<String> onToken,
Runnable onComplete,
Consumer<Throwable> onError) {
Map<String, Object> payload = toPayload(request);
Flux<ServerSentEvent<String>> flux = webClient.post()
.uri(CHAT_STREAM_PATH)
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.TEXT_EVENT_STREAM)
.bodyValue(payload)
.retrieve()
.bodyToFlux(SSE_STRING_TYPE);
try {
// blockLast() : transforme le flux réactif en appel bloquant conforme
// au contrat synchrone du port. L'appelant choisit le thread.
flux
.timeout(Duration.ofSeconds(120))
.doOnNext(sse -> handleEvent(sse, onToken, onError))
.blockLast();
onComplete.run();
} catch (Exception e) {
onError.accept(new AiProviderException(
"Erreur lors du streaming chat depuis le Brain.", e));
}
}
/** Dispatch selon le type d'événement SSE (data par défaut, done, error). */
private void handleEvent(
ServerSentEvent<String> sse,
Consumer<String> onToken,
Consumer<Throwable> onError) {
String event = sse.event(); // null si pas d'event: xxx -> c'est un data par défaut
String data = sse.data();
if ("error".equals(event)) {
onError.accept(new AiProviderException(
"Le Brain a signalé une erreur : " + data));
return;
}
if ("done".equals(event)) {
return; // la fin est gérée par blockLast + onComplete
}
// Défaut : événement data avec JSON {"token":"..."}.
String token = extractToken(data);
if (token != null && !token.isEmpty()) {
onToken.accept(token);
}
}
/**
* Parse minimaliste du JSON {"token":"..."} sans pull Jackson ici.
* Si le format se complexifie, on remplacera par un DTO Jackson.
*/
private String extractToken(String json) {
if (json == null) return null;
int idx = json.indexOf("\"token\"");
if (idx < 0) return null;
int colon = json.indexOf(':', idx);
int firstQuote = json.indexOf('"', colon + 1);
int lastQuote = json.lastIndexOf('"');
if (firstQuote < 0 || lastQuote <= firstQuote) return null;
return json.substring(firstQuote + 1, lastQuote)
.replace("\\n", "\n")
.replace("\\\"", "\"")
.replace("\\\\", "\\");
}
// --- Construction du payload JSON vers le Brain -------------------------
private Map<String, Object> toPayload(ChatRequest request) {
Map<String, Object> root = new LinkedHashMap<>();
root.put("messages", request.getMessages().stream()
.map(this::messageToMap)
.collect(Collectors.toList()));
root.put("lore_context", loreContextToMap(request.getLoreContext()));
// page_context est optionnel côté Brain (Pydantic l'accepte null).
// On ne l'ajoute au payload que s'il est effectivement fourni.
if (request.getPageContext() != null) {
root.put("page_context", pageContextToMap(request.getPageContext()));
}
return root;
}
private Map<String, Object> pageContextToMap(PageContext pc) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("title", pc.getTitle());
map.put("template_name", pc.getTemplateName());
map.put("template_fields", pc.getTemplateFields());
map.put("values", pc.getValues());
return map;
}
private Map<String, Object> messageToMap(ChatMessage m) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("role", m.getRole());
map.put("content", m.getContent());
return map;
}
private Map<String, Object> loreContextToMap(LoreStructuralContext ctx) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("lore_name", ctx.getLoreName());
map.put("lore_description", ctx.getLoreDescription());
Map<String, Object> foldersMap = new LinkedHashMap<>();
for (Map.Entry<String, List<FolderPage>> e : ctx.getFolders().entrySet()) {
foldersMap.put(e.getKey(), e.getValue().stream()
.map(this::folderPageToMap)
.collect(Collectors.toList()));
}
map.put("folders", foldersMap);
map.put("tags", ctx.getTags());
return map;
}
private Map<String, Object> folderPageToMap(FolderPage fp) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("title", fp.getTitle());
map.put("template_name", fp.getTemplateName());
return map;
}
}

View File

@@ -0,0 +1,104 @@
package com.loremind.infrastructure.ai;
import com.loremind.domain.generationcontext.GenerationContext;
import com.loremind.domain.generationcontext.GenerationResult;
import com.loremind.domain.generationcontext.ports.AiProvider;
import com.loremind.domain.generationcontext.ports.AiProviderException;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.client.ResourceAccessException;
import org.springframework.web.client.RestClientResponseException;
import org.springframework.web.client.RestTemplate;
import java.util.Map;
/**
* Adapter de sortie : implémente le port AiProvider en appelant
* le Brain Python via HTTP (RestTemplate).
*
* Responsabilités exclusives de cette classe :
* 1. Traduire GenerationContext (domaine) -> BrainGeneratePageRequest (wire).
* 2. Exécuter l'appel HTTP POST /generate-page.
* 3. Traduire BrainGeneratePageResponse (wire) -> GenerationResult (domaine).
* 4. Traduire toute erreur technique en AiProviderException (exception de domaine).
*
* Le domaine ne voit JAMAIS RestTemplate, Jackson, ni la moindre URL.
*/
@Component
public class BrainAiClient implements AiProvider {
private static final String GENERATE_PAGE_PATH = "/generate-page";
private final RestTemplate restTemplate;
private final String baseUrl;
public BrainAiClient(
RestTemplate restTemplate,
@Value("${brain.base-url}") String baseUrl) {
this.restTemplate = restTemplate;
this.baseUrl = baseUrl;
}
@Override
public GenerationResult generatePage(GenerationContext context) {
BrainGeneratePageRequest request = toBrainRequest(context);
BrainGeneratePageResponse response = callBrain(request);
return toDomainResult(response);
}
// --- Traduction domaine -> wire -----------------------------------------
private BrainGeneratePageRequest toBrainRequest(GenerationContext context) {
return new BrainGeneratePageRequest(
context.getLoreName(),
context.getLoreDescription(),
context.getFolderName(),
context.getTemplateName(),
context.getTemplateFields(),
context.getPageTitle()
);
}
// --- Appel HTTP + traduction d'erreurs ----------------------------------
private BrainGeneratePageResponse callBrain(BrainGeneratePageRequest request) {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<BrainGeneratePageRequest> entity = new HttpEntity<>(request, headers);
try {
BrainGeneratePageResponse response = restTemplate.postForObject(
baseUrl + GENERATE_PAGE_PATH,
entity,
BrainGeneratePageResponse.class
);
if (response == null || response.getValues() == null) {
throw new AiProviderException("Le Brain a renvoyé une réponse vide.");
}
return response;
} catch (ResourceAccessException e) {
// Timeout ou connexion impossible (Brain down)
throw new AiProviderException(
"Le Brain est injoignable (timeout ou service arrêté).", e);
} catch (RestClientResponseException e) {
// Code HTTP 4xx/5xx renvoyé par le Brain
throw new AiProviderException(
"Le Brain a répondu avec une erreur HTTP " + e.getStatusCode().value(), e);
} catch (AiProviderException e) {
throw e; // déjà traduite, ne pas ré-envelopper
} catch (Exception e) {
// Filet de sécurité (JSON invalide, etc.)
throw new AiProviderException(
"Erreur inattendue lors de l'appel au Brain.", e);
}
}
// --- Traduction wire -> domaine -----------------------------------------
private GenerationResult toDomainResult(BrainGeneratePageResponse response) {
return new GenerationResult(Map.copyOf(response.getValues()));
}
}

View File

@@ -0,0 +1,37 @@
package com.loremind.infrastructure.ai;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor;
import lombok.Value;
import java.util.List;
/**
* DTO interne de l'Adapter : format JSON envoyé au Brain Python.
* Package-private : n'existe que pour la couche infrastructure.
*
* Le contrat HTTP côté Python utilise snake_case — on le matche ici
* pour éviter de configurer Jackson globalement (impact sur le reste du projet).
*/
@Value
@AllArgsConstructor
class BrainGeneratePageRequest {
@JsonProperty("lore_name")
String loreName;
@JsonProperty("lore_description")
String loreDescription;
@JsonProperty("folder_name")
String folderName;
@JsonProperty("template_name")
String templateName;
@JsonProperty("template_fields")
List<String> templateFields;
@JsonProperty("page_title")
String pageTitle;
}

View File

@@ -0,0 +1,20 @@
package com.loremind.infrastructure.ai;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.Map;
/**
* DTO interne de l'Adapter : format JSON reçu du Brain Python.
*
* @Data + @NoArgsConstructor : nécessaire à Jackson pour la désérialisation.
*/
@Data
@NoArgsConstructor
class BrainGeneratePageResponse {
@JsonProperty("values")
private Map<String, String> values;
}

View File

@@ -0,0 +1,29 @@
package com.loremind.infrastructure.ai;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestTemplate;
import java.time.Duration;
/**
* Configuration Spring fournissant un RestTemplate avec timeout adapté
* aux appels vers le Brain (LLM local parfois lent).
*
* Ce bean est réutilisable par tout futur Adapter HTTP du projet.
*/
@Configuration
public class RestTemplateConfig {
@Bean
public RestTemplate brainRestTemplate(
RestTemplateBuilder builder,
@Value("${brain.timeout-seconds}") long timeoutSeconds) {
return builder
.setConnectTimeout(Duration.ofSeconds(10))
.setReadTimeout(Duration.ofSeconds(timeoutSeconds))
.build();
}
}

View File

@@ -0,0 +1,142 @@
package com.loremind.infrastructure.web.controller;
import com.loremind.application.generationcontext.StreamChatForLoreUseCase;
import com.loremind.domain.generationcontext.ChatMessage;
import com.loremind.infrastructure.web.dto.generationcontext.ChatMessageDTO;
import com.loremind.infrastructure.web.dto.generationcontext.ChatStreamRequestDTO;
import org.springframework.core.task.AsyncTaskExecutor;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.io.IOException;
import java.util.List;
import java.util.stream.Collectors;
/**
* REST Controller pour le chat IA streamé (Server-Sent Events).
*
* POST /api/ai/chat/stream → flux SSE de tokens
*
* Le streaming est lancé dans un thread séparé (AsyncTaskExecutor) pour
* ne pas bloquer le thread servlet pendant toute la durée de la génération.
* SseEmitter est thread-safe : les callbacks du port AiChatProvider peuvent
* écrire directement dessus depuis n'importe quel thread.
*/
@RestController
@RequestMapping("/api/ai")
public class AiChatController {
/** Timeout SSE long — les modèles LLM locaux peuvent générer pendant quelques minutes. */
private static final long SSE_TIMEOUT_MS = 5 * 60 * 1000L;
private final StreamChatForLoreUseCase streamChatForLoreUseCase;
private final AsyncTaskExecutor taskExecutor;
public AiChatController(
StreamChatForLoreUseCase streamChatForLoreUseCase,
AsyncTaskExecutor taskExecutor) {
this.streamChatForLoreUseCase = streamChatForLoreUseCase;
this.taskExecutor = taskExecutor;
}
@PostMapping(value = "/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter chatStream(@RequestBody ChatStreamRequestDTO body) {
SseEmitter emitter = new SseEmitter(SSE_TIMEOUT_MS);
List<ChatMessage> messages = toDomainMessages(body.getMessages());
taskExecutor.execute(() -> runStreaming(emitter, body.getLoreId(), body.getPageId(), messages));
return emitter;
}
// --- Exécution du streaming dans un thread dédié ------------------------
private void runStreaming(SseEmitter emitter, String loreId, String pageId, List<ChatMessage> messages) {
try {
streamChatForLoreUseCase.execute(
loreId,
pageId,
messages,
token -> sendToken(emitter, token),
() -> complete(emitter),
error -> fail(emitter, error)
);
} catch (IllegalArgumentException e) {
// Lore ou Page introuvable : on envoie un event error puis on termine proprement.
fail(emitter, e);
} catch (Exception e) {
fail(emitter, e);
}
}
// --- Helpers SSE (un seul point d'écriture par type d'événement) --------
private void sendToken(SseEmitter emitter, String token) {
try {
emitter.send(SseEmitter.event()
.data("{\"token\":" + jsonEscape(token) + "}"));
} catch (IOException e) {
emitter.completeWithError(e);
}
}
private void complete(SseEmitter emitter) {
try {
emitter.send(SseEmitter.event().name("done").data("{}"));
emitter.complete();
} catch (IOException e) {
emitter.completeWithError(e);
}
}
private void fail(SseEmitter emitter, Throwable error) {
try {
String message = error.getMessage() != null ? error.getMessage() : error.getClass().getSimpleName();
emitter.send(SseEmitter.event()
.name("error")
.data("{\"message\":" + jsonEscape(message) + "}"));
emitter.complete();
} catch (IOException ioe) {
emitter.completeWithError(ioe);
}
}
// --- Utilitaires --------------------------------------------------------
/** Encadre une chaîne de guillemets et échappe les caractères JSON dangereux. */
private String jsonEscape(String raw) {
if (raw == null) return "\"\"";
StringBuilder sb = new StringBuilder(raw.length() + 2);
sb.append('"');
for (int i = 0; i < raw.length(); i++) {
char c = raw.charAt(i);
switch (c) {
case '"': sb.append("\\\""); break;
case '\\': sb.append("\\\\"); break;
case '\n': sb.append("\\n"); break;
case '\r': sb.append("\\r"); break;
case '\t': sb.append("\\t"); break;
default:
if (c < 0x20) {
sb.append(String.format("\\u%04x", (int) c));
} else {
sb.append(c);
}
}
}
sb.append('"');
return sb.toString();
}
private List<ChatMessage> toDomainMessages(List<ChatMessageDTO> dtos) {
if (dtos == null) return List.of();
return dtos.stream()
.map(dto -> new ChatMessage(dto.getRole(), dto.getContent()))
.collect(Collectors.toList());
}
}

View File

@@ -0,0 +1,66 @@
package com.loremind.infrastructure.web.controller;
import com.loremind.application.generationcontext.GeneratePageValuesUseCase;
import com.loremind.domain.generationcontext.ports.AiProviderException;
import com.loremind.infrastructure.web.dto.generationcontext.GenerationSuggestionsDTO;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
/**
* REST Controller pour la génération IA d'une Page.
*
* POST /api/pages/{id}/generate → suggestions de valeurs (non persistées)
*
* Endpoint séparé de PageController par SRP : il expose le GenerationContext,
* pas le CRUD de Page. URL RESTful conservée (action sur une ressource Page).
*/
@RestController
@RequestMapping("/api/pages")
public class PageGenerationController {
private final GeneratePageValuesUseCase generatePageValuesUseCase;
public PageGenerationController(GeneratePageValuesUseCase generatePageValuesUseCase) {
this.generatePageValuesUseCase = generatePageValuesUseCase;
}
/**
* Demande à l'IA de suggérer des valeurs pour les champs dynamiques
* d'une Page. Ne modifie PAS la Page en base.
*
* Codes retour :
* 200 — suggestions renvoyées
* 404 — page introuvable
* 422 — template sans champs (rien à générer)
* 502 — Brain Python indisponible ou en erreur
* 500 — incohérence BDD (template/lore/dossier introuvable)
*/
@PostMapping("/{id}/generate")
public ResponseEntity<GenerationSuggestionsDTO> generate(@PathVariable String id) {
try {
Map<String, String> values = generatePageValuesUseCase.execute(id);
return ResponseEntity.ok(new GenerationSuggestionsDTO(values));
} catch (IllegalArgumentException e) {
// Page introuvable — faute de l'appelant
return ResponseEntity.notFound().build();
} catch (AiProviderException e) {
// Brain down / timeout / réponse invalide
return ResponseEntity.status(HttpStatus.BAD_GATEWAY).build();
} catch (IllegalStateException e) {
// Distinction fine : template sans champs (422) vs autre incohérence BDD (500)
if (e.getMessage() != null && e.getMessage().contains("aucun champ")) {
return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY).build();
}
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
}

View File

@@ -0,0 +1,18 @@
package com.loremind.infrastructure.web.dto.generationcontext;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* DTO HTTP pour un message d'une conversation.
* Rôles acceptés : "user", "assistant", "system".
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class ChatMessageDTO {
private String role;
private String content;
}

View File

@@ -0,0 +1,26 @@
package com.loremind.infrastructure.web.dto.generationcontext;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
/**
* DTO HTTP de requête pour POST /api/ai/chat/stream.
*
* Le Core charge lui-même le Structural Context à partir de {loreId}.
* Le frontend envoie uniquement l'historique de la conversation éphémère.
*/
@Data
@NoArgsConstructor
public class ChatStreamRequestDTO {
private String loreId;
/**
* Optionnel : si fourni, l'IA reçoit aussi un PageContext focalisé sur
* cette page précise (titre, template, champs, valeurs actuelles).
* Sans pageId, le chat reste générique au Lore.
*/
private String pageId;
private List<ChatMessageDTO> messages;
}

View File

@@ -0,0 +1,22 @@
package com.loremind.infrastructure.web.dto.generationcontext;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.Map;
/**
* DTO exposé au frontend pour la génération IA d'une Page.
* Format : { "values": { "fieldName1": "suggestion1", ... } }
*
* On renvoie la map telle quelle — pas de tri, pas de filtrage côté serveur.
* L'UI décide comment merger avec les valeurs déjà saisies par l'utilisateur.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class GenerationSuggestionsDTO {
private Map<String, String> values;
}

View File

@@ -1,6 +1,10 @@
# Configuration du serveur
server.port=8080
# On garde Tomcat (Web MVC) comme serveur primaire, malgré la présence
# de WebFlux (utilisé uniquement pour WebClient côté adapter SSE vers le Brain).
spring.main.web-application-type=servlet
# Configuration de la base de données PostgreSQL
spring.datasource.url=jdbc:postgresql://localhost:5432/loremind
spring.datasource.username=ietm64
@@ -18,3 +22,7 @@ spring.web.cors.allowed-origins=http://localhost:4200
spring.web.cors.allowed-methods=GET,POST,PUT,DELETE,OPTIONS
spring.web.cors.allowed-headers=*
spring.web.cors.allow-credentials=true
# Configuration du Brain (service IA Python)
brain.base-url=http://localhost:8000
brain.timeout-seconds=120