Plusieurs gros ajouts :
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Build & Push Images / build-switcher (push) Has been cancelled

- Possibilité de discuter avec un PDF ; RAG ou analyse approfondie. Enlèvement de l'autre outil PDF de discussion qui analysait d'abord un PDF en proposant directement une intégration sans attendre qu'on pose de question
- Mise en place de l'import directement dans les outils dans la sidebar
- Mise en place d'un outil pour créer des tables aléatoires avec possibilité d'utiliser pendant la partie
- Mise en place d'un outil pour mettre en place des PNJ, scènes, chapitre.... directement à partir de la discussion avec le PDF
- Mise en place RAG avec mistal-embeding ou nomic si on utilise ollama
- Mise en place mistral, google en fournisseurs alternatifs pour l'IA dans le cloud
- version 0.11.0-bêta
This commit is contained in:
2026-06-07 09:52:15 +02:00
parent 5eb15dc449
commit edc4434298
113 changed files with 6347 additions and 662 deletions

View File

@@ -1,46 +1,32 @@
package com.loremind.application.campaigncontext;
import com.loremind.application.generationcontext.CampaignStructuralContextBuilder;
import com.loremind.application.generationcontext.LoreStructuralContextBuilder;
import com.loremind.domain.campaigncontext.Campaign;
import com.loremind.domain.campaigncontext.ports.CampaignPdfAdvisor;
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.generationcontext.CampaignStructuralContext;
import com.loremind.domain.generationcontext.CampaignStructuralContext.ArcSummary;
import com.loremind.domain.generationcontext.CampaignStructuralContext.ChapterSummary;
import com.loremind.domain.generationcontext.CampaignStructuralContext.NpcSummary;
import com.loremind.domain.generationcontext.CampaignStructuralContext.SceneSummary;
import com.loremind.domain.generationcontext.LoreStructuralContext;
import com.loremind.domain.generationcontext.LoreStructuralContext.PageSummary;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Map;
import java.util.function.Consumer;
/**
* Service applicatif : conseils d'adaptation d'un PDF à une campagne existante.
*
* <p>Assemble un « brief » de la campagne (structure + PNJ + univers/lore) — la même
* matière que le chat de campagne — et délègue la génération streamée au Brain via
* <p>Assemble un « brief » de la campagne (structure + PNJ + univers/lore) via
* {@link CampaignBriefBuilder} et délègue la génération streamée au Brain via
* {@link CampaignPdfAdvisor}. Ne persiste rien : la sortie est du conseil libre.</p>
*/
@Service
public class CampaignAdaptService {
private final CampaignRepository campaignRepository;
private final CampaignStructuralContextBuilder campaignContextBuilder;
private final LoreStructuralContextBuilder loreContextBuilder;
private final CampaignBriefBuilder briefBuilder;
private final CampaignPdfAdvisor advisor;
public CampaignAdaptService(
CampaignRepository campaignRepository,
CampaignStructuralContextBuilder campaignContextBuilder,
LoreStructuralContextBuilder loreContextBuilder,
CampaignBriefBuilder briefBuilder,
CampaignPdfAdvisor advisor) {
this.campaignRepository = campaignRepository;
this.campaignContextBuilder = campaignContextBuilder;
this.loreContextBuilder = loreContextBuilder;
this.briefBuilder = briefBuilder;
this.advisor = advisor;
}
@@ -55,64 +41,6 @@ public class CampaignAdaptService {
Campaign campaign = campaignRepository.findById(campaignId)
.orElseThrow(() -> new IllegalArgumentException("Campagne introuvable : " + campaignId));
advisor.adviseStreaming(
pdfBytes, filename, buildBrief(campaign), messagesJson, onToken, onComplete, onError);
}
/** Construit un résumé markdown de la campagne (structure + PNJ + lore). */
private String buildBrief(Campaign campaign) {
CampaignStructuralContext cc = campaignContextBuilder.build(campaign.getId());
StringBuilder sb = new StringBuilder();
sb.append("# Campagne : ").append(cc.campaignName()).append("\n");
if (notBlank(cc.campaignDescription())) sb.append(cc.campaignDescription()).append("\n");
sb.append("\n## Structure (arcs → chapitres → scènes)\n");
if (cc.arcs().isEmpty()) {
sb.append("_(aucun arc pour le moment)_\n");
}
for (ArcSummary arc : cc.arcs()) {
sb.append("### Arc : ").append(arc.name());
if (notBlank(arc.description())) sb.append("").append(arc.description());
sb.append("\n");
for (ChapterSummary ch : arc.chapters()) {
sb.append("- Chapitre : ").append(ch.name());
if (notBlank(ch.description())) sb.append("").append(ch.description());
sb.append("\n");
for (SceneSummary sc : ch.scenes()) {
sb.append(" - Scène : ").append(sc.name());
if (notBlank(sc.description())) sb.append("").append(sc.description());
sb.append("\n");
}
}
}
if (!cc.npcs().isEmpty()) {
sb.append("\n## PNJ existants\n");
for (NpcSummary n : cc.npcs()) {
sb.append("- ").append(n.name());
if (notBlank(n.snippet())) sb.append(" : ").append(n.snippet());
sb.append("\n");
}
}
if (campaign.isLinkedToLore()) {
loreContextBuilder.buildOptional(campaign.getLoreId()).ifPresent(lore -> appendLore(sb, lore));
}
return sb.toString();
}
private void appendLore(StringBuilder sb, LoreStructuralContext lore) {
sb.append("\n## Univers (Lore) : ").append(lore.loreName()).append("\n");
if (notBlank(lore.loreDescription())) sb.append(lore.loreDescription()).append("\n");
for (Map.Entry<String, List<PageSummary>> entry : lore.folders().entrySet()) {
sb.append("### ").append(entry.getKey()).append("\n");
for (PageSummary page : entry.getValue()) {
sb.append("- ").append(page.title()).append("\n");
}
}
}
private static boolean notBlank(String s) {
return s != null && !s.isBlank();
pdfBytes, filename, briefBuilder.build(campaign), messagesJson, onToken, onComplete, onError);
}
}

View File

@@ -0,0 +1,93 @@
package com.loremind.application.campaigncontext;
import com.loremind.application.generationcontext.CampaignStructuralContextBuilder;
import com.loremind.application.generationcontext.LoreStructuralContextBuilder;
import com.loremind.domain.campaigncontext.Campaign;
import com.loremind.domain.generationcontext.CampaignStructuralContext;
import com.loremind.domain.generationcontext.CampaignStructuralContext.ArcSummary;
import com.loremind.domain.generationcontext.CampaignStructuralContext.ChapterSummary;
import com.loremind.domain.generationcontext.CampaignStructuralContext.NpcSummary;
import com.loremind.domain.generationcontext.CampaignStructuralContext.SceneSummary;
import com.loremind.domain.generationcontext.LoreStructuralContext;
import com.loremind.domain.generationcontext.LoreStructuralContext.PageSummary;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Map;
/**
* Construit un résumé markdown d'une campagne (structure arcs→chapitres→scènes +
* PNJ + univers/lore). Partagé par les fonctions IA qui doivent « voir » la
* campagne : conseils d'adaptation PDF (CampaignAdaptService) et ateliers RAG
* (NotebookService). Centralisé ici pour une seule source de vérité.
*/
@Service
public class CampaignBriefBuilder {
private final CampaignStructuralContextBuilder campaignContextBuilder;
private final LoreStructuralContextBuilder loreContextBuilder;
public CampaignBriefBuilder(
CampaignStructuralContextBuilder campaignContextBuilder,
LoreStructuralContextBuilder loreContextBuilder) {
this.campaignContextBuilder = campaignContextBuilder;
this.loreContextBuilder = loreContextBuilder;
}
public String build(Campaign campaign) {
CampaignStructuralContext cc = campaignContextBuilder.build(campaign.getId());
StringBuilder sb = new StringBuilder();
sb.append("# Campagne : ").append(cc.campaignName()).append("\n");
if (notBlank(cc.campaignDescription())) sb.append(cc.campaignDescription()).append("\n");
sb.append("\n## Structure (arcs → chapitres → scènes)\n");
if (cc.arcs().isEmpty()) {
sb.append("_(aucun arc pour le moment)_\n");
}
for (ArcSummary arc : cc.arcs()) {
sb.append("### Arc : ").append(arc.name());
if (notBlank(arc.description())) sb.append("").append(arc.description());
sb.append("\n");
for (ChapterSummary ch : arc.chapters()) {
sb.append("- Chapitre : ").append(ch.name());
if (notBlank(ch.description())) sb.append("").append(ch.description());
sb.append("\n");
for (SceneSummary sc : ch.scenes()) {
sb.append(" - Scène : ").append(sc.name());
if (notBlank(sc.description())) sb.append("").append(sc.description());
sb.append("\n");
}
}
}
if (!cc.npcs().isEmpty()) {
sb.append("\n## PNJ existants\n");
for (NpcSummary n : cc.npcs()) {
sb.append("- ").append(n.name());
if (notBlank(n.snippet())) sb.append(" : ").append(n.snippet());
sb.append("\n");
}
}
if (campaign.isLinkedToLore()) {
loreContextBuilder.buildOptional(campaign.getLoreId()).ifPresent(lore -> appendLore(sb, lore));
}
return sb.toString();
}
private void appendLore(StringBuilder sb, LoreStructuralContext lore) {
sb.append("\n## Univers (Lore) : ").append(lore.loreName()).append("\n");
if (notBlank(lore.loreDescription())) sb.append(lore.loreDescription()).append("\n");
for (Map.Entry<String, List<PageSummary>> entry : lore.folders().entrySet()) {
sb.append("### ").append(entry.getKey()).append("\n");
for (PageSummary page : entry.getValue()) {
sb.append("- ").append(page.title()).append("\n");
}
}
}
private static boolean notBlank(String s) {
return s != null && !s.isBlank();
}
}

View File

@@ -0,0 +1,132 @@
package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Campaign;
import com.loremind.domain.campaigncontext.Notebook;
import com.loremind.domain.campaigncontext.NotebookMessage;
import com.loremind.domain.campaigncontext.NotebookSource;
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.campaigncontext.ports.NotebookIndexer;
import com.loremind.domain.campaigncontext.ports.NotebookRepository;
import org.springframework.stereotype.Service;
import java.util.List;
/**
* Service d'application des notebooks (atelier RAG) : CRUD, indexation des sources
* (déléguée au Brain), conversation persistée, et assemblage du contexte campagne.
*/
@Service
public class NotebookService {
private final NotebookRepository repository;
private final NotebookIndexer indexer;
private final CampaignRepository campaignRepository;
private final CampaignBriefBuilder briefBuilder;
public NotebookService(
NotebookRepository repository,
NotebookIndexer indexer,
CampaignRepository campaignRepository,
CampaignBriefBuilder briefBuilder) {
this.repository = repository;
this.indexer = indexer;
this.campaignRepository = campaignRepository;
this.briefBuilder = briefBuilder;
}
// --- Notebooks ---
public Notebook createNotebook(String campaignId, String name) {
String safeName = (name == null || name.isBlank()) ? "Nouvel atelier" : name.trim();
return repository.save(Notebook.builder().campaignId(campaignId).name(safeName).build());
}
public java.util.Optional<Notebook> getNotebook(String id) {
return repository.findById(id);
}
public List<Notebook> getNotebooksByCampaign(String campaignId) {
return repository.findByCampaignId(campaignId);
}
public Notebook renameNotebook(String id, String name) {
Notebook nb = repository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Notebook introuvable: " + id));
nb.setName((name == null || name.isBlank()) ? nb.getName() : name.trim());
return repository.save(nb);
}
public void deleteNotebook(String id) {
// Supprime les vecteurs de chaque source côté Brain (best-effort) avant la BDD.
for (NotebookSource s : repository.findSourcesByNotebookId(id)) {
indexer.delete(s.getId());
}
repository.deleteById(id);
}
// --- Sources ---
public List<NotebookSource> getSources(String notebookId) {
return repository.findSourcesByNotebookId(notebookId);
}
/**
* Ajoute une source : crée la ligne (INDEXING), lance l'indexation Brain, puis
* met à jour (READY + compteurs) ou (FAILED) en cas d'échec — et relaie l'erreur.
*/
public NotebookSource addSource(String notebookId, String filename, byte[] pdfBytes) {
if (!repository.existsById(notebookId)) {
throw new IllegalArgumentException("Notebook introuvable: " + notebookId);
}
NotebookSource source = repository.saveSource(NotebookSource.builder()
.notebookId(notebookId)
.filename(filename != null && !filename.isBlank() ? filename : "source.pdf")
.status("INDEXING")
.build());
try {
NotebookIndexer.IndexResult result = indexer.index(source.getId(), pdfBytes, filename);
source.setStatus("READY");
source.setChunkCount(result.chunks());
source.setPageCount(result.pageCount());
return repository.saveSource(source);
} catch (RuntimeException e) {
source.setStatus("FAILED");
repository.saveSource(source);
throw e;
}
}
public void deleteSource(String sourceId) {
repository.findSourceById(sourceId).ifPresent(s -> indexer.delete(s.getId()));
repository.deleteSourceById(sourceId);
}
public List<String> readySourceIds(String notebookId) {
return repository.findSourcesByNotebookId(notebookId).stream()
.filter(s -> "READY".equals(s.getStatus()))
.map(NotebookSource::getId)
.toList();
}
// --- Conversation ---
public List<NotebookMessage> getMessages(String notebookId) {
return repository.findMessagesByNotebookId(notebookId);
}
public NotebookMessage addMessage(String notebookId, String role, String content) {
return repository.saveMessage(NotebookMessage.builder()
.notebookId(notebookId).role(role).content(content).build());
}
// --- Contexte campagne (oriente l'IA) ---
/** Brief COMPLET de la campagne (structure arcs/chapitres/scènes + PNJ + lore) :
* l'IA « voit » la campagne, pas seulement son nom. */
public String buildContext(String campaignId) {
if (campaignId == null) return "";
Campaign campaign = campaignRepository.findById(campaignId).orElse(null);
if (campaign == null) return "";
return briefBuilder.build(campaign);
}
}

View File

@@ -0,0 +1,133 @@
package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Campaign;
import com.loremind.domain.campaigncontext.RandomTable;
import com.loremind.domain.campaigncontext.RandomTableEntry;
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.campaigncontext.ports.RandomTableGenerator;
import com.loremind.domain.campaigncontext.ports.RandomTableRepository;
import com.loremind.domain.gamesystemcontext.ports.GameSystemRepository;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
/**
* Service d'application pour les tables aléatoires (campagne).
*/
@Service
public class RandomTableService {
private final RandomTableRepository repository;
private final RandomTableGenerator generator;
private final CampaignRepository campaignRepository;
private final GameSystemRepository gameSystemRepository;
public RandomTableService(
RandomTableRepository repository,
RandomTableGenerator generator,
CampaignRepository campaignRepository,
GameSystemRepository gameSystemRepository) {
this.repository = repository;
this.generator = generator;
this.campaignRepository = campaignRepository;
this.gameSystemRepository = gameSystemRepository;
}
public record TableData(
String name,
String description,
String diceFormula,
String icon,
List<RandomTableEntry> entries,
String campaignId,
Integer order
) {}
public RandomTable createTable(TableData data) {
int order = data.order() != null ? data.order() : nextOrderFor(data.campaignId());
RandomTable table = RandomTable.builder()
.name(data.name())
.description(data.description())
.diceFormula(data.diceFormula())
.icon(data.icon())
.entries(copyEntries(data.entries()))
.campaignId(data.campaignId())
.order(order)
.build();
return repository.save(table);
}
public Optional<RandomTable> getTableById(String id) {
return repository.findById(id);
}
public List<RandomTable> getTablesByCampaignId(String campaignId) {
return repository.findByCampaignId(campaignId);
}
public RandomTable updateTable(String id, TableData data) {
RandomTable existing = repository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Table aléatoire introuvable: " + id));
existing.setName(data.name());
existing.setDescription(data.description());
existing.setDiceFormula(data.diceFormula());
existing.setIcon(data.icon());
existing.setEntries(copyEntries(data.entries()));
if (data.order() != null) {
existing.setOrder(data.order());
}
return repository.save(existing);
}
public void deleteTable(String id) {
repository.deleteById(id);
}
/** Génère une PROPOSITION de table (non persistée) via l'IA, contextualisée campagne. */
public RandomTable generateProposal(String campaignId, String description, String diceFormula) {
String formula = (diceFormula == null || diceFormula.isBlank()) ? "1d20" : diceFormula;
RandomTableGenerator.GeneratedTable g = generator.generate(description, formula, buildContext(campaignId));
return RandomTable.builder()
.name(g.name())
.description(g.description())
.diceFormula(formula)
.campaignId(campaignId)
.entries(g.entries() != null ? new ArrayList<>(g.entries()) : new ArrayList<>())
.build();
}
/** Brode un court récit IA sur un résultat tiré (pour la partie). */
public String improviseRoll(String campaignId, String tableName, String resultLabel, String resultDetail) {
return generator.improvise(tableName, resultLabel, resultDetail, buildContext(campaignId));
}
/** Contexte libre (nom de campagne + description + système) pour orienter l'IA. */
private String buildContext(String campaignId) {
if (campaignId == null) return "";
Campaign campaign = campaignRepository.findById(campaignId).orElse(null);
if (campaign == null) return "";
StringBuilder sb = new StringBuilder();
sb.append("Campagne : ").append(campaign.getName());
if (campaign.getDescription() != null && !campaign.getDescription().isBlank()) {
sb.append("").append(campaign.getDescription().trim());
}
if (campaign.getGameSystemId() != null && !campaign.getGameSystemId().isBlank()) {
gameSystemRepository.findById(campaign.getGameSystemId())
.ifPresent(gs -> sb.append("\nSystème de jeu : ").append(gs.getName()));
}
return sb.toString();
}
private static List<RandomTableEntry> copyEntries(List<RandomTableEntry> entries) {
return entries != null ? new ArrayList<>(entries) : new ArrayList<>();
}
private int nextOrderFor(String campaignId) {
return repository.findByCampaignId(campaignId).stream()
.mapToInt(RandomTable::getOrder)
.max()
.orElse(-1) + 1;
}
}

View File

@@ -0,0 +1,23 @@
package com.loremind.domain.campaigncontext;
import lombok.Builder;
import lombok.Data;
import java.time.LocalDateTime;
/**
* Atelier d'adaptation (« notebook ») d'une campagne : une ou plusieurs sources
* PDF indexées (RAG) + une conversation, persistés pour y revenir.
* <p>
* Les SOURCES ({@link NotebookSource}) et les MESSAGES ({@link NotebookMessage})
* sont gérés comme entités liées par {@code notebookId} (chargées séparément).
*/
@Data
@Builder
public class Notebook {
private String id;
private String name;
private String campaignId;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}

View File

@@ -0,0 +1,20 @@
package com.loremind.domain.campaigncontext;
import lombok.Builder;
import lombok.Data;
import java.time.LocalDateTime;
/**
* Un message de la conversation d'un {@link Notebook}. {@code role} = "user" ou
* "assistant". Persisté pour recharger l'historique de l'atelier.
*/
@Data
@Builder
public class NotebookMessage {
private String id;
private String notebookId;
private String role;
private String content;
private LocalDateTime createdAt;
}

View File

@@ -0,0 +1,24 @@
package com.loremind.domain.campaigncontext;
import lombok.Builder;
import lombok.Data;
import java.time.LocalDateTime;
/**
* Une source (PDF) d'un {@link Notebook}. Son {@code id} sert de clé d'indexation
* vectorielle côté Brain (les vecteurs vivent sur le volume du Brain).
* <p>
* {@code status} : INDEXING (en cours), READY (interrogeable), FAILED (échec).
*/
@Data
@Builder
public class NotebookSource {
private String id;
private String notebookId;
private String filename;
private String status;
private int chunkCount;
private int pageCount;
private LocalDateTime createdAt;
}

View File

@@ -0,0 +1,50 @@
package com.loremind.domain.campaigncontext;
import lombok.Builder;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
/**
* Table aléatoire d'une campagne : on jette un dé ({@code diceFormula}) et la
* valeur tombée désigne une {@link RandomTableEntry} (par sa plage) → un résultat.
* <p>
* Outil MJ classique (rencontres, butin, complications, noms…). Le JET lui-même
* est effectué côté client (instantané, comme le panneau de dés) ; le domaine ne
* fait que stocker la table et ses entrées. Scope campagne (cross-aggregate via ID).
*/
@Data
@Builder
public class RandomTable {
private String id;
private String name;
/** Description libre (à quoi sert la table). Nullable. */
private String description;
/** Formule du dé à lancer : "1d20", "2d6", "d100"… */
private String diceFormula;
/** Clé d'icône (lucide) pour la sidebar/fiche. Nullable. */
private String icon;
/** Référence vers la Campaign parente (cross-aggregate via ID). */
private String campaignId;
/** Ordre d'affichage dans la liste des tables de la campagne. */
private int order;
/** Entrées ordonnées (par plage de jet). Jamais null après construction. */
private List<RandomTableEntry> entries;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
public List<RandomTableEntry> getEntries() {
if (entries == null) entries = new ArrayList<>();
return entries;
}
}

View File

@@ -0,0 +1,29 @@
package com.loremind.domain.campaigncontext;
import lombok.Builder;
import lombok.Data;
/**
* Une entrée d'une {@link RandomTable} : une PLAGE de jet (minRoll..maxRoll, bornes
* incluses) qui mappe vers un résultat. Les plages permettent les tables PONDÉRÉES
* (un résultat couvrant 110 est plus probable qu'un couvrant 1112).
* <p>
* Value object possédé par la table (pas d'identité propre côté domaine) : à chaque
* mise à jour, les entrées sont remplacées en bloc.
*/
@Data
@Builder
public class RandomTableEntry {
/** Borne basse du jet (incluse). */
private int minRoll;
/** Borne haute du jet (incluse). Pour une entrée unitaire, min == max. */
private int maxRoll;
/** Résultat court affiché (ex. "Embuscade de gobelins"). */
private String label;
/** Détail markdown : « ce que c'est » (effet, description). Nullable. */
private String detail;
}

View File

@@ -0,0 +1,36 @@
package com.loremind.domain.campaigncontext.ports;
import java.util.List;
import java.util.function.Consumer;
/**
* Port de sortie : chat ANCRÉ (RAG) sur les sources d'un notebook, streamé.
* Le Brain récupère les passages pertinents puis streame la réponse token par token.
*/
public interface NotebookChatStreamer {
/** Un message de la conversation transmis au Brain. */
record Msg(String role, String content) {}
/** Avancement de l'analyse approfondie (lecture du document par lots). */
record Progress(int current, int total) {}
/**
* Streame la réponse ancrée sur les sources. Les callbacks sont invoqués au fil
* de l'eau : {@code onToken} par fragment, {@code onProgress} (mode approfondi
* uniquement) pendant la lecture du document, {@code onDone} à la fin,
* {@code onError} en cas d'échec.
*
* @param deep true = analyse approfondie (map-reduce sur tout le document) ;
* false = chat RAG (top-k).
*/
void stream(
List<String> sourceIds,
List<Msg> messages,
String context,
boolean deep,
Consumer<String> onToken,
Consumer<Progress> onProgress,
Runnable onDone,
Consumer<Throwable> onError);
}

View File

@@ -0,0 +1,15 @@
package com.loremind.domain.campaigncontext.ports;
/**
* Échec d'indexation/chat d'un notebook (Brain injoignable, erreur du modèle…).
* Mappée en HTTP 502 par le contrôleur.
*/
public class NotebookException extends RuntimeException {
public NotebookException(String message) {
super(message);
}
public NotebookException(String message, Throwable cause) {
super(message, cause);
}
}

View File

@@ -0,0 +1,17 @@
package com.loremind.domain.campaigncontext.ports;
/**
* Port de sortie : indexation RAG d'une source de notebook (déléguée au Brain).
* Les vecteurs vivent côté Brain, keyés par {@code sourceId}.
*/
public interface NotebookIndexer {
/** Récapitulatif d'indexation renvoyé par le Brain. */
record IndexResult(int chunks, int pageCount, int ocrPageCount) {}
/** Indexe une source (extraction + embeddings + stockage vectoriel). */
IndexResult index(String sourceId, byte[] pdfBytes, String filename);
/** Supprime les vecteurs d'une source (au DELETE d'une source/notebook). */
void delete(String sourceId);
}

View File

@@ -0,0 +1,32 @@
package com.loremind.domain.campaigncontext.ports;
import com.loremind.domain.campaigncontext.Notebook;
import com.loremind.domain.campaigncontext.NotebookMessage;
import com.loremind.domain.campaigncontext.NotebookSource;
import java.util.List;
import java.util.Optional;
/**
* Port de sortie pour la persistance des notebooks (atelier), de leurs sources
* et de leur conversation. Port unique (3 agrégats liés) pour rester compact.
*/
public interface NotebookRepository {
// --- Notebook ---
Notebook save(Notebook notebook);
Optional<Notebook> findById(String id);
List<Notebook> findByCampaignId(String campaignId);
void deleteById(String id);
boolean existsById(String id);
// --- Sources ---
NotebookSource saveSource(NotebookSource source);
Optional<NotebookSource> findSourceById(String id);
List<NotebookSource> findSourcesByNotebookId(String notebookId);
void deleteSourceById(String id);
// --- Messages (conversation) ---
NotebookMessage saveMessage(NotebookMessage message);
List<NotebookMessage> findMessagesByNotebookId(String notebookId);
}

View File

@@ -0,0 +1,15 @@
package com.loremind.domain.campaigncontext.ports;
/**
* Échec de génération/improvisation IA d'une table (Brain injoignable, erreur du
* modèle, réponse inexploitable…). Mappée en HTTP 502 par le contrôleur.
*/
public class RandomTableGenerationException extends RuntimeException {
public RandomTableGenerationException(String message) {
super(message);
}
public RandomTableGenerationException(String message, Throwable cause) {
super(message, cause);
}
}

View File

@@ -0,0 +1,26 @@
package com.loremind.domain.campaigncontext.ports;
import com.loremind.domain.campaigncontext.RandomTableEntry;
import java.util.List;
/**
* Port de sortie : génération IA d'une table aléatoire et improvisation narrative
* sur un résultat tiré. Implémenté par un client du Brain (service IA Python).
*/
public interface RandomTableGenerator {
/** Table proposée (non persistée) à partir d'une description + formule de dé. */
record GeneratedTable(String name, String description, List<RandomTableEntry> entries) {}
/**
* Génère une proposition de table couvrant la formule de dé, sur le sujet
* donné, en s'appuyant sur le contexte (campagne, système…) s'il est fourni.
*/
GeneratedTable generate(String description, String diceFormula, String context);
/**
* Brode un court récit (2-3 phrases) sur un résultat tiré, pour lancer la scène.
*/
String improvise(String tableName, String resultLabel, String resultDetail, String context);
}

View File

@@ -0,0 +1,22 @@
package com.loremind.domain.campaigncontext.ports;
import com.loremind.domain.campaigncontext.RandomTable;
import java.util.List;
import java.util.Optional;
/**
* Port de sortie pour la persistance des {@link RandomTable}.
*/
public interface RandomTableRepository {
RandomTable save(RandomTable table);
Optional<RandomTable> findById(String id);
List<RandomTable> findByCampaignId(String campaignId);
void deleteById(String id);
boolean existsById(String id);
}

View File

@@ -90,8 +90,12 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
}
} catch (Exception e) {
if (!terminated[0]) {
// On EXPOSE la cause réelle (type + message) : sans ça, le diagnostic
// est impossible (timeout WebClient, connexion coupée, réponse non-2xx…).
String cause = e.getClass().getSimpleName()
+ (e.getMessage() != null ? "" + e.getMessage() : "");
onError.accept(new CampaignImportException(
"Erreur lors du streaming d'import depuis le Brain.", e));
"Erreur lors du streaming d'import depuis le Brain : " + cause, e));
}
}
}

View File

@@ -0,0 +1,139 @@
package com.loremind.infrastructure.ai;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.loremind.domain.campaigncontext.ports.NotebookChatStreamer;
import com.loremind.domain.campaigncontext.ports.NotebookException;
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 : relaie le chat ANCRÉ (RAG) du Brain via SSE (WebClient).
* Pattern identique aux imports streamés (cf. BrainRulesImportClient).
*/
@Component
public class BrainNotebookChatClient implements NotebookChatStreamer {
private static final String PATH = "/chat/notebook/stream";
private static final String DEEP_PATH = "/chat/notebook/deep/stream";
private static final ParameterizedTypeReference<ServerSentEvent<String>> SSE_STRING_TYPE =
new ParameterizedTypeReference<>() {};
private final WebClient webClient;
private final ObjectMapper objectMapper;
private final long timeoutSeconds;
public BrainNotebookChatClient(
WebClient.Builder webClientBuilder,
ObjectMapper objectMapper,
@Value("${brain.base-url}") String baseUrl,
@Value("${brain.import-timeout-seconds:600}") long timeoutSeconds) {
this.webClient = webClientBuilder.baseUrl(baseUrl).build();
this.objectMapper = objectMapper;
this.timeoutSeconds = timeoutSeconds;
}
@Override
public void stream(
List<String> sourceIds,
List<Msg> messages,
String context,
boolean deep,
Consumer<String> onToken,
Consumer<Progress> onProgress,
Runnable onDone,
Consumer<Throwable> onError) {
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("source_ids", sourceIds);
payload.put("messages", messages.stream()
.map(m -> Map.<String, Object>of("role", m.role(), "content", m.content()))
.collect(Collectors.toList()));
payload.put("context", context == null ? "" : context);
Flux<ServerSentEvent<String>> flux = webClient.post()
.uri(deep ? DEEP_PATH : PATH)
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.TEXT_EVENT_STREAM)
.bodyValue(payload)
.retrieve()
.bodyToFlux(SSE_STRING_TYPE);
boolean[] terminated = {false};
try {
flux
.timeout(Duration.ofSeconds(timeoutSeconds))
.doOnNext(sse -> handleEvent(sse, terminated, onToken, onProgress, onDone, onError))
.blockLast();
if (!terminated[0]) {
onDone.run(); // flux terminé sans event done explicite
}
} catch (Exception e) {
if (!terminated[0]) {
String cause = e.getClass().getSimpleName()
+ (e.getMessage() != null ? "" + e.getMessage() : "");
onError.accept(new NotebookException(
"Erreur lors du streaming du chat depuis le Brain : " + cause, e));
}
}
}
private void handleEvent(
ServerSentEvent<String> sse,
boolean[] terminated,
Consumer<String> onToken,
Consumer<Progress> onProgress,
Runnable onDone,
Consumer<Throwable> onError) {
String event = sse.event();
String data = sse.data() == null ? "" : sse.data();
if ("token".equals(event)) {
String token = readField(data, "token");
if (token != null && !token.isEmpty()) onToken.accept(token);
} else if ("progress".equals(event)) {
onProgress.accept(new Progress(readInt(data, "current"), readInt(data, "total")));
} else if ("done".equals(event)) {
terminated[0] = true;
onDone.run();
} else if ("error".equals(event)) {
terminated[0] = true;
onError.accept(new NotebookException("Le Brain a signalé une erreur : " + readMessage(data)));
}
}
private int readInt(String data, String field) {
try {
JsonNode node = objectMapper.readTree(data);
return node.hasNonNull(field) ? node.get(field).asInt() : 0;
} catch (Exception e) {
return 0;
}
}
private String readField(String data, String field) {
try {
JsonNode node = objectMapper.readTree(data);
return node.hasNonNull(field) ? node.get(field).asText() : null;
} catch (Exception e) {
return null;
}
}
private String readMessage(String data) {
String msg = readField(data, "message");
return msg != null ? msg : data;
}
}

View File

@@ -0,0 +1,100 @@
package com.loremind.infrastructure.ai;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.loremind.domain.campaigncontext.ports.NotebookException;
import com.loremind.domain.campaigncontext.ports.NotebookIndexer;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.ResourceAccessException;
import org.springframework.web.client.RestClientResponseException;
import org.springframework.web.client.RestTemplate;
/**
* Adapter de sortie : indexe une source de notebook via le Brain (multipart,
* one-shot bloquant — l'indexation d'un livre peut prendre du temps, d'où le
* RestTemplate à timeout long {@code brainImportRestTemplate}).
*/
@Component
public class BrainNotebookIndexClient implements NotebookIndexer {
private static final Logger LOG = LoggerFactory.getLogger(BrainNotebookIndexClient.class);
private static final String INDEX_PATH = "/index/notebook-source";
private final RestTemplate restTemplate;
private final String baseUrl;
public BrainNotebookIndexClient(
@Qualifier("brainImportRestTemplate") RestTemplate restTemplate,
@Value("${brain.base-url}") String baseUrl) {
this.restTemplate = restTemplate;
this.baseUrl = baseUrl;
}
@Override
public IndexResult index(String sourceId, byte[] pdfBytes, String filename) {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.MULTIPART_FORM_DATA);
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
body.add("source_id", sourceId);
body.add("file", filePart(pdfBytes, filename));
HttpEntity<MultiValueMap<String, Object>> entity = new HttpEntity<>(body, headers);
try {
IndexResponse resp = restTemplate.postForObject(
baseUrl + INDEX_PATH, entity, IndexResponse.class);
if (resp == null) {
throw new NotebookException("Le Brain a renvoyé une réponse vide à l'indexation.");
}
return new IndexResult(resp.chunks, resp.pageCount, resp.ocrPageCount);
} catch (ResourceAccessException e) {
throw new NotebookException("Le Brain est injoignable (timeout ou arrêté).", e);
} catch (RestClientResponseException e) {
throw new NotebookException(
"Le Brain a répondu HTTP " + e.getStatusCode().value()
+ " : " + e.getResponseBodyAsString(), e);
} catch (NotebookException e) {
throw e;
} catch (Exception e) {
throw new NotebookException("Erreur inattendue lors de l'indexation via le Brain.", e);
}
}
@Override
public void delete(String sourceId) {
// Best-effort : si le Brain est down, on ne bloque pas la suppression côté Core
// (les vecteurs orphelins seront simplement ignorés / nettoyables plus tard).
try {
restTemplate.delete(baseUrl + INDEX_PATH + "/" + sourceId);
} catch (Exception e) {
LOG.warn("Suppression des vecteurs de la source {} échouée (ignorée) : {}", sourceId, e.getMessage());
}
}
private ByteArrayResource filePart(byte[] pdfBytes, String filename) {
return new ByteArrayResource(pdfBytes) {
@Override
public String getFilename() {
return (filename == null || filename.isBlank()) ? "source.pdf" : filename;
}
};
}
/** Réponse JSON du Brain (snake_case). */
private static class IndexResponse {
public int chunks;
@JsonProperty("page_count")
public int pageCount;
@JsonProperty("ocr_page_count")
public int ocrPageCount;
}
}

View File

@@ -0,0 +1,120 @@
package com.loremind.infrastructure.ai;
import com.loremind.domain.campaigncontext.RandomTableEntry;
import com.loremind.domain.campaigncontext.ports.RandomTableGenerationException;
import com.loremind.domain.campaigncontext.ports.RandomTableGenerator;
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.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* Adapter de sortie : implémente {@link RandomTableGenerator} en appelant le Brain
* (POST one-shot, RestTemplate). Le secret inter-service est injecté par l'intercepteur
* du bean {@code brainRestTemplate}.
*/
@Component
public class BrainRandomTableClient implements RandomTableGenerator {
private static final String GENERATE_PATH = "/generate/random-table";
private static final String IMPROVISE_PATH = "/improvise/table-roll";
private final RestTemplate restTemplate;
private final String baseUrl;
public BrainRandomTableClient(
RestTemplate restTemplate,
@Value("${brain.base-url}") String baseUrl) {
this.restTemplate = restTemplate;
this.baseUrl = baseUrl;
}
@Override
public GeneratedTable generate(String description, String diceFormula, String context) {
Map<String, Object> req = new LinkedHashMap<>();
req.put("description", description == null ? "" : description);
req.put("dice_formula", diceFormula == null ? "1d20" : diceFormula);
req.put("context", context == null ? "" : context);
Map<String, Object> resp = post(GENERATE_PATH, req);
if (resp == null) {
throw new RandomTableGenerationException("Le Brain a renvoyé une réponse vide.");
}
List<RandomTableEntry> entries = new ArrayList<>();
Object rawEntries = resp.get("entries");
if (rawEntries instanceof List<?> list) {
for (Object item : list) {
if (!(item instanceof Map<?, ?> m)) continue;
Integer min = asInt(m.get("min_roll"));
Integer max = asInt(m.get("max_roll"));
String label = asString(m.get("label"));
if (min == null || max == null || label == null || label.isBlank()) continue;
entries.add(RandomTableEntry.builder()
.minRoll(min)
.maxRoll(Math.max(min, max))
.label(label)
.detail(asString(m.get("detail")))
.build());
}
}
if (entries.isEmpty()) {
throw new RandomTableGenerationException("Aucune entrée générée — réessaie ou reformule.");
}
String name = asString(resp.get("name"));
return new GeneratedTable(
name != null && !name.isBlank() ? name : description,
asString(resp.get("description")),
entries);
}
@Override
public String improvise(String tableName, String resultLabel, String resultDetail, String context) {
Map<String, Object> req = new LinkedHashMap<>();
req.put("table_name", tableName == null ? "" : tableName);
req.put("result_label", resultLabel == null ? "" : resultLabel);
req.put("result_detail", resultDetail == null ? "" : resultDetail);
req.put("context", context == null ? "" : context);
Map<String, Object> resp = post(IMPROVISE_PATH, req);
String narration = resp != null ? asString(resp.get("narration")) : null;
return narration != null ? narration : "";
}
@SuppressWarnings("unchecked")
private Map<String, Object> post(String path, Map<String, Object> body) {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<Map<String, Object>> entity = new HttpEntity<>(body, headers);
try {
return restTemplate.postForObject(baseUrl + path, entity, Map.class);
} catch (ResourceAccessException e) {
throw new RandomTableGenerationException("Le Brain est injoignable (timeout ou arrêté).", e);
} catch (RestClientResponseException e) {
throw new RandomTableGenerationException(
"Le Brain a répondu HTTP " + e.getStatusCode().value() + " : " + e.getResponseBodyAsString(), e);
} catch (Exception e) {
throw new RandomTableGenerationException("Erreur inattendue lors de l'appel au Brain.", e);
}
}
private static Integer asInt(Object o) {
if (o instanceof Number n) return n.intValue();
if (o instanceof String s) {
try { return Integer.parseInt(s.trim()); } catch (NumberFormatException ignored) { return null; }
}
return null;
}
private static String asString(Object o) {
return o != null ? o.toString() : null;
}
}

View File

@@ -148,8 +148,13 @@ public class BrainRulesImportClient implements RulesPdfImporter {
}
} catch (Exception e) {
if (!terminated[0]) {
// On EXPOSE la cause réelle (type + message) : sans ça, l'UI n'a qu'un
// message générique et le diagnostic est impossible (timeout WebClient,
// connexion coupée, réponse non-2xx du Brain, etc.).
String cause = e.getClass().getSimpleName()
+ (e.getMessage() != null ? "" + e.getMessage() : "");
onError.accept(new RulesImportException(
"Erreur lors du streaming d'import depuis le Brain.", e));
"Erreur lors du streaming d'import depuis le Brain : " + cause, e));
}
}
}

View File

@@ -0,0 +1,47 @@
package com.loremind.infrastructure.persistence.entity;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
@Entity
@Table(name = "notebooks", indexes = {
@Index(name = "idx_notebooks_campaign_id", columnList = "campaign_id")
})
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class NotebookJpaEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
@Column(name = "campaign_id", nullable = false)
private Long campaignId;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
updatedAt = LocalDateTime.now();
}
@PreUpdate
protected void onUpdate() {
updatedAt = LocalDateTime.now();
}
}

View File

@@ -0,0 +1,41 @@
package com.loremind.infrastructure.persistence.entity;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
@Entity
@Table(name = "notebook_messages", indexes = {
@Index(name = "idx_notebook_messages_notebook_id", columnList = "notebook_id")
})
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class NotebookMessageJpaEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "notebook_id", nullable = false)
private Long notebookId;
@Column(nullable = false, length = 16)
private String role;
@Column(columnDefinition = "TEXT", nullable = false)
private String content;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@PrePersist
protected void onCreate() {
if (createdAt == null) createdAt = LocalDateTime.now();
}
}

View File

@@ -0,0 +1,47 @@
package com.loremind.infrastructure.persistence.entity;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
@Entity
@Table(name = "notebook_sources", indexes = {
@Index(name = "idx_notebook_sources_notebook_id", columnList = "notebook_id")
})
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class NotebookSourceJpaEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "notebook_id", nullable = false)
private Long notebookId;
@Column(nullable = false)
private String filename;
@Column(nullable = false, length = 16)
private String status;
@Column(name = "chunk_count", nullable = false)
private int chunkCount;
@Column(name = "page_count", nullable = false)
private int pageCount;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@PrePersist
protected void onCreate() {
if (createdAt == null) createdAt = LocalDateTime.now();
}
}

View File

@@ -0,0 +1,51 @@
package com.loremind.infrastructure.persistence.entity;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import lombok.ToString;
/**
* Entité JPA d'une entrée de table aléatoire (enfant de {@link RandomTableJpaEntity}).
* Ordonnée par {@code position}. La référence parente est exclue de toString/equals
* pour éviter les récursions infinies (relation bidirectionnelle).
*/
@Entity
@Table(name = "random_table_entries", indexes = {
@Index(name = "idx_random_table_entries_table_id", columnList = "random_table_id")
})
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class RandomTableEntryJpaEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "min_roll", nullable = false)
private int minRoll;
@Column(name = "max_roll", nullable = false)
private int maxRoll;
@Column(nullable = false)
private String label;
@Column(columnDefinition = "TEXT")
private String detail;
/** Position d'affichage dans la table (ordre des entrées). */
@Column(nullable = false)
private int position;
@ManyToOne(optional = false, fetch = FetchType.LAZY)
@JoinColumn(name = "random_table_id", nullable = false)
@ToString.Exclude
@EqualsAndHashCode.Exclude
private RandomTableJpaEntity randomTable;
}

View File

@@ -0,0 +1,73 @@
package com.loremind.infrastructure.persistence.entity;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
/**
* Entité JPA d'une table aléatoire (parent) avec ses entrées ordonnées (enfants).
* Tables créées automatiquement par Hibernate (ddl-auto=update).
*/
@Entity
@Table(name = "random_tables")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class RandomTableJpaEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
@Column(columnDefinition = "TEXT")
private String description;
@Column(name = "dice_formula", nullable = false, length = 32)
private String diceFormula;
@Column(length = 64)
private String icon;
@Column(name = "campaign_id", nullable = false)
private Long campaignId;
@Column(name = "\"order\"", nullable = false)
private int order;
@OneToMany(
mappedBy = "randomTable",
cascade = CascadeType.ALL,
orphanRemoval = true,
fetch = FetchType.LAZY
)
@OrderBy("position ASC")
@Builder.Default
private List<RandomTableEntryJpaEntity> entries = new ArrayList<>();
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
updatedAt = LocalDateTime.now();
}
@PreUpdate
protected void onUpdate() {
updatedAt = LocalDateTime.now();
}
}

View File

@@ -0,0 +1,12 @@
package com.loremind.infrastructure.persistence.jpa;
import com.loremind.infrastructure.persistence.entity.NotebookJpaEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface NotebookJpaRepository extends JpaRepository<NotebookJpaEntity, Long> {
List<NotebookJpaEntity> findByCampaignIdOrderByUpdatedAtDesc(Long campaignId);
}

View File

@@ -0,0 +1,13 @@
package com.loremind.infrastructure.persistence.jpa;
import com.loremind.infrastructure.persistence.entity.NotebookMessageJpaEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface NotebookMessageJpaRepository extends JpaRepository<NotebookMessageJpaEntity, Long> {
List<NotebookMessageJpaEntity> findByNotebookIdOrderByCreatedAtAsc(Long notebookId);
void deleteByNotebookId(Long notebookId);
}

View File

@@ -0,0 +1,13 @@
package com.loremind.infrastructure.persistence.jpa;
import com.loremind.infrastructure.persistence.entity.NotebookSourceJpaEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface NotebookSourceJpaRepository extends JpaRepository<NotebookSourceJpaEntity, Long> {
List<NotebookSourceJpaEntity> findByNotebookIdOrderByCreatedAtAsc(Long notebookId);
void deleteByNotebookId(Long notebookId);
}

View File

@@ -0,0 +1,13 @@
package com.loremind.infrastructure.persistence.jpa;
import com.loremind.infrastructure.persistence.entity.RandomTableJpaEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface RandomTableJpaRepository extends JpaRepository<RandomTableJpaEntity, Long> {
List<RandomTableJpaEntity> findByCampaignIdOrderByOrderAsc(Long campaignId);
}

View File

@@ -0,0 +1,155 @@
package com.loremind.infrastructure.persistence.postgres;
import com.loremind.domain.campaigncontext.Notebook;
import com.loremind.domain.campaigncontext.NotebookMessage;
import com.loremind.domain.campaigncontext.NotebookSource;
import com.loremind.domain.campaigncontext.ports.NotebookRepository;
import com.loremind.infrastructure.persistence.entity.NotebookJpaEntity;
import com.loremind.infrastructure.persistence.entity.NotebookMessageJpaEntity;
import com.loremind.infrastructure.persistence.entity.NotebookSourceJpaEntity;
import com.loremind.infrastructure.persistence.jpa.NotebookJpaRepository;
import com.loremind.infrastructure.persistence.jpa.NotebookMessageJpaRepository;
import com.loremind.infrastructure.persistence.jpa.NotebookSourceJpaRepository;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
@Repository
public class PostgresNotebookRepository implements NotebookRepository {
private final NotebookJpaRepository notebookJpa;
private final NotebookSourceJpaRepository sourceJpa;
private final NotebookMessageJpaRepository messageJpa;
public PostgresNotebookRepository(
NotebookJpaRepository notebookJpa,
NotebookSourceJpaRepository sourceJpa,
NotebookMessageJpaRepository messageJpa) {
this.notebookJpa = notebookJpa;
this.sourceJpa = sourceJpa;
this.messageJpa = messageJpa;
}
// --- Notebook ---
@Override
public Notebook save(Notebook notebook) {
NotebookJpaEntity entity = notebook.getId() != null
? notebookJpa.findById(Long.parseLong(notebook.getId())).orElseGet(NotebookJpaEntity::new)
: new NotebookJpaEntity();
entity.setName(notebook.getName());
entity.setCampaignId(Long.parseLong(notebook.getCampaignId()));
return toNotebook(notebookJpa.save(entity));
}
@Override
public Optional<Notebook> findById(String id) {
return notebookJpa.findById(Long.parseLong(id)).map(this::toNotebook);
}
@Override
public List<Notebook> findByCampaignId(String campaignId) {
return notebookJpa.findByCampaignIdOrderByUpdatedAtDesc(Long.parseLong(campaignId)).stream()
.map(this::toNotebook).collect(Collectors.toList());
}
@Override
@Transactional
public void deleteById(String id) {
Long nid = Long.parseLong(id);
messageJpa.deleteByNotebookId(nid);
sourceJpa.deleteByNotebookId(nid);
notebookJpa.deleteById(nid);
}
@Override
public boolean existsById(String id) {
return notebookJpa.existsById(Long.parseLong(id));
}
// --- Sources ---
@Override
public NotebookSource saveSource(NotebookSource source) {
NotebookSourceJpaEntity entity = source.getId() != null
? sourceJpa.findById(Long.parseLong(source.getId())).orElseGet(NotebookSourceJpaEntity::new)
: new NotebookSourceJpaEntity();
entity.setNotebookId(Long.parseLong(source.getNotebookId()));
entity.setFilename(source.getFilename());
entity.setStatus(source.getStatus());
entity.setChunkCount(source.getChunkCount());
entity.setPageCount(source.getPageCount());
return toSource(sourceJpa.save(entity));
}
@Override
public Optional<NotebookSource> findSourceById(String id) {
return sourceJpa.findById(Long.parseLong(id)).map(this::toSource);
}
@Override
public List<NotebookSource> findSourcesByNotebookId(String notebookId) {
return sourceJpa.findByNotebookIdOrderByCreatedAtAsc(Long.parseLong(notebookId)).stream()
.map(this::toSource).collect(Collectors.toList());
}
@Override
public void deleteSourceById(String id) {
sourceJpa.deleteById(Long.parseLong(id));
}
// --- Messages ---
@Override
public NotebookMessage saveMessage(NotebookMessage message) {
NotebookMessageJpaEntity entity = NotebookMessageJpaEntity.builder()
.notebookId(Long.parseLong(message.getNotebookId()))
.role(message.getRole())
.content(message.getContent())
.build();
return toMessage(messageJpa.save(entity));
}
@Override
public List<NotebookMessage> findMessagesByNotebookId(String notebookId) {
return messageJpa.findByNotebookIdOrderByCreatedAtAsc(Long.parseLong(notebookId)).stream()
.map(this::toMessage).collect(Collectors.toList());
}
// --- Mapping ---
private Notebook toNotebook(NotebookJpaEntity e) {
return Notebook.builder()
.id(e.getId().toString())
.name(e.getName())
.campaignId(e.getCampaignId().toString())
.createdAt(e.getCreatedAt())
.updatedAt(e.getUpdatedAt())
.build();
}
private NotebookSource toSource(NotebookSourceJpaEntity e) {
return NotebookSource.builder()
.id(e.getId().toString())
.notebookId(e.getNotebookId().toString())
.filename(e.getFilename())
.status(e.getStatus())
.chunkCount(e.getChunkCount())
.pageCount(e.getPageCount())
.createdAt(e.getCreatedAt())
.build();
}
private NotebookMessage toMessage(NotebookMessageJpaEntity e) {
return NotebookMessage.builder()
.id(e.getId().toString())
.notebookId(e.getNotebookId().toString())
.role(e.getRole())
.content(e.getContent())
.createdAt(e.getCreatedAt())
.build();
}
}

View File

@@ -0,0 +1,106 @@
package com.loremind.infrastructure.persistence.postgres;
import com.loremind.domain.campaigncontext.RandomTable;
import com.loremind.domain.campaigncontext.RandomTableEntry;
import com.loremind.domain.campaigncontext.ports.RandomTableRepository;
import com.loremind.infrastructure.persistence.entity.RandomTableEntryJpaEntity;
import com.loremind.infrastructure.persistence.entity.RandomTableJpaEntity;
import com.loremind.infrastructure.persistence.jpa.RandomTableJpaRepository;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
@Repository
public class PostgresRandomTableRepository implements RandomTableRepository {
private final RandomTableJpaRepository jpaRepository;
public PostgresRandomTableRepository(RandomTableJpaRepository jpaRepository) {
this.jpaRepository = jpaRepository;
}
@Override
@Transactional
public RandomTable save(RandomTable table) {
// Création OU mise à jour : on charge l'entité gérée si elle existe afin que
// le remplacement des entrées (clear + add) déclenche bien orphanRemoval.
RandomTableJpaEntity entity = (table.getId() != null)
? jpaRepository.findById(Long.parseLong(table.getId())).orElseGet(RandomTableJpaEntity::new)
: new RandomTableJpaEntity();
entity.setName(table.getName());
entity.setDescription(table.getDescription());
entity.setDiceFormula(table.getDiceFormula());
entity.setIcon(table.getIcon());
entity.setCampaignId(Long.parseLong(table.getCampaignId()));
entity.setOrder(table.getOrder());
// Remplacement en bloc des entrées (les anciennes sont supprimées via orphanRemoval).
entity.getEntries().clear();
int position = 0;
for (RandomTableEntry e : table.getEntries()) {
entity.getEntries().add(RandomTableEntryJpaEntity.builder()
.minRoll(e.getMinRoll())
.maxRoll(e.getMaxRoll())
.label(e.getLabel())
.detail(e.getDetail())
.position(position++)
.randomTable(entity)
.build());
}
RandomTableJpaEntity saved = jpaRepository.save(entity);
return toDomainEntity(saved);
}
@Override
@Transactional(readOnly = true)
public Optional<RandomTable> findById(String id) {
return jpaRepository.findById(Long.parseLong(id)).map(this::toDomainEntity);
}
@Override
@Transactional(readOnly = true)
public List<RandomTable> findByCampaignId(String campaignId) {
return jpaRepository.findByCampaignIdOrderByOrderAsc(Long.parseLong(campaignId)).stream()
.map(this::toDomainEntity)
.collect(Collectors.toList());
}
@Override
public void deleteById(String id) {
jpaRepository.deleteById(Long.parseLong(id));
}
@Override
public boolean existsById(String id) {
return jpaRepository.existsById(Long.parseLong(id));
}
private RandomTable toDomainEntity(RandomTableJpaEntity e) {
List<RandomTableEntry> entries = e.getEntries().stream()
.map(c -> RandomTableEntry.builder()
.minRoll(c.getMinRoll())
.maxRoll(c.getMaxRoll())
.label(c.getLabel())
.detail(c.getDetail())
.build())
.collect(Collectors.toCollection(ArrayList::new));
return RandomTable.builder()
.id(e.getId().toString())
.name(e.getName())
.description(e.getDescription())
.diceFormula(e.getDiceFormula())
.icon(e.getIcon())
.campaignId(e.getCampaignId().toString())
.order(e.getOrder())
.entries(entries)
.createdAt(e.getCreatedAt())
.updatedAt(e.getUpdatedAt())
.build();
}
}

View File

@@ -0,0 +1,206 @@
package com.loremind.infrastructure.web.controller;
import com.loremind.application.campaigncontext.NotebookService;
import com.loremind.domain.campaigncontext.Notebook;
import com.loremind.domain.campaigncontext.NotebookSource;
import com.loremind.domain.campaigncontext.ports.NotebookChatStreamer;
import com.loremind.domain.campaigncontext.ports.NotebookException;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.core.task.TaskExecutor;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.io.IOException;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* REST Controller des notebooks (atelier RAG). CRUD + upload/indexation de sources
* + chat ancré streamé (SSE) qui persiste la conversation.
*/
@RestController
@RequestMapping("/api/notebooks")
public class NotebookController {
private static final long SSE_TIMEOUT_MS = 10 * 60 * 1000L;
private final NotebookService service;
private final NotebookChatStreamer chatStreamer;
private final TaskExecutor taskExecutor;
public NotebookController(
NotebookService service,
NotebookChatStreamer chatStreamer,
@Qualifier("applicationTaskExecutor") TaskExecutor taskExecutor) {
this.service = service;
this.chatStreamer = chatStreamer;
this.taskExecutor = taskExecutor;
}
// --- Notebooks ---
@PostMapping
public ResponseEntity<Notebook> create(@RequestBody CreateRequest req) {
return ResponseEntity.ok(service.createNotebook(req.campaignId(), req.name()));
}
@GetMapping("/campaign/{campaignId}")
public ResponseEntity<List<Notebook>> listByCampaign(@PathVariable String campaignId) {
return ResponseEntity.ok(service.getNotebooksByCampaign(campaignId));
}
@GetMapping("/{id}")
public ResponseEntity<Map<String, Object>> get(@PathVariable String id) {
Notebook nb = service.getNotebook(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Notebook introuvable"));
Map<String, Object> out = new LinkedHashMap<>();
out.put("id", nb.getId());
out.put("name", nb.getName());
out.put("campaignId", nb.getCampaignId());
out.put("sources", service.getSources(id));
out.put("messages", service.getMessages(id));
return ResponseEntity.ok(out);
}
@PutMapping("/{id}")
public ResponseEntity<Notebook> rename(@PathVariable String id, @RequestBody RenameRequest req) {
return ResponseEntity.ok(service.renameNotebook(id, req.name()));
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable String id) {
service.deleteNotebook(id);
return ResponseEntity.noContent().build();
}
// --- Sources ---
@PostMapping("/{id}/sources")
public ResponseEntity<NotebookSource> addSource(
@PathVariable String id,
@RequestParam("file") MultipartFile file) {
try {
byte[] bytes = file.getBytes();
NotebookSource source = service.addSource(id, file.getOriginalFilename(), bytes);
return ResponseEntity.ok(source);
} catch (IOException e) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Fichier illisible", e);
} catch (NotebookException e) {
throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, e.getMessage(), e);
}
}
@DeleteMapping("/sources/{sourceId}")
public ResponseEntity<Void> deleteSource(@PathVariable String sourceId) {
service.deleteSource(sourceId);
return ResponseEntity.noContent().build();
}
// --- Chat ancré streamé ---
@PostMapping(value = "/{id}/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter chatStream(@PathVariable String id, @RequestBody ChatRequest req) {
SseEmitter emitter = new SseEmitter(SSE_TIMEOUT_MS);
Notebook nb = service.getNotebook(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Notebook introuvable"));
String userMessage = req.message() == null ? "" : req.message().trim();
if (userMessage.isEmpty()) {
fail(emitter, new IllegalArgumentException("Message vide."));
return emitter;
}
// Persiste le message utilisateur AVANT le stream (l'historique inclura ce tour).
service.addMessage(id, "user", userMessage);
List<NotebookChatStreamer.Msg> history = service.getMessages(id).stream()
.map(m -> new NotebookChatStreamer.Msg(m.getRole(), m.getContent()))
.toList();
List<String> sourceIds = service.readySourceIds(id);
String context = service.buildContext(nb.getCampaignId());
boolean deep = req.deep() != null && req.deep();
taskExecutor.execute(() -> {
StringBuilder assistant = new StringBuilder();
chatStreamer.stream(
sourceIds, history, context, deep,
token -> { assistant.append(token); sendToken(emitter, token); },
progress -> sendProgress(emitter, progress),
() -> {
// Persiste la réponse de l'assistant à la fin du stream.
if (assistant.length() > 0) {
service.addMessage(id, "assistant", assistant.toString());
}
complete(emitter);
},
error -> fail(emitter, error));
});
return emitter;
}
// --- Helpers SSE (mêmes conventions que AiChatController) ---
private void sendToken(SseEmitter emitter, String token) {
try {
emitter.send(SseEmitter.event().name("token").data("{\"token\":" + jsonEscape(token) + "}"));
} catch (IOException e) {
emitter.completeWithError(e);
}
}
private void sendProgress(SseEmitter emitter, NotebookChatStreamer.Progress p) {
try {
emitter.send(SseEmitter.event().name("progress")
.data("{\"current\":" + p.current() + ",\"total\":" + p.total() + "}"));
} 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);
}
}
private String jsonEscape(String raw) {
if (raw == null) return "\"\"";
StringBuilder sb = new StringBuilder(raw.length() + 2).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);
}
}
return sb.append('"').toString();
}
public record CreateRequest(String campaignId, String name) {}
public record RenameRequest(String name) {}
public record ChatRequest(String message, Boolean deep) {}
}

View File

@@ -0,0 +1,100 @@
package com.loremind.infrastructure.web.controller;
import com.loremind.application.campaigncontext.RandomTableService;
import com.loremind.domain.campaigncontext.RandomTable;
import com.loremind.domain.campaigncontext.ports.RandomTableGenerationException;
import com.loremind.infrastructure.web.dto.campaigncontext.RandomTableDTO;
import com.loremind.infrastructure.web.mapper.RandomTableMapper;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@RestController
@RequestMapping("/api/random-tables")
public class RandomTableController {
private final RandomTableService service;
private final RandomTableMapper mapper;
public RandomTableController(RandomTableService service, RandomTableMapper mapper) {
this.service = service;
this.mapper = mapper;
}
@PostMapping
public ResponseEntity<RandomTableDTO> create(@RequestBody RandomTableDTO dto) {
RandomTable created = service.createTable(toData(dto, null));
return ResponseEntity.ok(mapper.toDTO(created));
}
@GetMapping("/{id}")
public ResponseEntity<RandomTableDTO> getById(@PathVariable String id) {
return service.getTableById(id)
.map(t -> ResponseEntity.ok(mapper.toDTO(t)))
.orElse(ResponseEntity.notFound().build());
}
@GetMapping("/campaign/{campaignId}")
public ResponseEntity<List<RandomTableDTO>> getByCampaign(@PathVariable String campaignId) {
List<RandomTableDTO> dtos = service.getTablesByCampaignId(campaignId).stream()
.map(mapper::toDTO)
.collect(Collectors.toList());
return ResponseEntity.ok(dtos);
}
@PutMapping("/{id}")
public ResponseEntity<RandomTableDTO> update(@PathVariable String id, @RequestBody RandomTableDTO dto) {
RandomTable updated = service.updateTable(id, toData(dto, dto.getOrder()));
return ResponseEntity.ok(mapper.toDTO(updated));
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable String id) {
service.deleteTable(id);
return ResponseEntity.noContent().build();
}
/** Génère une PROPOSITION de table via l'IA (non persistée) — l'UI préremplit le formulaire. */
@PostMapping("/generate")
public ResponseEntity<RandomTableDTO> generate(@RequestBody GenerateRequest req) {
try {
RandomTable proposal = service.generateProposal(req.campaignId(), req.description(), req.diceFormula());
return ResponseEntity.ok(mapper.toDTO(proposal));
} catch (RandomTableGenerationException e) {
throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, e.getMessage(), e);
}
}
/** Improvisation IA d'un court récit sur un résultat tiré (utilisé en partie). */
@PostMapping("/improvise")
public ResponseEntity<Map<String, String>> improvise(@RequestBody ImproviseRequest req) {
try {
String narration = service.improviseRoll(
req.campaignId(), req.tableName(), req.resultLabel(), req.resultDetail());
return ResponseEntity.ok(Map.of("narration", narration));
} catch (RandomTableGenerationException e) {
throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, e.getMessage(), e);
}
}
public record GenerateRequest(String campaignId, String description, String diceFormula) {}
public record ImproviseRequest(String campaignId, String tableName, String resultLabel, String resultDetail) {}
private RandomTableService.TableData toData(RandomTableDTO dto, Integer order) {
return new RandomTableService.TableData(
dto.getName(),
dto.getDescription(),
dto.getDiceFormula(),
dto.getIcon(),
mapper.toDomainEntries(dto.getEntries()),
dto.getCampaignId(),
order
);
}
}

View File

@@ -145,6 +145,16 @@ public class SettingsController {
return forward(HttpMethod.GET, "/models/openrouter", null);
}
@GetMapping("/models/mistral")
public ResponseEntity<Map<String, Object>> listMistralModels() {
return forward(HttpMethod.GET, "/models/mistral", null);
}
@GetMapping("/models/gemini")
public ResponseEntity<Map<String, Object>> listGeminiModels() {
return forward(HttpMethod.GET, "/models/gemini", null);
}
/**
* Serialiseur JSON minimal pour eviter d'instancier ObjectMapper a chaque
* appel. Suffisant pour notre cas d'usage : Map<String,Object> avec des

View File

@@ -0,0 +1,21 @@
package com.loremind.infrastructure.web.dto.campaigncontext;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
/**
* DTO d'une table aléatoire (avec ses entrées).
*/
@Data
public class RandomTableDTO {
private String id;
private String name;
private String description;
private String diceFormula;
private String icon;
private String campaignId;
private int order;
private List<RandomTableEntryDTO> entries = new ArrayList<>();
}

View File

@@ -0,0 +1,14 @@
package com.loremind.infrastructure.web.dto.campaigncontext;
import lombok.Data;
/**
* DTO d'une entrée de table aléatoire (plage de jet → résultat).
*/
@Data
public class RandomTableEntryDTO {
private int minRoll;
private int maxRoll;
private String label;
private String detail;
}

View File

@@ -0,0 +1,51 @@
package com.loremind.infrastructure.web.mapper;
import com.loremind.domain.campaigncontext.RandomTable;
import com.loremind.domain.campaigncontext.RandomTableEntry;
import com.loremind.infrastructure.web.dto.campaigncontext.RandomTableDTO;
import com.loremind.infrastructure.web.dto.campaigncontext.RandomTableEntryDTO;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.stream.Collectors;
@Component
public class RandomTableMapper {
public RandomTableDTO toDTO(RandomTable t) {
if (t == null) return null;
RandomTableDTO dto = new RandomTableDTO();
dto.setId(t.getId());
dto.setName(t.getName());
dto.setDescription(t.getDescription());
dto.setDiceFormula(t.getDiceFormula());
dto.setIcon(t.getIcon());
dto.setCampaignId(t.getCampaignId());
dto.setOrder(t.getOrder());
dto.setEntries(t.getEntries().stream().map(this::toEntryDTO).collect(Collectors.toList()));
return dto;
}
public List<RandomTableEntry> toDomainEntries(List<RandomTableEntryDTO> dtos) {
if (dtos == null) return List.of();
return dtos.stream().map(this::toDomainEntry).collect(Collectors.toList());
}
private RandomTableEntryDTO toEntryDTO(RandomTableEntry e) {
RandomTableEntryDTO dto = new RandomTableEntryDTO();
dto.setMinRoll(e.getMinRoll());
dto.setMaxRoll(e.getMaxRoll());
dto.setLabel(e.getLabel());
dto.setDetail(e.getDetail());
return dto;
}
private RandomTableEntry toDomainEntry(RandomTableEntryDTO dto) {
return RandomTableEntry.builder()
.minRoll(dto.getMinRoll())
.maxRoll(dto.getMaxRoll())
.label(dto.getLabel())
.detail(dto.getDetail())
.build();
}
}