Ajout de 2 fonctionnalitées principales : import PDF que ce soit pour les règles ou les campagnes directement.
Some checks failed
Build & Push Images / build (web) (push) Has been cancelled
Build & Push Images / build-switcher (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (brain) (push) Has been cancelled

Fonctionnalité de comparaison PDF / campagne pour faire un mix et demander des conseils à l'IA
This commit is contained in:
2026-06-04 13:55:27 +02:00
parent 091de0daf7
commit 6d00543a59
78 changed files with 5250 additions and 183 deletions

View File

@@ -0,0 +1,118 @@
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
* {@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 CampaignPdfAdvisor advisor;
public CampaignAdaptService(
CampaignRepository campaignRepository,
CampaignStructuralContextBuilder campaignContextBuilder,
LoreStructuralContextBuilder loreContextBuilder,
CampaignPdfAdvisor advisor) {
this.campaignRepository = campaignRepository;
this.campaignContextBuilder = campaignContextBuilder;
this.loreContextBuilder = loreContextBuilder;
this.advisor = advisor;
}
public void adviseStreaming(
String campaignId,
byte[] pdfBytes,
String filename,
String messagesJson,
Consumer<String> onToken,
Runnable onComplete,
Consumer<Throwable> onError) {
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();
}
}

View File

@@ -0,0 +1,181 @@
package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Arc;
import com.loremind.domain.campaigncontext.ArcType;
import com.loremind.domain.campaigncontext.CampaignImportProgress;
import com.loremind.domain.campaigncontext.CampaignImportProposal;
import com.loremind.domain.campaigncontext.CampaignImportProposal.ArcProposal;
import com.loremind.domain.campaigncontext.CampaignImportProposal.ChapterProposal;
import com.loremind.domain.campaigncontext.CampaignImportProposal.RoomProposal;
import com.loremind.domain.campaigncontext.CampaignImportProposal.SceneProposal;
import com.loremind.domain.campaigncontext.Chapter;
import com.loremind.domain.campaigncontext.Room;
import com.loremind.domain.campaigncontext.Scene;
import com.loremind.domain.campaigncontext.ports.CampaignPdfImporter;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import java.util.function.Consumer;
/**
* Service applicatif pour l'import d'un PDF de campagne.
*
* <p>Deux temps, conformes au principe « revue avant écriture » :
* 1. {@link #importStructureStreaming} génère une PROPOSITION d'arbre (rien
* n'est persisté), streamée pour l'avancement.
* 2. {@link #applyStructure} crée réellement les arcs/chapitres/scènes une fois
* l'arbre révisé/édité par l'utilisateur.</p>
*/
@Service
public class CampaignImportService {
private final CampaignPdfImporter campaignPdfImporter;
private final CampaignService campaignService;
private final ArcService arcService;
private final ChapterService chapterService;
private final SceneService sceneService;
public CampaignImportService(
CampaignPdfImporter campaignPdfImporter,
CampaignService campaignService,
ArcService arcService,
ChapterService chapterService,
SceneService sceneService) {
this.campaignPdfImporter = campaignPdfImporter;
this.campaignService = campaignService;
this.arcService = arcService;
this.chapterService = chapterService;
this.sceneService = sceneService;
}
/** Résumé de ce qui a été créé par {@link #applyStructure}. */
public record ApplyResult(int arcsCreated, int chaptersCreated, int scenesCreated) {}
/** Génère la proposition d'arbre (streamée). Ne persiste rien. */
public void importStructureStreaming(
byte[] pdfBytes,
String filename,
Consumer<CampaignImportProgress> onProgress,
Consumer<CampaignImportProposal> onDone,
Consumer<Throwable> onError) {
campaignPdfImporter.importCampaignStreaming(pdfBytes, filename, onProgress, onDone, onError);
}
/**
* Crée les arcs/chapitres/scènes de l'arbre révisé dans la campagne. Les
* arcs sont ajoutés APRÈS les arcs existants (ordre continué). Tout est créé
* dans une seule transaction (rollback si une étape échoue).
*/
@Transactional
public ApplyResult applyStructure(String campaignId, CampaignImportProposal proposal) {
if (!campaignService.campaignExists(campaignId)) {
throw new IllegalArgumentException("Campagne introuvable : " + campaignId);
}
int arcsCreated = 0, chaptersCreated = 0, scenesCreated = 0;
// Les nouveaux nœuds sont ordonnés APRÈS les frères existants (déjà comptés
// via leur existingId dans l'arbre fusionné venu de la revue).
int arcOrder = countExisting(proposal.arcs(), ArcProposal::existingId);
for (ArcProposal arcP : proposal.arcs()) {
if (isBlank(arcP.name())) continue;
String arcId;
if (!isBlank(arcP.existingId())) {
arcId = arcP.existingId(); // arc déjà présent → on s'y rattache
} else {
arcOrder++;
Arc arc = arcService.createArc(Arc.builder()
.name(arcP.name().trim())
.description(nullIfBlank(arcP.description()))
.campaignId(campaignId)
.order(arcOrder)
.type(parseArcType(arcP.type()))
.build());
arcId = arc.getId();
arcsCreated++;
}
int chapterOrder = countExisting(arcP.chapters(), ChapterProposal::existingId);
for (ChapterProposal chapP : safe(arcP.chapters())) {
if (isBlank(chapP.name())) continue;
String chapId;
if (!isBlank(chapP.existingId())) {
chapId = chapP.existingId();
} else {
chapterOrder++;
Chapter chapter = chapterService.createChapter(
chapP.name().trim(), nullIfBlank(chapP.description()), arcId, chapterOrder);
chapId = chapter.getId();
chaptersCreated++;
}
int sceneOrder = countExisting(chapP.scenes(), SceneProposal::existingId);
for (SceneProposal sceneP : safe(chapP.scenes())) {
if (isBlank(sceneP.name())) continue;
if (!isBlank(sceneP.existingId())) continue; // scène déjà présente
sceneOrder++;
sceneService.createScene(Scene.builder()
.name(sceneP.name().trim())
.description(nullIfBlank(sceneP.description()))
.playerNarration(nullIfBlank(sceneP.playerNarration()))
.gmSecretNotes(nullIfBlank(sceneP.gmNotes()))
.chapterId(chapId)
.order(sceneOrder)
.rooms(toRooms(sceneP.rooms()))
.build());
scenesCreated++;
}
}
}
return new ApplyResult(arcsCreated, chaptersCreated, scenesCreated);
}
/** Compte les nœuds déjà présents (existingId non vide) d'une liste. */
private static <T> int countExisting(List<T> list, java.util.function.Function<T, String> idOf) {
if (list == null) return 0;
int n = 0;
for (T t : list) {
if (!isBlank(idOf.apply(t))) n++;
}
return n;
}
/** "HUB" (insensible à la casse) → {@link ArcType#HUB} ; tout le reste → LINEAR. */
private static ArcType parseArcType(String type) {
return "HUB".equalsIgnoreCase(type == null ? "" : type.trim()) ? ArcType.HUB : ArcType.LINEAR;
}
/** Convertit les pièces proposées en {@link Room} (ID généré, ordre = index). */
private static List<Room> toRooms(List<RoomProposal> proposals) {
List<Room> rooms = new ArrayList<>();
if (proposals == null) return rooms;
int order = 0;
for (RoomProposal r : proposals) {
if (isBlank(r.name())) continue;
rooms.add(Room.builder()
.id(UUID.randomUUID().toString())
.name(r.name().trim())
.description(nullIfBlank(r.description()))
.enemies(nullIfBlank(r.enemies()))
.loot(nullIfBlank(r.loot()))
.order(order++)
.build());
}
return rooms;
}
private static <T> java.util.List<T> safe(java.util.List<T> list) {
return list == null ? java.util.List.of() : list;
}
private static boolean isBlank(String s) {
return s == null || s.isBlank();
}
private static String nullIfBlank(String s) {
return isBlank(s) ? null : s.trim();
}
}

View File

@@ -6,6 +6,7 @@ import com.loremind.domain.campaigncontext.ports.SceneRepository;
import org.springframework.beans.BeanUtils;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.Set;
@@ -40,6 +41,19 @@ public class SceneService {
return sceneRepository.save(scene);
}
/**
* Crée une scène à partir d'un objet Scene complet (tous les champs : narration
* joueurs, notes MJ, pièces…). Utilisé par l'import de campagne. L'ID est forcé
* à null pour laisser le repo en générer un nouveau.
*/
public Scene createScene(Scene input) {
input.setId(null);
if (input.getRooms() == null) {
input.setRooms(new ArrayList<>());
}
return sceneRepository.save(input);
}
public Optional<Scene> getSceneById(String id) {
return sceneRepository.findById(id);
}

View File

@@ -1,7 +1,9 @@
package com.loremind.application.gamesystemcontext;
import com.loremind.domain.gamesystemcontext.GameSystem;
import com.loremind.domain.gamesystemcontext.RulesImportResult;
import com.loremind.domain.gamesystemcontext.ports.GameSystemRepository;
import com.loremind.domain.gamesystemcontext.ports.RulesPdfImporter;
import com.loremind.domain.shared.template.TemplateField;
import org.springframework.stereotype.Service;
@@ -12,9 +14,34 @@ import java.util.Optional;
public class GameSystemService {
private final GameSystemRepository gameSystemRepository;
private final RulesPdfImporter rulesPdfImporter;
public GameSystemService(GameSystemRepository gameSystemRepository) {
public GameSystemService(GameSystemRepository gameSystemRepository,
RulesPdfImporter rulesPdfImporter) {
this.gameSystemRepository = gameSystemRepository;
this.rulesPdfImporter = rulesPdfImporter;
}
/**
* Importe un PDF de règles et renvoie une PROPOSITION de sections (titre →
* markdown). Ne persiste rien : l'UI laisse l'utilisateur réviser/éditer
* puis enregistrer le GameSystem via {@link #updateGameSystem}/{@link #createGameSystem}.
*/
public RulesImportResult importRulesFromPdf(byte[] pdfBytes, String filename) {
return rulesPdfImporter.importRules(pdfBytes, filename);
}
/**
* Variante streamée de {@link #importRulesFromPdf} : remonte l'avancement via
* callbacks (import long → l'UI affiche une progression). Ne persiste rien.
*/
public void importRulesFromPdfStreaming(
byte[] pdfBytes,
String filename,
java.util.function.Consumer<com.loremind.domain.gamesystemcontext.RulesImportProgress> onProgress,
java.util.function.Consumer<RulesImportResult> onDone,
java.util.function.Consumer<Throwable> onError) {
rulesPdfImporter.importRulesStreaming(pdfBytes, filename, onProgress, onDone, onError);
}
/**

View File

@@ -0,0 +1,18 @@
package com.loremind.domain.campaigncontext;
/**
* Évènement d'avancement émis pendant l'import streamé d'un PDF de campagne.
* <p>
* {@code total} = nombre de morceaux à traiter (0 pendant l'extraction).
* {@code current} = morceaux traités. Les compteurs arc/chapitre/scène donnent
* un aperçu de l'arbre trouvé jusqu'ici (affichage « au fil de l'eau »).
*/
public record CampaignImportProgress(
int current,
int total,
int pageCount,
int ocrPageCount,
int arcCount,
int chapterCount,
int sceneCount) {
}

View File

@@ -0,0 +1,40 @@
package com.loremind.domain.campaigncontext;
import java.util.List;
/**
* Proposition d'arborescence narrative extraite d'un PDF de campagne.
* <p>
* PROPOSITION non persistée : l'UI laisse l'utilisateur réviser/éditer l'arbre
* avant la création effective des arcs/chapitres/scènes. Records purs (domaine).
*/
public record CampaignImportProposal(List<ArcProposal> arcs) {
/**
* {@code existingId} (nullable) : si présent, le nœud existe DÉJÀ dans la
* campagne (rempli côté UI lors de la revue pré-chargée) → l'apply ne le
* recrée pas, il l'utilise comme parent des nouveaux enfants. Null = à créer.
*/
/** {@code type} = "LINEAR" ou "HUB" (mappé sur {@link ArcType} à l'apply). */
public record ArcProposal(
String name, String description, String type,
List<ChapterProposal> chapters, String existingId) {
}
public record ChapterProposal(
String name, String description, List<SceneProposal> scenes, String existingId) {
}
/**
* {@code rooms} non vide => lieu explorable (donjon). {@code playerNarration}
* = encadré « à lire aux joueurs », {@code gmNotes} = secrets/développement MJ.
*/
public record SceneProposal(
String name, String description, String playerNarration, String gmNotes,
List<RoomProposal> rooms, String existingId) {
}
public record RoomProposal(String name, String description, String enemies, String loot) {
}
}

View File

@@ -0,0 +1,16 @@
package com.loremind.domain.campaigncontext.ports;
/**
* Erreur de domaine : l'import d'un PDF de campagne a échoué (PDF illisible,
* Brain injoignable, LLM en erreur...).
*/
public class CampaignImportException extends RuntimeException {
public CampaignImportException(String message) {
super(message);
}
public CampaignImportException(String message, Throwable cause) {
super(message, cause);
}
}

View File

@@ -0,0 +1,32 @@
package com.loremind.domain.campaigncontext.ports;
import java.util.function.Consumer;
/**
* Port de sortie : produit des CONSEILS d'adaptation d'un PDF à une campagne
* existante, streamés token par token. Délègue au Brain (LLM + extraction PDF).
* <p>
* Contrairement à {@link CampaignPdfImporter} (qui structure pour créer), ici la
* sortie est du texte libre (markdown) : l'utilisateur applique à la main.
*/
public interface CampaignPdfAdvisor {
/**
* @param pdfBytes contenu du PDF à adapter.
* @param filename nom d'origine (diagnostic ; peut être null).
* @param brief description de la campagne existante (structure + PNJ + lore).
* @param messagesJson JSON de l'échange conversationnel ([{role, content}, …]) ;
* "[]" au 1er tour. Permet à l'utilisateur de répondre/corriger.
* @param onToken invoqué à chaque fragment de texte généré.
* @param onComplete invoqué à la fin normale du flux.
* @param onError invoqué en cas d'échec (PDF illisible, Brain/LLM en erreur).
*/
void adviseStreaming(
byte[] pdfBytes,
String filename,
String brief,
String messagesJson,
Consumer<String> onToken,
Runnable onComplete,
Consumer<Throwable> onError);
}

View File

@@ -0,0 +1,28 @@
package com.loremind.domain.campaigncontext.ports;
import com.loremind.domain.campaigncontext.CampaignImportProgress;
import com.loremind.domain.campaigncontext.CampaignImportProposal;
import java.util.function.Consumer;
/**
* Port de sortie : extrait et structure un PDF de campagne en arbre
* arc → chapitre → scène. L'implémentation délègue au Brain Python.
*/
public interface CampaignPdfImporter {
/**
* Variante streamée : l'import peut durer plusieurs minutes, on remonte
* l'avancement au fil de l'eau, puis la proposition finale.
*
* @param onProgress invoqué à chaque étape (extraction, puis par morceau).
* @param onDone invoqué une fois avec l'arbre proposé (non persisté).
* @param onError invoqué si l'extraction/structuration échoue.
*/
void importCampaignStreaming(
byte[] pdfBytes,
String filename,
Consumer<CampaignImportProgress> onProgress,
Consumer<CampaignImportProposal> onDone,
Consumer<Throwable> onError);
}

View File

@@ -0,0 +1,19 @@
package com.loremind.domain.gamesystemcontext;
import java.util.List;
/**
* Évènement d'avancement émis pendant l'import streamé d'un PDF de règles.
* <p>
* {@code total} = nombre de morceaux à traiter (0 tant que l'extraction n'est
* pas finie). {@code current} = morceaux déjà traités. {@code newSectionTitles}
* = titres de sections nouvellement trouvés/complétés par le dernier morceau
* (pour un affichage « au fil de l'eau »).
*/
public record RulesImportProgress(
int current,
int total,
int pageCount,
int ocrPageCount,
List<String> newSectionTitles) {
}

View File

@@ -0,0 +1,20 @@
package com.loremind.domain.gamesystemcontext;
import java.util.Map;
/**
* Proposition de règles extraites d'un PDF, prête à être révisée par l'utilisateur.
* <p>
* {@code sections} associe un titre de section à son contenu markdown — aligné
* sur le format {@link GameSystem#getRulesMarkdown()} (découpé par titres H2).
* C'est une PROPOSITION : rien n'est persisté ; l'UI laisse l'utilisateur
* réviser/éditer avant d'enregistrer le GameSystem.
* <p>
* {@code ocrPageCount} indique combien de pages ont nécessité l'OCR (scan) —
* 0 = PDF born-digital (couche texte présente).
*/
public record RulesImportResult(
Map<String, String> sections,
int pageCount,
int ocrPageCount) {
}

View File

@@ -0,0 +1,17 @@
package com.loremind.domain.gamesystemcontext.ports;
/**
* Erreur de domaine : l'import d'un PDF de règles a échoué (PDF illisible,
* Brain injoignable, LLM en erreur...). Les couches supérieures la traduisent
* en réponse HTTP sans connaître l'adapter concret.
*/
public class RulesImportException extends RuntimeException {
public RulesImportException(String message) {
super(message);
}
public RulesImportException(String message, Throwable cause) {
super(message, cause);
}
}

View File

@@ -0,0 +1,39 @@
package com.loremind.domain.gamesystemcontext.ports;
import com.loremind.domain.gamesystemcontext.RulesImportProgress;
import com.loremind.domain.gamesystemcontext.RulesImportResult;
import java.util.function.Consumer;
/**
* Port de sortie : extrait et structure les règles d'un PDF en sections.
* <p>
* L'implémentation (adapter) délègue au Brain Python (extraction texte + OCR +
* structuration LLM). Le domaine ne connaît ni HTTP, ni le Brain, ni le LLM.
*/
public interface RulesPdfImporter {
/**
* @param pdfBytes contenu binaire du PDF de règles.
* @param filename nom d'origine (diagnostic/logs ; peut être null).
* @return la proposition de sections (non persistée).
* @throws RulesImportException si l'extraction ou la structuration échoue.
*/
RulesImportResult importRules(byte[] pdfBytes, String filename);
/**
* Variante streamée : l'import peut durer plusieurs minutes, on remonte
* l'avancement au fil de l'eau. Les callbacks sont invoqués depuis le thread
* d'exécution de l'adapter (synchrone jusqu'à {@code onDone}/{@code onError}).
*
* @param onProgress invoqué à chaque étape (extraction, puis par morceau).
* @param onDone invoqué une fois avec le résultat final.
* @param onError invoqué si l'extraction/structuration échoue.
*/
void importRulesStreaming(
byte[] pdfBytes,
String filename,
Consumer<RulesImportProgress> onProgress,
Consumer<RulesImportResult> onDone,
Consumer<Throwable> onError);
}

View File

@@ -0,0 +1,120 @@
package com.loremind.infrastructure.ai;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.loremind.domain.campaigncontext.ports.CampaignPdfAdvisor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.http.MediaType;
import org.springframework.http.client.MultipartBodyBuilder;
import org.springframework.http.codec.ServerSentEvent;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.BodyInserters;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Flux;
import java.time.Duration;
import java.util.function.Consumer;
/**
* Adapter de sortie : implémente {@link CampaignPdfAdvisor} via WebClient + SSE
* (POST /adapt/campaign/stream). Envoie le PDF (multipart) + le brief de campagne,
* relaie les tokens de conseil au fil de l'eau.
*/
@Component
public class BrainCampaignAdaptClient implements CampaignPdfAdvisor {
private static final String ADAPT_PATH = "/adapt/campaign/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 BrainCampaignAdaptClient(
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 adviseStreaming(
byte[] pdfBytes,
String filename,
String brief,
String messagesJson,
Consumer<String> onToken,
Runnable onComplete,
Consumer<Throwable> onError) {
MultipartBodyBuilder parts = new MultipartBodyBuilder();
parts.part("file", new ByteArrayResource(pdfBytes) {
@Override
public String getFilename() {
return (filename == null || filename.isBlank()) ? "campaign.pdf" : filename;
}
});
parts.part("brief", brief == null ? "" : brief);
parts.part("messages", (messagesJson == null || messagesJson.isBlank()) ? "[]" : messagesJson);
Flux<ServerSentEvent<String>> flux = webClient.post()
.uri(ADAPT_PATH)
.contentType(MediaType.MULTIPART_FORM_DATA)
.accept(MediaType.TEXT_EVENT_STREAM)
.body(BodyInserters.fromMultipartData(parts.build()))
.retrieve()
.bodyToFlux(SSE_STRING_TYPE);
boolean[] terminated = {false};
try {
flux
.timeout(Duration.ofSeconds(timeoutSeconds))
.doOnNext(sse -> handleEvent(sse, terminated, onToken, onComplete, onError))
.blockLast();
// Flux clos sans 'done' explicite (ex: coupure) → on complète quand même.
if (!terminated[0]) onComplete.run();
} catch (Exception e) {
if (!terminated[0]) {
onError.accept(new RuntimeException(
"Erreur lors du streaming d'adaptation depuis le Brain.", e));
}
}
}
private void handleEvent(
ServerSentEvent<String> sse,
boolean[] terminated,
Consumer<String> onToken,
Runnable onComplete,
Consumer<Throwable> onError) {
String event = sse.event();
String data = sse.data() == null ? "" : sse.data();
if ("error".equals(event)) {
terminated[0] = true;
onError.accept(new RuntimeException("Le Brain a signalé une erreur : " + readField(data, "message")));
} else if ("done".equals(event)) {
terminated[0] = true;
onComplete.run();
} else if ("token".equals(event)) {
String token = readField(data, "token");
if (token != null && !token.isEmpty()) onToken.accept(token);
}
}
private String readField(String data, String field) {
try {
JsonNode node = objectMapper.readTree(data);
return node.hasNonNull(field) ? node.get(field).asText() : data;
} catch (Exception e) {
return data;
}
}
}

View File

@@ -0,0 +1,232 @@
package com.loremind.infrastructure.ai;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.loremind.domain.campaigncontext.CampaignImportProgress;
import com.loremind.domain.campaigncontext.CampaignImportProposal;
import com.loremind.domain.campaigncontext.CampaignImportProposal.ArcProposal;
import com.loremind.domain.campaigncontext.CampaignImportProposal.ChapterProposal;
import com.loremind.domain.campaigncontext.CampaignImportProposal.RoomProposal;
import com.loremind.domain.campaigncontext.CampaignImportProposal.SceneProposal;
import com.loremind.domain.campaigncontext.ports.CampaignImportException;
import com.loremind.domain.campaigncontext.ports.CampaignPdfImporter;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.http.MediaType;
import org.springframework.http.client.MultipartBodyBuilder;
import org.springframework.http.codec.ServerSentEvent;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.BodyInserters;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Flux;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Consumer;
/**
* Adapter de sortie : implémente {@link CampaignPdfImporter} en appelant le
* Brain Python via WebClient + SSE (POST /import/campaign/stream).
* <p>
* Le secret inter-service est ajouté par le WebClientCustomizer. Le timeout est
* long (import d'un livre entier = nombreux appels LLM en série).
*/
@Component
public class BrainCampaignImportClient implements CampaignPdfImporter {
private static final String IMPORT_CAMPAIGN_STREAM_PATH = "/import/campaign/stream";
private static final ParameterizedTypeReference<ServerSentEvent<String>> SSE_STRING_TYPE =
new ParameterizedTypeReference<>() {};
private final WebClient webClient;
private final ObjectMapper objectMapper;
private final long importTimeoutSeconds;
public BrainCampaignImportClient(
WebClient.Builder webClientBuilder,
ObjectMapper objectMapper,
@Value("${brain.base-url}") String baseUrl,
@Value("${brain.import-timeout-seconds:600}") long importTimeoutSeconds) {
this.webClient = webClientBuilder.baseUrl(baseUrl).build();
this.objectMapper = objectMapper;
this.importTimeoutSeconds = importTimeoutSeconds;
}
@Override
public void importCampaignStreaming(
byte[] pdfBytes,
String filename,
Consumer<CampaignImportProgress> onProgress,
Consumer<CampaignImportProposal> onDone,
Consumer<Throwable> onError) {
MultipartBodyBuilder parts = new MultipartBodyBuilder();
parts.part("file", filePart(pdfBytes, filename))
.filename(filename == null || filename.isBlank() ? "campaign.pdf" : filename);
Flux<ServerSentEvent<String>> flux = webClient.post()
.uri(IMPORT_CAMPAIGN_STREAM_PATH)
.contentType(MediaType.MULTIPART_FORM_DATA)
.accept(MediaType.TEXT_EVENT_STREAM)
.body(BodyInserters.fromMultipartData(parts.build()))
.retrieve()
.bodyToFlux(SSE_STRING_TYPE);
int[] pageCount = {0};
int[] ocrPageCount = {0};
boolean[] terminated = {false};
try {
flux
.timeout(Duration.ofSeconds(importTimeoutSeconds))
.doOnNext(sse -> handleEvent(
sse, pageCount, ocrPageCount, terminated, onProgress, onDone, onError))
.blockLast();
if (!terminated[0]) {
onError.accept(new CampaignImportException(
"Le flux d'import s'est interrompu avant la fin."));
}
} catch (Exception e) {
if (!terminated[0]) {
onError.accept(new CampaignImportException(
"Erreur lors du streaming d'import depuis le Brain.", e));
}
}
}
private void handleEvent(
ServerSentEvent<String> sse,
int[] pageCount,
int[] ocrPageCount,
boolean[] terminated,
Consumer<CampaignImportProgress> onProgress,
Consumer<CampaignImportProposal> onDone,
Consumer<Throwable> onError) {
String event = sse.event();
String data = sse.data() == null ? "" : sse.data();
if ("error".equals(event)) {
terminated[0] = true;
onError.accept(new CampaignImportException(
"Le Brain a signalé une erreur : " + readMessage(data)));
return;
}
if ("extracting".equals(event)) {
onProgress.accept(new CampaignImportProgress(0, 0, 0, 0, 0, 0, 0));
return;
}
JsonNode node = readJson(data);
if (node == null) return;
if ("start".equals(event)) {
pageCount[0] = node.path("page_count").asInt();
ocrPageCount[0] = node.path("ocr_page_count").asInt();
onProgress.accept(new CampaignImportProgress(
0, node.path("total").asInt(), pageCount[0], ocrPageCount[0], 0, 0, 0));
} else if ("progress".equals(event)) {
onProgress.accept(new CampaignImportProgress(
node.path("current").asInt(),
node.path("total").asInt(),
pageCount[0],
ocrPageCount[0],
node.path("arc_count").asInt(),
node.path("chapter_count").asInt(),
node.path("scene_count").asInt()));
} else if ("done".equals(event)) {
terminated[0] = true;
onDone.accept(new CampaignImportProposal(toArcs(node.path("arcs"))));
}
}
// --- Parsing de l'arbre --------------------------------------------------
private List<ArcProposal> toArcs(JsonNode arcsNode) {
List<ArcProposal> arcs = new ArrayList<>();
if (arcsNode != null && arcsNode.isArray()) {
for (JsonNode arc : arcsNode) {
arcs.add(new ArcProposal(
text(arc, "name"),
text(arc, "description"),
text(arc, "type"),
toChapters(arc.path("chapters")),
null));
}
}
return arcs;
}
private List<ChapterProposal> toChapters(JsonNode chaptersNode) {
List<ChapterProposal> chapters = new ArrayList<>();
if (chaptersNode != null && chaptersNode.isArray()) {
for (JsonNode ch : chaptersNode) {
chapters.add(new ChapterProposal(
text(ch, "name"),
text(ch, "description"),
toScenes(ch.path("scenes")),
null));
}
}
return chapters;
}
private List<SceneProposal> toScenes(JsonNode scenesNode) {
List<SceneProposal> scenes = new ArrayList<>();
if (scenesNode != null && scenesNode.isArray()) {
for (JsonNode sc : scenesNode) {
scenes.add(new SceneProposal(
text(sc, "name"), text(sc, "description"),
text(sc, "player_narration"), text(sc, "gm_notes"),
toRooms(sc.path("rooms")), null));
}
}
return scenes;
}
private List<RoomProposal> toRooms(JsonNode roomsNode) {
List<RoomProposal> rooms = new ArrayList<>();
if (roomsNode != null && roomsNode.isArray()) {
for (JsonNode rm : roomsNode) {
rooms.add(new RoomProposal(
text(rm, "name"), text(rm, "description"),
text(rm, "enemies"), text(rm, "loot")));
}
}
return rooms;
}
// --- Helpers -------------------------------------------------------------
private ByteArrayResource filePart(byte[] pdfBytes, String filename) {
return new ByteArrayResource(pdfBytes) {
@Override
public String getFilename() {
return (filename == null || filename.isBlank()) ? "campaign.pdf" : filename;
}
};
}
private static String text(JsonNode node, String field) {
JsonNode v = node.path(field);
return v.isMissingNode() || v.isNull() ? "" : v.asText();
}
private JsonNode readJson(String data) {
try {
return objectMapper.readTree(data);
} catch (Exception e) {
return null;
}
}
private String readMessage(String data) {
JsonNode node = readJson(data);
if (node != null && node.hasNonNull("message")) {
return node.get("message").asText();
}
return data;
}
}

View File

@@ -0,0 +1,248 @@
package com.loremind.infrastructure.ai;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.loremind.domain.gamesystemcontext.RulesImportProgress;
import com.loremind.domain.gamesystemcontext.RulesImportResult;
import com.loremind.domain.gamesystemcontext.ports.RulesImportException;
import com.loremind.domain.gamesystemcontext.ports.RulesPdfImporter;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.client.MultipartBodyBuilder;
import org.springframework.http.codec.ServerSentEvent;
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;
import org.springframework.web.reactive.function.BodyInserters;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Flux;
import java.time.Duration;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Consumer;
/**
* Adapter de sortie : implémente {@link RulesPdfImporter} en appelant le Brain
* Python via HTTP multipart.
* <p>
* Deux variantes :
* - {@link #importRules} : one-shot bloquant (RestTemplate, POST /import/rules).
* - {@link #importRulesStreaming} : streamé (WebClient + SSE, POST /import/rules/stream)
* pour remonter l'avancement d'un import long.
* <p>
* Le RestTemplate ({@code brainImportRestTemplate}) a un timeout long ; le secret
* inter-service est ajouté par l'intercepteur du bean (RestTemplate) et par le
* WebClientCustomizer (WebClient).
*/
@Component
public class BrainRulesImportClient implements RulesPdfImporter {
private static final String IMPORT_RULES_PATH = "/import/rules";
private static final String IMPORT_RULES_STREAM_PATH = "/import/rules/stream";
private static final ParameterizedTypeReference<ServerSentEvent<String>> SSE_STRING_TYPE =
new ParameterizedTypeReference<>() {};
private final RestTemplate restTemplate;
private final WebClient webClient;
private final ObjectMapper objectMapper;
private final String baseUrl;
private final long importTimeoutSeconds;
public BrainRulesImportClient(
@Qualifier("brainImportRestTemplate") RestTemplate restTemplate,
WebClient.Builder webClientBuilder,
ObjectMapper objectMapper,
@Value("${brain.base-url}") String baseUrl,
@Value("${brain.import-timeout-seconds:600}") long importTimeoutSeconds) {
this.restTemplate = restTemplate;
this.webClient = webClientBuilder.baseUrl(baseUrl).build();
this.objectMapper = objectMapper;
this.baseUrl = baseUrl;
this.importTimeoutSeconds = importTimeoutSeconds;
}
// --- One-shot (bloquant) -------------------------------------------------
@Override
public RulesImportResult importRules(byte[] pdfBytes, String filename) {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.MULTIPART_FORM_DATA);
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
body.add("file", filePart(pdfBytes, filename));
HttpEntity<MultiValueMap<String, Object>> entity = new HttpEntity<>(body, headers);
try {
BrainRulesImportResponse response = restTemplate.postForObject(
baseUrl + IMPORT_RULES_PATH, entity, BrainRulesImportResponse.class);
if (response == null || response.getSections() == null) {
throw new RulesImportException("Le Brain a renvoyé une réponse vide.");
}
return new RulesImportResult(
Map.copyOf(response.getSections()),
response.getPageCount(),
response.getOcrPageCount());
} catch (ResourceAccessException e) {
throw new RulesImportException(
"Le Brain est injoignable (timeout ou service arrêté).", e);
} catch (RestClientResponseException e) {
throw new RulesImportException(
"Le Brain a répondu HTTP " + e.getStatusCode().value()
+ " : " + e.getResponseBodyAsString(), e);
} catch (RulesImportException e) {
throw e;
} catch (Exception e) {
throw new RulesImportException("Erreur inattendue lors de l'import via le Brain.", e);
}
}
// --- Streamé (SSE) -------------------------------------------------------
@Override
public void importRulesStreaming(
byte[] pdfBytes,
String filename,
Consumer<RulesImportProgress> onProgress,
Consumer<RulesImportResult> onDone,
Consumer<Throwable> onError) {
MultipartBodyBuilder parts = new MultipartBodyBuilder();
parts.part("file", filePart(pdfBytes, filename))
.filename(filename == null || filename.isBlank() ? "rules.pdf" : filename);
Flux<ServerSentEvent<String>> flux = webClient.post()
.uri(IMPORT_RULES_STREAM_PATH)
.contentType(MediaType.MULTIPART_FORM_DATA)
.accept(MediaType.TEXT_EVENT_STREAM)
.body(BodyInserters.fromMultipartData(parts.build()))
.retrieve()
.bodyToFlux(SSE_STRING_TYPE);
// Holders mutables : le flux est consommé séquentiellement (blockLast),
// donc pas de souci de concurrence sur ces compteurs.
int[] pageCount = {0};
int[] ocrPageCount = {0};
boolean[] terminated = {false};
try {
flux
.timeout(Duration.ofSeconds(importTimeoutSeconds))
.doOnNext(sse -> handleEvent(
sse, pageCount, ocrPageCount, terminated, onProgress, onDone, onError))
.blockLast();
// Flux terminé sans event done/error (ex: connexion coupée) → on signale.
if (!terminated[0]) {
onError.accept(new RulesImportException(
"Le flux d'import s'est interrompu avant la fin."));
}
} catch (Exception e) {
if (!terminated[0]) {
onError.accept(new RulesImportException(
"Erreur lors du streaming d'import depuis le Brain.", e));
}
}
}
private void handleEvent(
ServerSentEvent<String> sse,
int[] pageCount,
int[] ocrPageCount,
boolean[] terminated,
Consumer<RulesImportProgress> onProgress,
Consumer<RulesImportResult> onDone,
Consumer<Throwable> onError) {
String event = sse.event();
String data = sse.data() == null ? "" : sse.data();
if ("error".equals(event)) {
terminated[0] = true;
onError.accept(new RulesImportException(
"Le Brain a signalé une erreur : " + readMessage(data)));
return;
}
if ("extracting".equals(event)) {
// Phase d'extraction : total inconnu (0) → l'UI affiche "Extraction…".
onProgress.accept(new RulesImportProgress(0, 0, 0, 0, List.of()));
return;
}
JsonNode node = readJson(data);
if (node == null) return;
if ("start".equals(event)) {
pageCount[0] = node.path("page_count").asInt();
ocrPageCount[0] = node.path("ocr_page_count").asInt();
onProgress.accept(new RulesImportProgress(
0, node.path("total").asInt(), pageCount[0], ocrPageCount[0], List.of()));
} else if ("progress".equals(event)) {
onProgress.accept(new RulesImportProgress(
node.path("current").asInt(),
node.path("total").asInt(),
pageCount[0],
ocrPageCount[0],
toStringList(node.path("new_sections"))));
} else if ("done".equals(event)) {
terminated[0] = true;
onDone.accept(new RulesImportResult(
toStringMap(node.path("sections")),
node.path("page_count").asInt(),
node.path("ocr_page_count").asInt()));
}
}
// --- Helpers -------------------------------------------------------------
/** ByteArrayResource avec nom de fichier : sans nom, l'upload n'est pas vu comme un fichier. */
private ByteArrayResource filePart(byte[] pdfBytes, String filename) {
return new ByteArrayResource(pdfBytes) {
@Override
public String getFilename() {
return (filename == null || filename.isBlank()) ? "rules.pdf" : filename;
}
};
}
private JsonNode readJson(String data) {
try {
return objectMapper.readTree(data);
} catch (Exception e) {
return null; // morceau de flux non-JSON inattendu : on l'ignore.
}
}
private String readMessage(String data) {
JsonNode node = readJson(data);
if (node != null && node.hasNonNull("message")) {
return node.get("message").asText();
}
return data;
}
private List<String> toStringList(JsonNode array) {
List<String> out = new ArrayList<>();
if (array != null && array.isArray()) {
array.forEach(n -> out.add(n.asText()));
}
return out;
}
private Map<String, String> toStringMap(JsonNode object) {
Map<String, String> out = new LinkedHashMap<>();
if (object != null && object.isObject()) {
object.fields().forEachRemaining(e -> out.put(e.getKey(), e.getValue().asText()));
}
return out;
}
}

View File

@@ -0,0 +1,26 @@
package com.loremind.infrastructure.ai;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.Map;
/**
* DTO interne de l'Adapter : JSON reçu du Brain sur POST /import/rules.
*
* @Data + @NoArgsConstructor : requis par Jackson pour la désérialisation.
*/
@Data
@NoArgsConstructor
class BrainRulesImportResponse {
@JsonProperty("sections")
private Map<String, String> sections;
@JsonProperty("page_count")
private int pageCount;
@JsonProperty("ocr_page_count")
private int ocrPageCount;
}

View File

@@ -5,6 +5,7 @@ import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.boot.web.reactive.function.client.WebClientCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.web.client.RestTemplate;
import java.time.Duration;
@@ -23,6 +24,7 @@ public class RestTemplateConfig {
private static final String INTERNAL_SECRET_HEADER = "X-Internal-Secret";
@Bean
@Primary
public RestTemplate brainRestTemplate(
RestTemplateBuilder builder,
@Value("${brain.timeout-seconds}") long timeoutSeconds,
@@ -39,6 +41,29 @@ public class RestTemplateConfig {
.build();
}
/**
* RestTemplate dédié à l'import de PDF (règles/campagne) au timeout LONG :
* l'extraction + la structuration map-reduce d'un livre entier enchaîne de
* nombreux appels LLM et dépasse facilement le timeout des appels courts.
* Même entête d'auth inter-service que {@link #brainRestTemplate}.
*/
@Bean
public RestTemplate brainImportRestTemplate(
RestTemplateBuilder builder,
@Value("${brain.import-timeout-seconds:600}") long importTimeoutSeconds,
@Value("${brain.internal-secret}") String internalSecret) {
return builder
.setConnectTimeout(Duration.ofSeconds(10))
.setReadTimeout(Duration.ofSeconds(importTimeoutSeconds))
.additionalInterceptors((request, body, execution) -> {
if (internalSecret != null && !internalSecret.isBlank()) {
request.getHeaders().set(INTERNAL_SECRET_HEADER, internalSecret);
}
return execution.execute(request, body);
})
.build();
}
/**
* Ajoute X-Internal-Secret comme header par defaut a tous les WebClient
* construits via le builder auto-configure par Spring Boot. Evite de

View File

@@ -0,0 +1,97 @@
package com.loremind.infrastructure.web.controller;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.loremind.application.campaigncontext.CampaignAdaptService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.core.task.TaskExecutor;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.io.IOException;
import java.util.Map;
/**
* REST Controller : conseils d'adaptation d'un PDF à une campagne existante (SSE).
* - POST /api/campaigns/{id}/adapt-pdf/stream (multipart) → flux de tokens markdown.
* Ne persiste rien : la sortie est du conseil libre à appliquer manuellement.
*/
@RestController
@RequestMapping("/api/campaigns/{campaignId}/adapt-pdf")
public class CampaignAdaptController {
private static final Logger log = LoggerFactory.getLogger(CampaignAdaptController.class);
private static final long SSE_TIMEOUT_MS = 15 * 60 * 1000L;
private final CampaignAdaptService campaignAdaptService;
private final TaskExecutor taskExecutor;
private final ObjectMapper objectMapper;
public CampaignAdaptController(
CampaignAdaptService campaignAdaptService,
@Qualifier("applicationTaskExecutor") TaskExecutor taskExecutor,
ObjectMapper objectMapper) {
this.campaignAdaptService = campaignAdaptService;
this.taskExecutor = taskExecutor;
this.objectMapper = objectMapper;
}
@PostMapping(value = "/stream",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter adaptStream(
@PathVariable String campaignId,
@RequestParam("file") MultipartFile file,
@RequestParam(value = "messages", required = false) String messagesJson) throws IOException {
SseEmitter emitter = new SseEmitter(SSE_TIMEOUT_MS);
if (file == null || file.isEmpty()) {
sendError(emitter, "Fichier PDF vide.");
return emitter;
}
byte[] bytes = file.getBytes();
String filename = file.getOriginalFilename();
taskExecutor.execute(() -> {
try {
campaignAdaptService.adviseStreaming(
campaignId, bytes, filename, messagesJson,
token -> sendToken(emitter, token),
() -> {
sendEvent(emitter, "done", Map.of());
emitter.complete();
},
error -> {
log.warn("Adaptation PDF échouée : {}", error.getMessage());
sendError(emitter, error.getMessage());
});
} catch (IllegalArgumentException e) {
sendError(emitter, "Campagne introuvable.");
} catch (Exception e) {
log.warn("Adaptation PDF échouée : {}", e.getMessage());
sendError(emitter, e.getMessage());
}
});
return emitter;
}
private void sendToken(SseEmitter emitter, String token) {
sendEvent(emitter, "token", Map.of("token", token));
}
private void sendError(SseEmitter emitter, String message) {
sendEvent(emitter, "error", Map.of("message", message != null ? message : "Erreur inconnue."));
emitter.complete();
}
private void sendEvent(SseEmitter emitter, String eventName, Object payload) {
try {
emitter.send(SseEmitter.event().name(eventName).data(
objectMapper.writeValueAsString(payload), MediaType.APPLICATION_JSON));
} catch (IOException e) {
emitter.completeWithError(e);
}
}
}

View File

@@ -0,0 +1,119 @@
package com.loremind.infrastructure.web.controller;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.loremind.application.campaigncontext.CampaignImportService;
import com.loremind.domain.campaigncontext.CampaignImportProposal;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.core.task.TaskExecutor;
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.servlet.mvc.method.annotation.SseEmitter;
import java.io.IOException;
import java.util.Map;
/**
* REST Controller pour l'import d'un PDF de campagne → arbre arc/chapitre/scène.
* <p>
* - POST /api/campaigns/{id}/import-structure/stream (multipart) → SSE de la
* proposition (progress + done). Ne persiste rien.
* - POST /api/campaigns/{id}/import-structure/apply (JSON arbre révisé) →
* crée les entités et renvoie le récapitulatif.
*/
@RestController
@RequestMapping("/api/campaigns/{campaignId}/import-structure")
public class CampaignImportController {
private static final Logger log = LoggerFactory.getLogger(CampaignImportController.class);
/** Timeout SSE généreux : un import de livre entier peut durer plusieurs minutes. */
private static final long IMPORT_SSE_TIMEOUT_MS = 15 * 60 * 1000L;
private final CampaignImportService campaignImportService;
private final TaskExecutor taskExecutor;
private final ObjectMapper objectMapper;
public CampaignImportController(
CampaignImportService campaignImportService,
@Qualifier("applicationTaskExecutor") TaskExecutor taskExecutor,
ObjectMapper objectMapper) {
this.campaignImportService = campaignImportService;
this.taskExecutor = taskExecutor;
this.objectMapper = objectMapper;
}
@PostMapping(value = "/stream",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter importStream(
@PathVariable String campaignId,
@RequestParam("file") MultipartFile file) throws IOException {
SseEmitter emitter = new SseEmitter(IMPORT_SSE_TIMEOUT_MS);
if (file == null || file.isEmpty()) {
sendError(emitter, "Fichier PDF vide.");
return emitter;
}
byte[] bytes = file.getBytes();
String filename = file.getOriginalFilename();
taskExecutor.execute(() -> {
try {
campaignImportService.importStructureStreaming(
bytes, filename,
progress -> sendEvent(emitter, "progress", progress),
proposal -> {
sendEvent(emitter, "done", proposal);
emitter.complete();
},
error -> {
log.warn("Import campagne (stream) échoué : {}", error.getMessage());
sendError(emitter, error.getMessage());
});
} catch (Exception e) {
log.warn("Import campagne (stream) échoué : {}", e.getMessage());
sendError(emitter, e.getMessage());
}
});
return emitter;
}
@PostMapping(value = "/apply", consumes = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<CampaignImportService.ApplyResult> apply(
@PathVariable String campaignId,
@RequestBody CampaignImportProposal proposal) {
try {
CampaignImportService.ApplyResult result =
campaignImportService.applyStructure(campaignId, proposal);
return ResponseEntity.ok(result);
} catch (IllegalArgumentException e) {
return ResponseEntity.notFound().build();
}
}
// --- Helpers SSE ---------------------------------------------------------
private void sendEvent(SseEmitter emitter, String eventName, Object payload) {
try {
emitter.send(SseEmitter.event().name(eventName).data(
objectMapper.writeValueAsString(payload), MediaType.APPLICATION_JSON));
} catch (IOException e) {
emitter.completeWithError(e);
}
}
private void sendError(SseEmitter emitter, String message) {
try {
emitter.send(SseEmitter.event().name("error").data(
objectMapper.writeValueAsString(Map.of(
"message", message != null ? message : "Erreur inconnue.")),
MediaType.APPLICATION_JSON));
emitter.complete();
} catch (IOException e) {
emitter.completeWithError(e);
}
}
}

View File

@@ -1,33 +1,59 @@
package com.loremind.infrastructure.web.controller;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.loremind.application.gamesystemcontext.GameSystemService;
import com.loremind.domain.gamesystemcontext.GameSystem;
import com.loremind.domain.gamesystemcontext.RulesImportProgress;
import com.loremind.domain.gamesystemcontext.RulesImportResult;
import com.loremind.domain.gamesystemcontext.ports.RulesImportException;
import com.loremind.domain.shared.template.TemplateField;
import com.loremind.infrastructure.web.dto.gamesystemcontext.GameSystemDTO;
import com.loremind.infrastructure.web.dto.gamesystemcontext.RulesImportResponseDTO;
import com.loremind.infrastructure.web.dto.shared.TemplateFieldDTO;
import com.loremind.infrastructure.web.mapper.GameSystemMapper;
import com.loremind.infrastructure.web.mapper.TemplateFieldMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
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.servlet.mvc.method.annotation.SseEmitter;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@RestController
@RequestMapping("/api/game-systems")
public class GameSystemController {
private static final Logger log = LoggerFactory.getLogger(GameSystemController.class);
/** Timeout SSE généreux : un import de livre entier peut durer plusieurs minutes. */
private static final long IMPORT_SSE_TIMEOUT_MS = 15 * 60 * 1000L;
private final GameSystemService gameSystemService;
private final GameSystemMapper gameSystemMapper;
private final TemplateFieldMapper templateFieldMapper;
private final TaskExecutor taskExecutor;
private final ObjectMapper objectMapper;
public GameSystemController(GameSystemService gameSystemService,
GameSystemMapper gameSystemMapper,
TemplateFieldMapper templateFieldMapper) {
TemplateFieldMapper templateFieldMapper,
@Qualifier("applicationTaskExecutor") TaskExecutor taskExecutor,
ObjectMapper objectMapper) {
this.gameSystemService = gameSystemService;
this.gameSystemMapper = gameSystemMapper;
this.templateFieldMapper = templateFieldMapper;
this.taskExecutor = taskExecutor;
this.objectMapper = objectMapper;
}
@PostMapping
@@ -71,6 +97,96 @@ public class GameSystemController {
return ResponseEntity.noContent().build();
}
/**
* Import d'un PDF de règles → proposition de sections (titre → markdown).
* Ne persiste RIEN : l'UI présente la proposition pour révision/édition,
* puis l'utilisateur enregistre le GameSystem via les endpoints habituels.
*/
@PostMapping(value = "/import-rules", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<RulesImportResponseDTO> importRules(@RequestParam("file") MultipartFile file) {
if (file == null || file.isEmpty()) {
return ResponseEntity.badRequest().build();
}
try {
RulesImportResult result = gameSystemService.importRulesFromPdf(
file.getBytes(), file.getOriginalFilename());
return ResponseEntity.ok(new RulesImportResponseDTO(
result.sections(), result.pageCount(), result.ocrPageCount()));
} catch (IOException e) {
return ResponseEntity.badRequest().build();
} catch (RulesImportException e) {
// Brain injoignable / LLM en erreur / PDF illisible : 502 (dépendance amont).
// On loggue la vraie cause (message du Brain propagé) pour le diagnostic.
log.warn("Import de règles échoué : {}", e.getMessage());
return ResponseEntity.status(HttpStatus.BAD_GATEWAY).build();
}
}
/**
* Variante streamée de l'import : émet l'avancement (SSE) puis le résultat,
* pour que l'UI affiche une progression pendant un import long.
* <p>
* Événements : {@code progress} (current/total/pageCount/ocrPageCount/newSectionTitles),
* {@code done} (sections + compteurs), {@code error} (message).
*/
@PostMapping(value = "/import-rules/stream",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter importRulesStream(@RequestParam("file") MultipartFile file) throws IOException {
SseEmitter emitter = new SseEmitter(IMPORT_SSE_TIMEOUT_MS);
if (file == null || file.isEmpty()) {
sendImportError(emitter, "Fichier PDF vide.");
return emitter;
}
// Les octets sont lus sur le thread servlet (le MultipartFile n'est plus
// disponible une fois la requête asynchrone) avant de partir en tâche de fond.
byte[] bytes = file.getBytes();
String filename = file.getOriginalFilename();
taskExecutor.execute(() -> {
try {
gameSystemService.importRulesFromPdfStreaming(
bytes, filename,
progress -> sendImportEvent(emitter, "progress", progress),
result -> {
sendImportEvent(emitter, "done", result);
emitter.complete();
},
error -> {
log.warn("Import de règles (stream) échoué : {}", error.getMessage());
sendImportError(emitter, error.getMessage());
});
} catch (Exception e) {
log.warn("Import de règles (stream) échoué : {}", e.getMessage());
sendImportError(emitter, e.getMessage());
}
});
return emitter;
}
/** Sérialise `payload` en JSON et l'envoie comme évènement SSE nommé. */
private void sendImportEvent(SseEmitter emitter, String eventName, Object payload) {
try {
emitter.send(SseEmitter.event().name(eventName).data(
objectMapper.writeValueAsString(payload), MediaType.APPLICATION_JSON));
} catch (IOException e) {
emitter.completeWithError(e);
}
}
/** Envoie un évènement `error` {message} puis termine le flux. */
private void sendImportError(SseEmitter emitter, String message) {
try {
emitter.send(SseEmitter.event().name("error").data(
objectMapper.writeValueAsString(Map.of(
"message", message != null ? message : "Erreur inconnue.")),
MediaType.APPLICATION_JSON));
emitter.complete();
} catch (IOException e) {
emitter.completeWithError(e);
}
}
private GameSystemService.GameSystemData toData(GameSystemDTO dto) {
return new GameSystemService.GameSystemData(
dto.getName(),

View File

@@ -0,0 +1,16 @@
package com.loremind.infrastructure.web.dto.gamesystemcontext;
import java.util.Map;
/**
* Réponse de l'import d'un PDF de règles : proposition de sections à réviser.
*
* @param sections titre de section → contenu markdown (non persisté).
* @param pageCount nombre de pages extraites du PDF.
* @param ocrPageCount nombre de pages ayant nécessité l'OCR (0 = born-digital).
*/
public record RulesImportResponseDTO(
Map<String, String> sections,
int pageCount,
int ocrPageCount) {
}

View File

@@ -35,6 +35,9 @@ spring.web.cors.allow-credentials=true
# Configuration du Brain (service IA Python)
brain.base-url=${BRAIN_BASE_URL:http://localhost:8000}
brain.timeout-seconds=120
# Timeout dedie a l'import de PDF (regles/campagne) : map-reduce LLM sur un
# livre entier => potentiellement plusieurs minutes. Surchargeable via env.
brain.import-timeout-seconds=${BRAIN_IMPORT_TIMEOUT_SECONDS:600}
# Secret partage Core <-> Brain (auth inter-service via entete X-Internal-Secret).
# OBLIGATOIRE en prod : si non defini, le Brain rejette toutes les requetes (fail-closed).
@@ -53,9 +56,10 @@ minio.access-key=${MINIO_ACCESS_KEY:minioadmin}
minio.secret-key=${MINIO_SECRET_KEY:minioadmin}
minio.bucket=${MINIO_BUCKET:loremind-images}
# Limites d'upload d'images (MB)
spring.servlet.multipart.max-file-size=10MB
spring.servlet.multipart.max-request-size=10MB
# Limites d'upload multipart (MB) : images ET PDF de regles. Releve a 64 Mo
# pour accepter les livres de regles illustres (le Brain plafonne aussi a 60 Mo).
spring.servlet.multipart.max-file-size=64MB
spring.servlet.multipart.max-request-size=64MB
# Mode demo : masque Settings/Export cote front et bloque les PUT /api/settings
# cote serveur. Activer via DEMO_MODE=true sur les deploiements publics.