Ajout de 2 fonctionnalitées principales : import PDF que ce soit pour les règles ou les campagnes directement.
Fonctionnalité de comparaison PDF / campagne pour faire un mix et demander des conseils à l'IA
This commit is contained in:
@@ -14,7 +14,7 @@
|
||||
|
||||
<groupId>com.loremind</groupId>
|
||||
<artifactId>loremind-core</artifactId>
|
||||
<version>0.9.2-beta</version>
|
||||
<version>0.10.0-beta</version>
|
||||
<name>LoreMind Core</name>
|
||||
<description>Backend Core - Architecture Hexagonale</description>
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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) {
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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) {
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user