Plusieurs gros ajouts :
- 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:
@@ -14,7 +14,7 @@
|
||||
|
||||
<groupId>com.loremind</groupId>
|
||||
<artifactId>loremind-core</artifactId>
|
||||
<version>0.10.3-beta</version>
|
||||
<version>0.11.0-beta</version>
|
||||
<name>LoreMind Core</name>
|
||||
<description>Backend Core - Architecture Hexagonale</description>
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 1–10 est plus probable qu'un couvrant 11–12).
|
||||
* <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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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) {}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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<>();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user