Ajout de la partie IA
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user