Mise à jour avec la possibilité de mettre des images

This commit is contained in:
2026-04-21 02:47:09 +02:00
parent 5b133aa2fe
commit 1a5b6f8d79
125 changed files with 4866 additions and 348 deletions

View File

@@ -69,6 +69,13 @@
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!-- MinIO — client S3-compatible pour le stockage d'images (Shared Kernel images). -->
<dependency>
<groupId>io.minio</groupId>
<artifactId>minio</artifactId>
<version>8.5.11</version>
</dependency>
</dependencies>
<build>

View File

@@ -63,6 +63,7 @@ public class ArcService {
arc.setRewards(updated.getRewards());
arc.setResolution(updated.getResolution());
arc.setRelatedPageIds(updated.getRelatedPageIds());
arc.setIllustrationImageIds(updated.getIllustrationImageIds());
return arcRepository.save(arc);
}

View File

@@ -60,6 +60,7 @@ public class ChapterService {
chapter.setPlayerObjectives(updated.getPlayerObjectives());
chapter.setNarrativeStakes(updated.getNarrativeStakes());
chapter.setRelatedPageIds(updated.getRelatedPageIds());
chapter.setIllustrationImageIds(updated.getIllustrationImageIds());
return chapterRepository.save(chapter);
}

View File

@@ -65,6 +65,7 @@ public class SceneService {
scene.setCombatDifficulty(updated.getCombatDifficulty());
scene.setEnemies(updated.getEnemies());
scene.setRelatedPageIds(updated.getRelatedPageIds());
scene.setIllustrationImageIds(updated.getIllustrationImageIds());
return sceneRepository.save(scene);
}

View File

@@ -0,0 +1,111 @@
package com.loremind.application.generationcontext;
import com.loremind.domain.campaigncontext.Arc;
import com.loremind.domain.campaigncontext.Campaign;
import com.loremind.domain.campaigncontext.Chapter;
import com.loremind.domain.campaigncontext.Scene;
import com.loremind.domain.campaigncontext.ports.ArcRepository;
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
import com.loremind.domain.campaigncontext.ports.SceneRepository;
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.SceneSummary;
import org.springframework.stereotype.Component;
import java.util.Comparator;
import java.util.List;
import java.util.stream.Collectors;
/**
* Service applicatif qui construit un {@link CampaignStructuralContext}
* depuis le Campaign Context (projection Campaign → GenerationContext).
*
* Traverse l'arbre arcs → chapitres → scènes en respectant l'ordre narratif
* (tri sur le champ `order` de chaque entité). Charge le NOM + le SYNOPSIS
* (description courte) de chaque niveau : l'IA sait donc de quoi parle
* chaque scène/chapitre/arc sans qu'on lui passe les notes MJ ou la
* narration détaillée — celles-ci restent réservées à l'entité focus via
* NarrativeEntityContext.
*/
@Component
public class CampaignStructuralContextBuilder {
private final CampaignRepository campaignRepository;
private final ArcRepository arcRepository;
private final ChapterRepository chapterRepository;
private final SceneRepository sceneRepository;
public CampaignStructuralContextBuilder(
CampaignRepository campaignRepository,
ArcRepository arcRepository,
ChapterRepository chapterRepository,
SceneRepository sceneRepository) {
this.campaignRepository = campaignRepository;
this.arcRepository = arcRepository;
this.chapterRepository = chapterRepository;
this.sceneRepository = sceneRepository;
}
/**
* Construit la carte narrative d'une Campagne (arcs → chapitres → scènes,
* nom + description courte à chaque niveau).
* @throws IllegalArgumentException si la Campagne est introuvable
*/
public CampaignStructuralContext build(String campaignId) {
Campaign campaign = campaignRepository.findById(campaignId)
.orElseThrow(() -> new IllegalArgumentException(
"Campagne non trouvée avec l'ID: " + campaignId));
List<ArcSummary> arcs = arcRepository.findByCampaignId(campaignId).stream()
.sorted(Comparator.comparingInt(Arc::getOrder))
.map(this::toArcSummary)
.collect(Collectors.toList());
return CampaignStructuralContext.builder()
.campaignName(campaign.getName())
.campaignDescription(campaign.getDescription())
.arcs(arcs)
.build();
}
private ArcSummary toArcSummary(Arc arc) {
List<ChapterSummary> chapters = chapterRepository.findByArcId(arc.getId()).stream()
.sorted(Comparator.comparingInt(Chapter::getOrder))
.map(this::toChapterSummary)
.collect(Collectors.toList());
return ArcSummary.builder()
.name(arc.getName())
.description(arc.getDescription())
.illustrationCount(countImages(arc.getIllustrationImageIds()))
.chapters(chapters)
.build();
}
private ChapterSummary toChapterSummary(Chapter chapter) {
List<SceneSummary> scenes = sceneRepository.findByChapterId(chapter.getId()).stream()
.sorted(Comparator.comparingInt(Scene::getOrder))
.map(this::toSceneSummary)
.collect(Collectors.toList());
return ChapterSummary.builder()
.name(chapter.getName())
.description(chapter.getDescription())
.illustrationCount(countImages(chapter.getIllustrationImageIds()))
.scenes(scenes)
.build();
}
private SceneSummary toSceneSummary(Scene scene) {
return SceneSummary.builder()
.name(scene.getName())
.description(scene.getDescription())
.illustrationCount(countImages(scene.getIllustrationImageIds()))
.build();
}
/** Helper defensif : compte les illustrations attachees (null-safe). */
private static int countImages(List<String> ids) {
return ids == null ? 0 : ids.size();
}
}

View File

@@ -72,7 +72,9 @@ public class GeneratePageValuesUseCase {
.loreDescription(lore.getDescription())
.folderName(folder.getName())
.templateName(template.getName())
.templateFields(template.getFields())
// Seuls les champs TEXT sont envoyes a l'IA : les champs IMAGE
// necessitent un workflow different (pas de generation LLM texte).
.templateFields(template.textFieldNames())
.pageTitle(page.getTitle())
.build();
@@ -114,10 +116,12 @@ public class GeneratePageValuesUseCase {
}
private void requireNonEmptyFields(Template template) {
if (template.getFields() == null || template.getFields().isEmpty()) {
// On exige au moins un champ TEXT : les champs IMAGE ne sont pas genereables
// par l'IA (pas de text-to-image pour l'instant).
if (template.textFieldNames().isEmpty()) {
throw new IllegalStateException(
"Le template '" + template.getName()
+ "' n'a aucun champ à générer.");
+ "' n'a aucun champ texte à générer.");
}
}
}

View File

@@ -0,0 +1,175 @@
package com.loremind.application.generationcontext;
import com.loremind.domain.generationcontext.LoreStructuralContext;
import com.loremind.domain.generationcontext.LoreStructuralContext.PageSummary;
import com.loremind.domain.lorecontext.Lore;
import com.loremind.domain.lorecontext.LoreNode;
import com.loremind.domain.lorecontext.Page;
import com.loremind.domain.lorecontext.Template;
import com.loremind.domain.lorecontext.ports.LoreNodeRepository;
import com.loremind.domain.lorecontext.ports.LoreRepository;
import com.loremind.domain.lorecontext.ports.PageRepository;
import com.loremind.domain.lorecontext.ports.TemplateRepository;
import org.springframework.stereotype.Component;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;
/**
* Service applicatif qui construit un {@link LoreStructuralContext}
* depuis le Lore Context (Single Responsibility : projection LoreContext → GenerationContext).
*
* Partagé entre {@link StreamChatForLoreUseCase} (Lore) et
* {@link StreamChatForCampaignUseCase} (Campagne liée à un Lore) pour
* respecter DRY — la carte structurelle d'un Lore se calcule de la même
* manière des deux côtés.
*
* Depuis b9 : chaque PageSummary embarque values/tags/relatedPageTitles
* (résolus en titres), avec troncature à {@value #MAX_VALUE_LENGTH} caractères
* par valeur pour garder le prompt sous contrôle.
*/
@Component
public class LoreStructuralContextBuilder {
/** Garde-fou : évite qu'un champ énorme (ex: "Histoire" de 5000 car.) ne sature le prompt. */
private static final int MAX_VALUE_LENGTH = 500;
private final LoreRepository loreRepository;
private final LoreNodeRepository loreNodeRepository;
private final PageRepository pageRepository;
private final TemplateRepository templateRepository;
public LoreStructuralContextBuilder(
LoreRepository loreRepository,
LoreNodeRepository loreNodeRepository,
PageRepository pageRepository,
TemplateRepository templateRepository) {
this.loreRepository = loreRepository;
this.loreNodeRepository = loreNodeRepository;
this.pageRepository = pageRepository;
this.templateRepository = templateRepository;
}
/**
* Construit la carte structurelle pour un Lore obligatoire.
* @throws IllegalArgumentException si le Lore est introuvable
*/
public LoreStructuralContext build(String loreId) {
return buildOptional(loreId).orElseThrow(() ->
new IllegalArgumentException("Lore non trouvé avec l'ID: " + loreId));
}
/**
* Variante non-strict : renvoie Optional.empty() si le Lore a été supprimé
* (cas d'une Campagne dont le loreId pointe sur un Lore effacé entre-temps).
*/
public Optional<LoreStructuralContext> buildOptional(String loreId) {
return loreRepository.findById(loreId).map(this::buildFromLore);
}
private LoreStructuralContext buildFromLore(Lore lore) {
List<LoreNode> nodes = loreNodeRepository.findByLoreId(lore.getId());
List<Page> pages = pageRepository.findByLoreId(lore.getId());
List<Template> templates = templateRepository.findByLoreId(lore.getId());
// Maps de résolution construites une seule fois — évite les N² en aval.
Map<String, String> templateNameById = templates.stream()
.collect(Collectors.toMap(Template::getId, Template::getName, (a, b) -> a));
Map<String, String> pageTitleById = pages.stream()
.collect(Collectors.toMap(Page::getId, Page::getTitle, (a, b) -> a));
return LoreStructuralContext.builder()
.loreName(lore.getName())
.loreDescription(lore.getDescription())
.folders(buildFoldersMap(nodes, pages, templateNameById, pageTitleById))
.tags(extractUniqueTags(pages))
.build();
}
private Map<String, List<PageSummary>> buildFoldersMap(
List<LoreNode> nodes,
List<Page> pages,
Map<String, String> templateNameById,
Map<String, String> pageTitleById) {
// LinkedHashMap : préserve l'ordre d'insertion pour un prompt lisible.
Map<String, List<PageSummary>> folders = new LinkedHashMap<>();
for (LoreNode node : nodes) {
folders.put(node.getName(), pagesInFolder(node.getId(), pages, templateNameById, pageTitleById));
}
return folders;
}
private List<PageSummary> pagesInFolder(
String nodeId,
List<Page> allPages,
Map<String, String> templateNameById,
Map<String, String> pageTitleById) {
return allPages.stream()
.filter(p -> nodeId.equals(p.getNodeId()))
.map(p -> toPageSummary(p, templateNameById, pageTitleById))
.collect(Collectors.toList());
}
private PageSummary toPageSummary(
Page page,
Map<String, String> templateNameById,
Map<String, String> pageTitleById) {
return PageSummary.builder()
.title(page.getTitle())
.templateName(templateNameById.getOrDefault(page.getTemplateId(), "?"))
.values(truncatedValues(page.getValues()))
.tags(page.getTags() != null ? List.copyOf(page.getTags()) : Collections.emptyList())
.relatedPageTitles(resolveRelatedTitles(page.getRelatedPageIds(), pageTitleById))
.build();
}
/**
* Copie défensive des values avec troncature par valeur.
* Les entrées vides/nulles sont filtrées pour alléger le prompt.
*/
private Map<String, String> truncatedValues(Map<String, String> source) {
if (source == null || source.isEmpty()) {
return Collections.emptyMap();
}
Map<String, String> out = new LinkedHashMap<>();
for (Map.Entry<String, String> e : source.entrySet()) {
String v = e.getValue();
if (v == null || v.isBlank()) continue;
out.put(e.getKey(), truncate(v));
}
return out;
}
private String truncate(String value) {
if (value.length() <= MAX_VALUE_LENGTH) return value;
return value.substring(0, MAX_VALUE_LENGTH) + "";
}
/**
* Résout les IDs de pages liées en titres. Un ID qui ne matche rien
* (page supprimée entre-temps) est silencieusement ignoré — pas de "?"
* qui polluerait le prompt.
*/
private List<String> resolveRelatedTitles(
List<String> relatedIds, Map<String, String> pageTitleById) {
if (relatedIds == null || relatedIds.isEmpty()) {
return Collections.emptyList();
}
return relatedIds.stream()
.map(pageTitleById::get)
.filter(title -> title != null && !title.isBlank())
.collect(Collectors.toList());
}
private List<String> extractUniqueTags(List<Page> pages) {
return pages.stream()
.filter(p -> p.getTags() != null)
.flatMap(p -> p.getTags().stream())
.distinct()
.collect(Collectors.toList());
}
}

View File

@@ -0,0 +1,126 @@
package com.loremind.application.generationcontext;
import com.loremind.domain.campaigncontext.Arc;
import com.loremind.domain.campaigncontext.Chapter;
import com.loremind.domain.campaigncontext.Scene;
import com.loremind.domain.campaigncontext.ports.ArcRepository;
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
import com.loremind.domain.campaigncontext.ports.SceneRepository;
import com.loremind.domain.generationcontext.NarrativeEntityContext;
import org.springframework.stereotype.Component;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* Service applicatif qui construit un {@link NarrativeEntityContext}
* depuis une entité Arc / Chapter / Scene du Campaign Context.
*
* Responsabilité unique : mapper les champs textuels spécifiques de chaque
* type vers la map uniforme `fields` du VO. Utilise LinkedHashMap pour
* préserver l'ordre des champs dans le prompt (lisibilité).
*/
@Component
public class NarrativeEntityContextBuilder {
private final ArcRepository arcRepository;
private final ChapterRepository chapterRepository;
private final SceneRepository sceneRepository;
public NarrativeEntityContextBuilder(
ArcRepository arcRepository,
ChapterRepository chapterRepository,
SceneRepository sceneRepository) {
this.arcRepository = arcRepository;
this.chapterRepository = chapterRepository;
this.sceneRepository = sceneRepository;
}
/**
* Charge l'entité narrative ciblée et la projette vers un VO du GenerationContext.
*
* @param entityType "arc", "chapter" ou "scene" (insensible à la casse)
* @param entityId l'ID de l'entité
* @throws IllegalArgumentException si le type est inconnu ou l'entité introuvable
*/
public NarrativeEntityContext build(String entityType, String entityId) {
String normalized = entityType == null ? "" : entityType.trim().toLowerCase();
switch (normalized) {
case "arc": return fromArc(loadArc(entityId));
case "chapter": return fromChapter(loadChapter(entityId));
case "scene": return fromScene(loadScene(entityId));
default:
throw new IllegalArgumentException("Type d'entité narrative inconnu: " + entityType);
}
}
// --- Chargement ---------------------------------------------------------
private Arc loadArc(String id) {
return arcRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Arc non trouvé: " + id));
}
private Chapter loadChapter(String id) {
return chapterRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Chapitre non trouvé: " + id));
}
private Scene loadScene(String id) {
return sceneRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Scène non trouvée: " + id));
}
// --- Mapping entité → VO ------------------------------------------------
private NarrativeEntityContext fromArc(Arc a) {
Map<String, String> fields = new LinkedHashMap<>();
putField(fields, "description (synopsis)", a.getDescription());
putField(fields, "themes", a.getThemes());
putField(fields, "stakes", a.getStakes());
putField(fields, "rewards", a.getRewards());
putField(fields, "resolution", a.getResolution());
putField(fields, "gmNotes", a.getGmNotes());
return NarrativeEntityContext.builder()
.entityType("arc")
.title(a.getName())
.fields(fields)
.build();
}
private NarrativeEntityContext fromChapter(Chapter c) {
Map<String, String> fields = new LinkedHashMap<>();
putField(fields, "description (synopsis)", c.getDescription());
putField(fields, "playerObjectives", c.getPlayerObjectives());
putField(fields, "narrativeStakes", c.getNarrativeStakes());
putField(fields, "gmNotes", c.getGmNotes());
return NarrativeEntityContext.builder()
.entityType("chapter")
.title(c.getName())
.fields(fields)
.build();
}
private NarrativeEntityContext fromScene(Scene s) {
Map<String, String> fields = new LinkedHashMap<>();
putField(fields, "description", s.getDescription());
putField(fields, "location", s.getLocation());
putField(fields, "timing", s.getTiming());
putField(fields, "atmosphere", s.getAtmosphere());
putField(fields, "playerNarration", s.getPlayerNarration());
putField(fields, "choicesConsequences", s.getChoicesConsequences());
putField(fields, "combatDifficulty", s.getCombatDifficulty());
putField(fields, "enemies", s.getEnemies());
putField(fields, "gmSecretNotes", s.getGmSecretNotes());
return NarrativeEntityContext.builder()
.entityType("scene")
.title(s.getName())
.fields(fields)
.build();
}
/** Null/blank devient chaîne vide — uniforme côté prompt, pas de NPE côté LLM. */
private static void putField(Map<String, String> target, String key, String value) {
target.put(key, value == null ? "" : value);
}
}

View File

@@ -0,0 +1,105 @@
package com.loremind.application.generationcontext;
import com.loremind.domain.campaigncontext.Campaign;
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.generationcontext.CampaignStructuralContext;
import com.loremind.domain.generationcontext.ChatMessage;
import com.loremind.domain.generationcontext.ChatRequest;
import com.loremind.domain.generationcontext.LoreStructuralContext;
import com.loremind.domain.generationcontext.NarrativeEntityContext;
import com.loremind.domain.generationcontext.ports.AiChatProvider;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.function.Consumer;
/**
* Use case applicatif : chat conversationnel pour une Campagne avec Structural Context.
*
* Orchestre :
* 1. Chargement de la carte narrative de la Campagne (arcs → chapitres → scènes).
* 2. Si la Campagne est liée à un Lore (`loreId`), chargement également de
* la carte du Lore associé (asymétrie métier : Campagne voit son Lore).
* 3. Si une entité narrative précise est ciblée (arc/chapter/scene en cours
* d'édition), focalisation via `NarrativeEntityContext`.
* 4. Délégation au port `AiChatProvider` pour le streaming token par token.
*
* Zéro persistance : la conversation est éphémère (responsabilité du frontend).
*/
@Service
public class StreamChatForCampaignUseCase {
private final CampaignRepository campaignRepository;
private final CampaignStructuralContextBuilder campaignContextBuilder;
private final LoreStructuralContextBuilder loreContextBuilder;
private final NarrativeEntityContextBuilder narrativeEntityContextBuilder;
private final AiChatProvider aiChatProvider;
public StreamChatForCampaignUseCase(
CampaignRepository campaignRepository,
CampaignStructuralContextBuilder campaignContextBuilder,
LoreStructuralContextBuilder loreContextBuilder,
NarrativeEntityContextBuilder narrativeEntityContextBuilder,
AiChatProvider aiChatProvider) {
this.campaignRepository = campaignRepository;
this.campaignContextBuilder = campaignContextBuilder;
this.loreContextBuilder = loreContextBuilder;
this.narrativeEntityContextBuilder = narrativeEntityContextBuilder;
this.aiChatProvider = aiChatProvider;
}
/**
* Streame la réponse du LLM pour la Campagne donnée.
*
* Méthode bloquante : retourne une fois le stream terminé (onComplete ou onError).
* L'appelant (controller SSE) doit l'exécuter dans un thread dédié.
*
* @param campaignId obligatoire — la campagne concernée
* @param entityType optionnel ("arc"|"chapter"|"scene") — si fourni avec entityId,
* focalise l'IA sur l'entité narrative en cours d'édition.
* @param entityId optionnel — ID de l'entité si `entityType` est fourni
* @throws IllegalArgumentException si la Campagne (ou l'entité ciblée) est introuvable
*/
public void execute(
String campaignId,
String entityType,
String entityId,
List<ChatMessage> messages,
Consumer<String> onToken,
Runnable onComplete,
Consumer<Throwable> onError) {
Campaign campaign = campaignRepository.findById(campaignId)
.orElseThrow(() -> new IllegalArgumentException(
"Campagne non trouvée avec l'ID: " + campaignId));
CampaignStructuralContext campaignContext = campaignContextBuilder.build(campaignId);
LoreStructuralContext loreContext = loadLinkedLoreContextOrNull(campaign);
NarrativeEntityContext narrativeEntity = buildNarrativeEntityOrNull(entityType, entityId);
ChatRequest request = ChatRequest.builder()
.messages(messages)
.loreContext(loreContext)
.campaignContext(campaignContext)
.narrativeEntity(narrativeEntity)
.build();
aiChatProvider.streamChat(request, onToken, onComplete, onError);
}
/**
* Charge le LoreStructuralContext si la campagne est liée ET que le Lore
* existe encore (cas dégradé : loreId pointant sur un Lore supprimé →
* on continue sans contexte Lore plutôt que d'échouer).
*/
private LoreStructuralContext loadLinkedLoreContextOrNull(Campaign campaign) {
if (!campaign.isLinkedToLore()) return null;
return loreContextBuilder.buildOptional(campaign.getLoreId()).orElse(null);
}
private NarrativeEntityContext buildNarrativeEntityOrNull(String entityType, String entityId) {
if (entityType == null || entityType.isBlank()) return null;
if (entityId == null || entityId.isBlank()) return null;
return narrativeEntityContextBuilder.build(entityType, entityId);
}
}

View File

@@ -3,52 +3,43 @@ package com.loremind.application.generationcontext;
import com.loremind.domain.generationcontext.ChatMessage;
import com.loremind.domain.generationcontext.ChatRequest;
import com.loremind.domain.generationcontext.LoreStructuralContext;
import com.loremind.domain.generationcontext.LoreStructuralContext.FolderPage;
import com.loremind.domain.generationcontext.PageContext;
import com.loremind.domain.generationcontext.ports.AiChatProvider;
import com.loremind.domain.lorecontext.Lore;
import com.loremind.domain.lorecontext.LoreNode;
import com.loremind.domain.lorecontext.Page;
import com.loremind.domain.lorecontext.Template;
import com.loremind.domain.lorecontext.ports.LoreNodeRepository;
import com.loremind.domain.lorecontext.ports.LoreRepository;
import com.loremind.domain.lorecontext.ports.PageRepository;
import com.loremind.domain.lorecontext.ports.TemplateRepository;
import org.springframework.stereotype.Service;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Consumer;
import java.util.stream.Collectors;
/**
* Use case applicatif : chat conversationnel avec Structural Context d'un Lore.
*
* Orchestrateur — charge la carte structurelle (dossiers + pages + templates
* + tags) depuis le LoreContext, la traduit vers le GenerationContext, puis
* délègue au port AiChatProvider pour le streaming.
* Orchestrateur fin — délègue la construction du LoreStructuralContext au
* {@link LoreStructuralContextBuilder} (service partagé avec
* {@link StreamChatForCampaignUseCase}), charge le PageContext si demandé,
* puis délègue au port AiChatProvider pour le streaming.
*
* Zéro persistance : la conversation est éphémère (responsabilité du frontend).
*/
@Service
public class StreamChatForLoreUseCase {
private final LoreRepository loreRepository;
private final LoreNodeRepository loreNodeRepository;
private final LoreStructuralContextBuilder loreContextBuilder;
private final PageRepository pageRepository;
private final TemplateRepository templateRepository;
private final AiChatProvider aiChatProvider;
public StreamChatForLoreUseCase(
LoreRepository loreRepository,
LoreNodeRepository loreNodeRepository,
LoreStructuralContextBuilder loreContextBuilder,
PageRepository pageRepository,
TemplateRepository templateRepository,
AiChatProvider aiChatProvider) {
this.loreRepository = loreRepository;
this.loreNodeRepository = loreNodeRepository;
this.loreContextBuilder = loreContextBuilder;
this.pageRepository = pageRepository;
this.templateRepository = templateRepository;
this.aiChatProvider = aiChatProvider;
@@ -73,7 +64,7 @@ public class StreamChatForLoreUseCase {
Runnable onComplete,
Consumer<Throwable> onError) {
LoreStructuralContext loreContext = buildLoreContext(loreId);
LoreStructuralContext loreContext = loreContextBuilder.build(loreId);
PageContext pageContext = (pageId == null || pageId.isBlank())
? null
: buildPageContext(pageId);
@@ -87,8 +78,6 @@ public class StreamChatForLoreUseCase {
aiChatProvider.streamChat(request, onToken, onComplete, onError);
}
// --- Construction du contexte d'une page précise ------------------------
/**
* Charge la Page + son Template et construit un PageContext prêt à injecter.
* Si le template est absent (page orpheline), on renvoie un PageContext
@@ -106,9 +95,9 @@ public class StreamChatForLoreUseCase {
Template template = templateRepository.findById(page.getTemplateId()).orElse(null);
if (template != null) {
templateName = template.getName();
templateFields = template.getFields() != null
? template.getFields()
: Collections.emptyList();
// On expose uniquement les noms des champs TEXT a l'IA pour le chat.
// Les champs IMAGE ne sont pas pertinents pour une generation textuelle.
templateFields = template.textFieldNames();
}
}
@@ -123,59 +112,4 @@ public class StreamChatForLoreUseCase {
.values(values)
.build();
}
// --- Construction de la carte structurelle ------------------------------
private LoreStructuralContext buildLoreContext(String loreId) {
Lore lore = loreRepository.findById(loreId)
.orElseThrow(() -> new IllegalArgumentException(
"Lore non trouvé avec l'ID: " + loreId));
List<LoreNode> nodes = loreNodeRepository.findByLoreId(loreId);
List<Page> pages = pageRepository.findByLoreId(loreId);
List<Template> templates = templateRepository.findByLoreId(loreId);
Map<String, List<FolderPage>> folders = buildFoldersMap(nodes, pages, templates);
List<String> tags = extractUniqueTags(pages);
return LoreStructuralContext.builder()
.loreName(lore.getName())
.loreDescription(lore.getDescription())
.folders(folders)
.tags(tags)
.build();
}
private Map<String, List<FolderPage>> buildFoldersMap(
List<LoreNode> nodes, List<Page> pages, List<Template> templates) {
Map<String, String> templateNameById = templates.stream()
.collect(Collectors.toMap(Template::getId, Template::getName, (a, b) -> a));
// LinkedHashMap : préserve l'ordre d'insertion pour un prompt lisible.
Map<String, List<FolderPage>> folders = new LinkedHashMap<>();
for (LoreNode node : nodes) {
folders.put(node.getName(), pagesInFolder(node.getId(), pages, templateNameById));
}
return folders;
}
private List<FolderPage> pagesInFolder(
String nodeId, List<Page> allPages, Map<String, String> templateNameById) {
return allPages.stream()
.filter(p -> nodeId.equals(p.getNodeId()))
.map(p -> FolderPage.builder()
.title(p.getTitle())
.templateName(templateNameById.getOrDefault(p.getTemplateId(), "?"))
.build())
.collect(Collectors.toList());
}
private List<String> extractUniqueTags(List<Page> pages) {
return pages.stream()
.filter(p -> p.getTags() != null)
.flatMap(p -> p.getTags().stream())
.distinct()
.collect(Collectors.toList());
}
}

View File

@@ -0,0 +1,112 @@
package com.loremind.application.images;
import com.loremind.domain.images.Image;
import com.loremind.domain.images.ports.ImageRepository;
import com.loremind.domain.images.ports.ImageStorage;
import org.springframework.stereotype.Service;
import java.io.InputStream;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
import java.util.Set;
/**
* Service d'application pour le Shared Kernel images.
*
* Orchestre l'upload / download / delete en combinant les deux ports du
* domaine : ImageStorage (binaire) et ImageRepository (metadonnees).
*
* Couche Application de l'Architecture Hexagonale : pas de JPA, pas de HTTP,
* pas de MinIO ici. Juste de la logique metier pure.
*/
@Service
public class ImageService {
/** MIME types autorises a l'upload. Evite les fichiers piegeux deguises en image. */
private static final Set<String> ALLOWED_MIME_TYPES = Set.of(
"image/jpeg",
"image/png",
"image/webp",
"image/gif"
);
/** Taille max coherente avec la config Spring (application.properties). */
private static final long MAX_SIZE_BYTES = 10L * 1024 * 1024; // 10 Mo
private final ImageRepository imageRepository;
private final ImageStorage imageStorage;
public ImageService(ImageRepository imageRepository, ImageStorage imageStorage) {
this.imageRepository = imageRepository;
this.imageStorage = imageStorage;
}
/**
* Use case upload : valide -> envoie le binaire -> persiste les metadonnees.
*
* En cas d'echec de persistance DB apres un upload MinIO reussi, on tente
* une compensation (suppression du binaire orphelin) pour eviter de
* laisser trainer un fichier sans reference.
*/
public Image upload(String filename, String contentType, InputStream data, long sizeBytes) {
validateUpload(filename, contentType, sizeBytes);
String storageKey = imageStorage.upload(filename, contentType, data, sizeBytes);
try {
Image image = Image.builder()
.filename(filename)
.contentType(contentType)
.sizeBytes(sizeBytes)
.storageKey(storageKey)
.uploadedAt(LocalDateTime.now())
.build();
return imageRepository.save(image);
} catch (RuntimeException ex) {
// Compensation : on evite le binaire orphelin en MinIO si la DB a plante.
imageStorage.delete(storageKey);
throw ex;
}
}
public Optional<Image> getById(String id) {
return imageRepository.findById(id);
}
/**
* Recupere le flux binaire d'une image via son ID metier.
* Utilise par le controller pour servir GET /api/images/:id.
*/
public Optional<InputStream> downloadById(String id) {
return imageRepository.findById(id)
.map(img -> imageStorage.download(img.getStorageKey()));
}
/** Suppression symetrique : binaire d'abord, metadonnees ensuite. */
public void deleteById(String id) {
imageRepository.findById(id).ifPresent(img -> {
imageStorage.delete(img.getStorageKey());
imageRepository.deleteById(id);
});
}
// --- Validation --------------------------------------------------------
private void validateUpload(String filename, String contentType, long sizeBytes) {
if (filename == null || filename.isBlank()) {
throw new IllegalArgumentException("Le nom du fichier est requis.");
}
if (contentType == null || !ALLOWED_MIME_TYPES.contains(contentType.toLowerCase())) {
throw new IllegalArgumentException(
"Type de fichier non supporte. Types acceptes : " + List.copyOf(ALLOWED_MIME_TYPES));
}
if (sizeBytes <= 0) {
throw new IllegalArgumentException("Le fichier est vide.");
}
if (sizeBytes > MAX_SIZE_BYTES) {
throw new IllegalArgumentException(
"Fichier trop volumineux (max " + (MAX_SIZE_BYTES / 1024 / 1024) + " Mo).");
}
}
}

View File

@@ -75,6 +75,9 @@ public class PageService {
existing.setValues(changes.getValues() != null
? new HashMap<>(changes.getValues())
: new HashMap<>());
existing.setImageValues(changes.getImageValues() != null
? new HashMap<>(changes.getImageValues())
: new HashMap<>());
existing.setNotes(changes.getNotes());
existing.setTags(changes.getTags() != null
? new ArrayList<>(changes.getTags())

View File

@@ -1,6 +1,7 @@
package com.loremind.application.lorecontext;
import com.loremind.domain.lorecontext.Template;
import com.loremind.domain.lorecontext.TemplateField;
import com.loremind.domain.lorecontext.ports.TemplateRepository;
import org.springframework.stereotype.Service;
@@ -26,7 +27,7 @@ public class TemplateService {
String name,
String description,
String defaultNodeId,
List<String> fields) {
List<TemplateField> fields) {
Template template = Template.builder()
.loreId(loreId)
.name(name)
@@ -68,8 +69,8 @@ public class TemplateService {
existing.setDescription(changes.getDescription());
existing.setDefaultNodeId(changes.getDefaultNodeId());
existing.setFields(changes.getFields() != null
? new ArrayList<>(changes.getFields())
: new ArrayList<>());
? new ArrayList<TemplateField>(changes.getFields())
: new ArrayList<TemplateField>());
// loreId volontairement immuable : un template ne migre pas d'un Lore à l'autre.
return templateRepository.save(existing);
}

View File

@@ -37,6 +37,13 @@ public class Arc {
@Builder.Default
private List<String> relatedPageIds = new ArrayList<>();
/**
* IDs des images (Shared Kernel) servant d'illustrations a cet arc.
* Galerie ordonnee : la 1ere image est l'illustration principale.
*/
@Builder.Default
private List<String> illustrationImageIds = new ArrayList<>();
private LocalDateTime createdAt;
private LocalDateTime updatedAt;

View File

@@ -33,6 +33,12 @@ public class Chapter {
@Builder.Default
private List<String> relatedPageIds = new ArrayList<>();
/**
* IDs des images (Shared Kernel) illustrant ce chapitre.
*/
@Builder.Default
private List<String> illustrationImageIds = new ArrayList<>();
private LocalDateTime createdAt;
private LocalDateTime updatedAt;

View File

@@ -46,6 +46,13 @@ public class Scene {
@Builder.Default
private List<String> relatedPageIds = new ArrayList<>();
/**
* IDs des images (Shared Kernel) illustrant cette scene.
* Utile pour carte du lieu, portraits des PNJ principaux, ambiance.
*/
@Builder.Default
private List<String> illustrationImageIds = new ArrayList<>();
private LocalDateTime createdAt;
private LocalDateTime updatedAt;

View File

@@ -0,0 +1,63 @@
package com.loremind.domain.generationcontext;
import lombok.Builder;
import lombok.Singular;
import lombok.Value;
import java.util.List;
/**
* Carte narrative enrichie d'une Campagne pour nourrir l'IA.
*
* Ceci est un Value Object du Generation Context (Bounded Context IA).
* Jumeau de LoreStructuralContext côté Campaign : on décrit l'arbre
* arcs → chapitres → scènes avec le NOM + une DESCRIPTION courte à chaque
* niveau. Les champs longs (notes MJ, narration joueur, combat) restent
* exclus : l'IA les obtient uniquement via {@link NarrativeEntityContext}
* pour l'entité focus.
*
* Objectif : permettre à l'IA de répondre "c'est quoi la scène X ?" même
* quand X n'est pas l'entité en cours d'édition, sans exploser le prompt.
* Budget typique : ~30 tokens/scène × 100 scènes = 3k tokens (confortable).
*
* La liste `arcs` préserve l'ordre narratif (tri sur `order` ascendant
* fait par le use case côté application layer).
*/
@Value
@Builder
public class CampaignStructuralContext {
String campaignName;
String campaignDescription;
@Singular List<ArcSummary> arcs;
/** Résumé d'un arc : nom + description courte + ses chapitres. */
@Value
@Builder
public static class ArcSummary {
String name;
String description;
/** Nombre d'illustrations attachees a cet arc (pour hint dans le prompt IA). */
int illustrationCount;
@Singular List<ChapterSummary> chapters;
}
/** Résumé d'un chapitre : nom + description courte + ses scènes. */
@Value
@Builder
public static class ChapterSummary {
String name;
String description;
int illustrationCount;
@Singular List<SceneSummary> scenes;
}
/** Résumé d'une scène : nom + description courte. */
@Value
@Builder
public static class SceneSummary {
String name;
String description;
int illustrationCount;
}
}

View File

@@ -8,15 +8,35 @@ import java.util.List;
/**
* Object de valeur encapsulant une requête de chat streamé.
*
* Regroupe l'historique de la conversation et le contexte structurel du
* Lore — les deux informations dont l'IA a besoin pour répondre.
* Ceci est un Value Object du Generation Context.
* Regroupe l'historique de la conversation et les contextes structurels
* (Lore et/ou Campagne) dont l'IA a besoin pour répondre.
*
* Combinaisons supportées (asymétrie demandée par le métier) :
* - loreContext seul → chat Lore (page-edit / page-create)
* - loreContext + pageContext → chat Lore focalisé sur une page
* - campaignContext (+ loreContext si liée) → chat Campagne, voit son Lore associé
* - campaignContext + narrativeEntity → chat Campagne focalisé sur arc/chapter/scene
*
* Un chat Lore ne reçoit JAMAIS de campaignContext : un Lore ne voit pas
* ses campagnes (asymétrie métier : la campagne est l'emprunteur du Lore,
* pas l'inverse).
*/
@Value
@Builder
public class ChatRequest {
List<ChatMessage> messages;
/** Optionnel : carte structurelle du Lore. Null si campagne non liée à un Lore. */
LoreStructuralContext loreContext;
/** Optionnel : contexte d'une page précise en cours d'édition. Null = chat générique au Lore. */
/** Optionnel : contexte d'une page précise en cours d'édition (chat Lore uniquement). */
PageContext pageContext;
/** Optionnel : carte narrative d'une Campagne (chat Campagne uniquement). */
CampaignStructuralContext campaignContext;
/** Optionnel : entité narrative en cours d'édition (arc/chapter/scene). */
NarrativeEntityContext narrativeEntity;
}

View File

@@ -8,11 +8,11 @@ import java.util.List;
import java.util.Map;
/**
* Carte structurelle d'un Lore pour nourrir l'IA sans tout lui envoyer.
* Carte structurelle enrichie d'un Lore pour nourrir l'IA.
*
* Équivalent Java du LoreStructuralContext Python. Pas de contenu des pages,
* uniquement la structure (dossiers, titres, templates, tags). Suffit pour
* que l'IA propose des suggestions cohérentes avec l'existant.
* Équivalent Java du LoreStructuralContext Python. Depuis l'étape b9,
* chaque page expose ses valeurs de champs, ses tags et ses pages liées
* (résolues en titres) — plus uniquement son nom et son template.
*
* La map `folders` est indexée par nom de dossier et mappe vers la liste
* des pages qu'il contient (liste vide autorisée pour les dossiers vides).
@@ -23,17 +23,30 @@ public class LoreStructuralContext {
String loreName;
String loreDescription;
Map<String, List<FolderPage>> folders;
Map<String, List<PageSummary>> folders;
@Singular List<String> tags;
/**
* Résumé minimaliste d'une page : juste son titre et son template.
* Pas de valeurs, pas de notes, pas de tags (pour garder le prompt léger).
* Résumé projeté d'une page pour l'IA.
*
* Contient le contenu utile au raisonnement LLM :
* - title + templateName : identification
* - values : contenu des champs dynamiques (tronqué côté builder)
* - tags : étiquettes métier
* - relatedPageTitles : pages liées DÉJÀ résolues en titres lisibles
* (les IDs techniques n'ont aucune utilité dans un prompt LLM).
*
* Les notes privées du MJ ne figurent PAS ici (choix b9 : exposer
* uniquement ce qui est partageable en narration — les secrets MJ
* restent confinés à leur page d'édition).
*/
@Value
@Builder
public static class FolderPage {
public static class PageSummary {
String title;
String templateName;
Map<String, String> values;
List<String> tags;
List<String> relatedPageTitles;
}
}

View File

@@ -0,0 +1,29 @@
package com.loremind.domain.generationcontext;
import lombok.Builder;
import lombok.Value;
import java.util.Map;
/**
* Contexte d'une entité narrative précise en cours d'édition (Arc, Chapter, ou Scene).
*
* Ceci est un Value Object du Generation Context.
* Équivalent de PageContext côté Lore mais appliqué à la Campagne : injecté
* dans le system prompt pour orienter l'IA vers CETTE entité précise plutôt
* que vers l'arbre narratif global. Modèle uniforme pour les 3 types :
* un discriminator `entityType` + un titre + une map de champs textuels.
*
* `fields` associe le nom d'un champ (ex: "themes", "playerNarration")
* à sa valeur actuelle (chaîne vide si non renseigné). Utiliser une
* LinkedHashMap à la construction pour un prompt lisible (ordre préservé).
*/
@Value
@Builder
public class NarrativeEntityContext {
/** "arc", "chapter" ou "scene" — utilisé pour libeller le bloc du prompt. */
String entityType;
String title;
Map<String, String> fields;
}

View File

@@ -0,0 +1,56 @@
package com.loremind.domain.images;
import lombok.Builder;
import lombok.Data;
import java.time.LocalDateTime;
/**
* Entite de domaine representant une image uploadee par l'utilisateur.
*
* Shared Kernel : cette entite vit dans un package transverse (ni LoreContext
* ni CampaignContext) car une image peut etre referencee par n'importe quelle
* entite de ces deux contextes (Page, Scene, Chapter, Arc). Elle n'appartient
* a aucun context en particulier.
*
* Design :
* - Metadata en DB relationnelle (Postgres)
* - Binaire sur object storage (MinIO/S3) referencE par `storageKey`
* - Le domaine ne connait pas MinIO : il manipule juste une cle opaque.
*
* Architecture Hexagonale : entite pure, aucune dependance technique.
*/
@Data
@Builder
public class Image {
/** Identifiant stable (String pour rester agnostique vis-a-vis du stockage). */
private String id;
/** Nom original du fichier uploade (ex: "portrait-elfe.jpg"). */
private String filename;
/** Type MIME valide (ex: "image/jpeg", "image/png", "image/webp"). */
private String contentType;
/** Taille en octets, utile pour quotas et affichage UI. */
private long sizeBytes;
/**
* Cle opaque dans l'object storage (ex: "images/abc123.jpg").
* Le domaine ne fait qu'acheminer cette cle ; seul l'adaptateur MinIO sait
* comment la transformer en bucket + path pour recuperer le binaire.
*/
private String storageKey;
/** Horodatage de l'upload initial (l'image est immuable apres creation). */
private LocalDateTime uploadedAt;
// --- Methodes metier ---------------------------------------------------
/** Une image est "sereement valide" si elle pointe bien vers un binaire. */
public boolean isValid() {
return storageKey != null && !storageKey.isBlank()
&& contentType != null && contentType.startsWith("image/");
}
}

View File

@@ -0,0 +1,26 @@
package com.loremind.domain.images.ports;
import com.loremind.domain.images.Image;
import java.util.Optional;
/**
* Port de sortie pour la persistance des metadonnees d'images.
*
* Architecture Hexagonale : ce port est defini dans le domaine ; il est
* implemente par un adaptateur d'infrastructure (PostgresImageRepository).
*
* Ne manipule QUE les metadonnees (filename, mimeType, storageKey...).
* Le binaire est gere par un autre port : ImageStorage.
* Cette separation suit le Single Responsibility Principle (SRP).
*/
public interface ImageRepository {
Image save(Image image);
Optional<Image> findById(String id);
void deleteById(String id);
boolean existsById(String id);
}

View File

@@ -0,0 +1,34 @@
package com.loremind.domain.images.ports;
import java.io.InputStream;
/**
* Port de sortie pour le stockage du BINAIRE des images.
*
* Separe de ImageRepository (metadonnees) pour respecter le SRP :
* - ImageRepository --> Postgres (metadonnees)
* - ImageStorage --> MinIO/S3 (fichiers binaires)
*
* Le domaine raisonne en termes de "cle opaque" (storageKey).
* Chaque implementation (MinIO, filesystem, S3...) traduit cette cle selon
* sa propre logique physique.
*/
public interface ImageStorage {
/**
* Envoie un flux binaire et retourne la cle generee.
*
* @param filename nom d'origine (utilise pour extraire l'extension)
* @param contentType MIME type valide
* @param data flux binaire a stocker
* @param sizeBytes taille en octets (requis par certains backends comme S3)
* @return cle opaque utilisable ensuite pour retrouver le binaire
*/
String upload(String filename, String contentType, InputStream data, long sizeBytes);
/** Recupere le flux binaire associe a une cle, ou null si inexistante. */
InputStream download(String storageKey);
/** Supprime le binaire. No-op silencieux si la cle n'existe pas. */
void delete(String storageKey);
}

View File

@@ -0,0 +1,15 @@
package com.loremind.domain.lorecontext;
/**
* Type d'un champ dynamique d'un Template.
*
* - TEXT : valeur textuelle libre (stockee dans Page.values : Map<String, String>)
* - IMAGE : galerie d'images, represente comme une liste d'IDs d'images
* (stockee dans Page.imageValues : Map<String, List<String>>)
*
* Extension future possible : RICH_TEXT, NUMBER, DATE, BOOLEAN, LORE_LINK...
*/
public enum FieldType {
TEXT,
IMAGE
}

View File

@@ -31,9 +31,16 @@ public class Page {
private String templateId;
private String title;
/** Valeurs des champs dynamiques définis par le Template. */
/** Valeurs des champs dynamiques TEXT définis par le Template. */
private Map<String, String> values;
/**
* Valeurs des champs dynamiques IMAGE : pour chaque nom de champ IMAGE du
* template, la liste ordonnee des IDs d'images uploadees (Shared Kernel images).
* Structure separee de `values` pour garder des types homogenes par map.
*/
private Map<String, List<String>> imageValues;
/** Notes privées du MJ (non exportées vers FoundryVTT). */
private String notes;
@@ -61,6 +68,22 @@ public class Page {
return values == null ? null : values.get(fieldName);
}
/** Remplace la liste d'IDs d'images pour un champ IMAGE donne. */
public void setImageFieldValue(String fieldName, List<String> imageIds) {
if (imageValues == null) {
imageValues = new HashMap<>();
}
imageValues.put(fieldName, imageIds != null ? new ArrayList<>(imageIds) : new ArrayList<>());
this.updatedAt = LocalDateTime.now();
}
/** Liste d'IDs d'images pour un champ IMAGE (ou liste vide si absent). */
public List<String> getImageFieldValue(String fieldName) {
if (imageValues == null) return new ArrayList<>();
List<String> ids = imageValues.get(fieldName);
return ids != null ? ids : new ArrayList<>();
}
public void addTag(String tag) {
if (tag == null || tag.isBlank()) return;
if (tags == null) tags = new ArrayList<>();

View File

@@ -13,9 +13,13 @@ import java.util.List;
* Un Template :
* - appartient à un Lore (loreId)
* - définit le noeud par défaut où seront rangées les Pages créées (defaultNodeId)
* - porte une liste ordonnée de noms de champs dynamiques (fields)
* - porte une liste ordonnée de {@link TemplateField} (nom + type TEXT/IMAGE)
* qui seront instanciés sur chaque Page produite depuis ce gabarit.
*
* Evolution : les `fields` etaient autrefois de simples `List<String>` (noms seuls).
* Depuis l'ajout du support des images, chaque champ a un type discriminant pour
* piloter le rendu UI et la logique IA.
*
* Entité pure du domaine : aucune dépendance technique (Spring, JPA, etc.).
*/
@Data
@@ -23,11 +27,11 @@ import java.util.List;
public class Template {
private String id;
private String loreId; // Rattachement au Lore propriétaire
private String loreId; // Rattachement au Lore propriétaire
private String name;
private String description;
private String defaultNodeId; // Noeud cible des Pages générées
private List<String> fields; // Noms des champs dynamiques (ordonnés)
private String defaultNodeId; // Noeud cible des Pages générées
private List<TemplateField> fields; // Champs dynamiques ordonnes (nom + type)
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
@@ -38,23 +42,39 @@ public class Template {
return fields == null ? 0 : fields.size();
}
/** Ajoute un champ à la fin de la liste (ignore les doublons et les blancs). */
public void addField(String fieldName) {
if (fieldName == null || fieldName.isBlank()) {
/**
* Retourne uniquement les noms des champs de type TEXT.
* Utilise par l'IA : seul le texte peut etre genere, pas les images.
*/
public List<String> textFieldNames() {
if (fields == null) return new ArrayList<>();
return fields.stream()
.filter(f -> f.getType() == FieldType.TEXT)
.map(TemplateField::getName)
.toList();
}
/** Ajoute un champ a la fin de la liste (ignore les doublons par nom et les blancs). */
public void addField(TemplateField field) {
if (field == null || field.getName() == null || field.getName().isBlank()) {
return;
}
if (fields == null) {
fields = new ArrayList<>();
}
if (!fields.contains(fieldName)) {
fields.add(fieldName);
boolean alreadyPresent = fields.stream()
.anyMatch(f -> f.getName().equals(field.getName()));
if (!alreadyPresent) {
fields.add(field);
this.updatedAt = LocalDateTime.now();
}
}
/** Retire un champ s'il existe. */
/** Retire le champ dont le nom correspond (premiere occurrence). */
public void removeField(String fieldName) {
if (fields != null && fields.remove(fieldName)) {
if (fields == null || fieldName == null) return;
boolean removed = fields.removeIf(f -> fieldName.equals(f.getName()));
if (removed) {
this.updatedAt = LocalDateTime.now();
}
}

View File

@@ -0,0 +1,39 @@
package com.loremind.domain.lorecontext;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* Value Object d'un champ de Template.
*
* Un champ a un nom (affiche dans l'UI) et un type (TEXT ou IMAGE, extensible).
* Le type pilote le rendu cote front (textarea vs galerie d'images) ET
* la logique metier (seuls les champs TEXT sont envoyes a l'IA pour generation).
*
* Evolution de `List<String> fields` vers `List<TemplateField> fields` :
* refactor propre (DDD Value Object polymorphism) permettant d'ajouter
* facilement d'autres types de champs (DATE, NUMBER, RICH_TEXT...) sans
* casser le contrat.
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class TemplateField {
/** Nom du champ tel qu'affiche dans l'UI (ex: "Histoire", "Portrait"). */
private String name;
/** Type du champ, pilote le rendu et la generation IA. */
private FieldType type;
/** Raccourci : construit un champ de type TEXT (cas le plus courant). */
public static TemplateField text(String name) {
return new TemplateField(name, FieldType.TEXT);
}
/** Raccourci : construit un champ de type IMAGE. */
public static TemplateField image(String name) {
return new TemplateField(name, FieldType.IMAGE);
}
}

View File

@@ -1,9 +1,14 @@
package com.loremind.infrastructure.ai;
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.SceneSummary;
import com.loremind.domain.generationcontext.ChatMessage;
import com.loremind.domain.generationcontext.ChatRequest;
import com.loremind.domain.generationcontext.LoreStructuralContext;
import com.loremind.domain.generationcontext.LoreStructuralContext.FolderPage;
import com.loremind.domain.generationcontext.LoreStructuralContext.PageSummary;
import com.loremind.domain.generationcontext.NarrativeEntityContext;
import com.loremind.domain.generationcontext.PageContext;
import com.loremind.domain.generationcontext.ports.AiChatProvider;
import com.loremind.domain.generationcontext.ports.AiProviderException;
@@ -23,11 +28,15 @@ import java.util.function.Consumer;
import java.util.stream.Collectors;
/**
* Adapter de sortie : implémente AiChatProvider en appelant
* le Brain Python via WebClient + SSE (Server-Sent Events).
* Adapter de sortie (Architecture Hexagonale) : implémente AiChatProvider
* en appelant le Brain Python via WebClient + SSE (Server-Sent Events).
*
* Responsabilités :
* 1. Traduire ChatRequest (domaine) -> JSON attendu par /chat/stream.
* Sérialise lore_context, page_context, campaign_context et
* narrative_entity de façon conditionnelle selon le scénario d'appel
* (chat Lore / chat Lore focalisé page / chat Campagne / chat Campagne
* focalisé arc-chapter-scene).
* 2. Consommer le flux SSE token par token.
* 3. Invoquer onToken / onComplete / onError au bon moment.
* 4. Traduire toute erreur technique en AiProviderException.
@@ -123,29 +132,32 @@ public class BrainAiChatClient implements AiChatProvider {
// --- Construction du payload JSON vers le Brain -------------------------
/**
* Construit le payload JSON. Chaque contexte optionnel est omis s'il est
* null, pour s'aligner sur le schéma Pydantic côté Brain (champs
* Optional qui restent absents du dict transmis au LLM).
*/
private Map<String, Object> toPayload(ChatRequest request) {
Map<String, Object> root = new LinkedHashMap<>();
root.put("messages", request.getMessages().stream()
.map(this::messageToMap)
.collect(Collectors.toList()));
root.put("lore_context", loreContextToMap(request.getLoreContext()));
// page_context est optionnel côté Brain (Pydantic l'accepte null).
// On ne l'ajoute au payload que s'il est effectivement fourni.
if (request.getLoreContext() != null) {
root.put("lore_context", loreContextToMap(request.getLoreContext()));
}
if (request.getPageContext() != null) {
root.put("page_context", pageContextToMap(request.getPageContext()));
}
if (request.getCampaignContext() != null) {
root.put("campaign_context", campaignContextToMap(request.getCampaignContext()));
}
if (request.getNarrativeEntity() != null) {
root.put("narrative_entity", narrativeEntityToMap(request.getNarrativeEntity()));
}
return root;
}
private Map<String, Object> pageContextToMap(PageContext pc) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("title", pc.getTitle());
map.put("template_name", pc.getTemplateName());
map.put("template_fields", pc.getTemplateFields());
map.put("values", pc.getValues());
return map;
}
private Map<String, Object> messageToMap(ChatMessage m) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("role", m.getRole());
@@ -159,9 +171,9 @@ public class BrainAiChatClient implements AiChatProvider {
map.put("lore_description", ctx.getLoreDescription());
Map<String, Object> foldersMap = new LinkedHashMap<>();
for (Map.Entry<String, List<FolderPage>> e : ctx.getFolders().entrySet()) {
for (Map.Entry<String, List<PageSummary>> e : ctx.getFolders().entrySet()) {
foldersMap.put(e.getKey(), e.getValue().stream()
.map(this::folderPageToMap)
.map(this::pageSummaryToMap)
.collect(Collectors.toList()));
}
map.put("folders", foldersMap);
@@ -169,10 +181,86 @@ public class BrainAiChatClient implements AiChatProvider {
return map;
}
private Map<String, Object> folderPageToMap(FolderPage fp) {
private Map<String, Object> pageSummaryToMap(PageSummary ps) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("title", fp.getTitle());
map.put("template_name", fp.getTemplateName());
map.put("title", ps.getTitle());
map.put("template_name", ps.getTemplateName());
// values/tags/related_page_titles ne sont sérialisés que s'ils contiennent
// de l'info — payload réseau plus léger quand la page est vierge.
if (ps.getValues() != null && !ps.getValues().isEmpty()) {
map.put("values", ps.getValues());
}
if (ps.getTags() != null && !ps.getTags().isEmpty()) {
map.put("tags", ps.getTags());
}
if (ps.getRelatedPageTitles() != null && !ps.getRelatedPageTitles().isEmpty()) {
map.put("related_page_titles", ps.getRelatedPageTitles());
}
return map;
}
private Map<String, Object> pageContextToMap(PageContext pc) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("title", pc.getTitle());
map.put("template_name", pc.getTemplateName());
map.put("template_fields", pc.getTemplateFields());
map.put("values", pc.getValues());
return map;
}
private Map<String, Object> campaignContextToMap(CampaignStructuralContext ctx) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("campaign_name", ctx.getCampaignName());
map.put("campaign_description", ctx.getCampaignDescription());
map.put("arcs", ctx.getArcs().stream()
.map(this::arcSummaryToMap)
.collect(Collectors.toList()));
return map;
}
private Map<String, Object> arcSummaryToMap(ArcSummary a) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("name", a.getName());
map.put("description", a.getDescription());
// Envoye au Python pour enrichir le prompt ("N illustrations attachees").
// Serialise uniquement si > 0 pour economiser le payload sur les entites sans images.
if (a.getIllustrationCount() > 0) {
map.put("illustration_count", a.getIllustrationCount());
}
map.put("chapters", a.getChapters().stream()
.map(this::chapterSummaryToMap)
.collect(Collectors.toList()));
return map;
}
private Map<String, Object> chapterSummaryToMap(ChapterSummary c) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("name", c.getName());
map.put("description", c.getDescription());
if (c.getIllustrationCount() > 0) {
map.put("illustration_count", c.getIllustrationCount());
}
map.put("scenes", c.getScenes().stream()
.map(this::sceneSummaryToMap)
.collect(Collectors.toList()));
return map;
}
private Map<String, Object> sceneSummaryToMap(SceneSummary s) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("name", s.getName());
map.put("description", s.getDescription());
if (s.getIllustrationCount() > 0) {
map.put("illustration_count", s.getIllustrationCount());
}
return map;
}
private Map<String, Object> narrativeEntityToMap(NarrativeEntityContext ne) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("entity_type", ne.getEntityType());
map.put("title", ne.getTitle());
map.put("fields", ne.getFields());
return map;
}
}

View File

@@ -0,0 +1,54 @@
package com.loremind.infrastructure.persistence.converter;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.persistence.AttributeConverter;
import jakarta.persistence.Converter;
import java.util.Collections;
import java.util.List;
import java.util.Map;
/**
* Convertit une Map<String, List<String>> du domaine en chaine JSON et inversement.
*
* Utilise pour Page.imageValues : pour chaque champ IMAGE du template
* (ex: "Portrait"), la map stocke la liste ordonnee des IDs d'images uploadees.
*
* Exemple de JSON produit :
* {"Portrait": ["42","17"], "Carte": ["99"]}
*
* Adaptateur technique d'infrastructure : le domaine ne connait jamais ce converter.
*/
@Converter
public class StringListMapJsonConverter
implements AttributeConverter<Map<String, List<String>>, String> {
private static final ObjectMapper MAPPER = new ObjectMapper();
@Override
public String convertToDatabaseColumn(Map<String, List<String>> attribute) {
if (attribute == null || attribute.isEmpty()) {
return "{}";
}
try {
return MAPPER.writeValueAsString(attribute);
} catch (Exception e) {
throw new IllegalStateException(
"Erreur serialisation Map<String, List<String>> -> JSON", e);
}
}
@Override
public Map<String, List<String>> convertToEntityAttribute(String dbData) {
if (dbData == null || dbData.isBlank()) {
return Collections.emptyMap();
}
try {
return MAPPER.readValue(dbData, new TypeReference<Map<String, List<String>>>() {});
} catch (Exception e) {
throw new IllegalStateException(
"Erreur deserialisation JSON -> Map<String, List<String>>", e);
}
}
}

View File

@@ -0,0 +1,97 @@
package com.loremind.infrastructure.persistence.converter;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.loremind.domain.lorecontext.FieldType;
import com.loremind.domain.lorecontext.TemplateField;
import jakarta.persistence.AttributeConverter;
import jakarta.persistence.Converter;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
/**
* Convertisseur JPA pour {@code List<TemplateField>}.
*
* <h3>Backward compatibility (CRITIQUE)</h3>
* Les templates crees avant l'introduction de {@link TemplateField} sont
* persistes au format legacy : {@code ["Nom", "Histoire", "Portrait"]}.
* Les nouveaux templates utilisent le format : {@code [{"name":"Nom","type":"TEXT"}, ...]}.
*
* Ce converter sait lire les DEUX formats en lecture (tolerant) mais ecrit
* toujours au nouveau format. Cela evite une migration de donnees risquee :
* la premiere ecriture d'un template legacy suffit a le convertir.
*
* <h3>Responsabilite</h3>
* Adaptateur technique pur : le domaine ne connait jamais ce converter.
*/
@Converter
public class TemplateFieldListJsonConverter
implements AttributeConverter<List<TemplateField>, String> {
private static final ObjectMapper MAPPER = new ObjectMapper();
@Override
public String convertToDatabaseColumn(List<TemplateField> attribute) {
if (attribute == null || attribute.isEmpty()) {
return "[]";
}
try {
return MAPPER.writeValueAsString(attribute);
} catch (Exception e) {
throw new IllegalStateException(
"Erreur serialisation List<TemplateField> -> JSON", e);
}
}
@Override
public List<TemplateField> convertToEntityAttribute(String dbData) {
if (dbData == null || dbData.isBlank()) {
return Collections.emptyList();
}
try {
JsonNode root = MAPPER.readTree(dbData);
if (!root.isArray()) {
return Collections.emptyList();
}
List<TemplateField> result = new ArrayList<>();
for (JsonNode item : root) {
if (item.isTextual()) {
// Format legacy : chaine simple, on suppose TEXT par defaut.
result.add(TemplateField.text(item.asText()));
} else if (item.isObject()) {
// Nouveau format : {name, type}
String name = item.path("name").asText(null);
String typeStr = item.path("type").asText("TEXT");
FieldType type;
try {
type = FieldType.valueOf(typeStr);
} catch (IllegalArgumentException ex) {
// Type inconnu (ajoute par une version future) : fallback TEXT.
type = FieldType.TEXT;
}
if (name != null && !name.isBlank()) {
result.add(new TemplateField(name, type));
}
}
// Autres types de noeuds (nombre, booleen...) : ignores silencieusement.
}
return result;
} catch (Exception e) {
throw new IllegalStateException(
"Erreur deserialisation JSON -> List<TemplateField>", e);
}
}
/** Utilitaire de test pour verifier le parsing d'une chaine brute. */
static List<TemplateField> parseForTests(String dbData) {
return new TemplateFieldListJsonConverter().convertToEntityAttribute(dbData);
}
// typeRef garde pour reference future si on veut deserialiser directement.
@SuppressWarnings("unused")
private static final TypeReference<List<TemplateField>> TYPE_REF =
new TypeReference<>() {};
}

View File

@@ -62,6 +62,12 @@ public class ArcJpaEntity {
@Builder.Default
private List<String> relatedPageIds = new ArrayList<>();
/** IDs des images (Shared Kernel) illustrant cet arc. JSON dans colonne TEXT. */
@Column(name = "illustration_image_ids", columnDefinition = "TEXT")
@Convert(converter = StringListJsonConverter.class)
@Builder.Default
private List<String> illustrationImageIds = new ArrayList<>();
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;

View File

@@ -52,6 +52,11 @@ public class ChapterJpaEntity {
@Builder.Default
private List<String> relatedPageIds = new ArrayList<>();
@Column(name = "illustration_image_ids", columnDefinition = "TEXT")
@Convert(converter = StringListJsonConverter.class)
@Builder.Default
private List<String> illustrationImageIds = new ArrayList<>();
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;

View File

@@ -0,0 +1,49 @@
package com.loremind.infrastructure.persistence.entity;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* Entite JPA pour les metadonnees d'images en PostgreSQL.
* Le binaire est stocke cote MinIO (reference par storage_key).
*/
@Entity
@Table(name = "images")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ImageJpaEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String filename;
@Column(name = "content_type", nullable = false)
private String contentType;
@Column(name = "size_bytes", nullable = false)
private long sizeBytes;
/** Cle opaque dans MinIO, unique. */
@Column(name = "storage_key", nullable = false, unique = true)
private String storageKey;
@Column(name = "uploaded_at", nullable = false, updatable = false)
private LocalDateTime uploadedAt;
@PrePersist
protected void onCreate() {
if (uploadedAt == null) {
uploadedAt = LocalDateTime.now();
}
}
}

View File

@@ -1,6 +1,7 @@
package com.loremind.infrastructure.persistence.entity;
import com.loremind.infrastructure.persistence.converter.StringListJsonConverter;
import com.loremind.infrastructure.persistence.converter.StringListMapJsonConverter;
import com.loremind.infrastructure.persistence.converter.StringMapJsonConverter;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
@@ -46,6 +47,11 @@ public class PageJpaEntity {
@Convert(converter = StringMapJsonConverter.class)
private Map<String, String> values;
/** Stocke les IDs d'images par champ IMAGE du template. JSON dans colonne TEXT. */
@Column(name = "image_values_json", columnDefinition = "TEXT")
@Convert(converter = StringListMapJsonConverter.class)
private Map<String, List<String>> imageValues;
@Column(columnDefinition = "TEXT")
private String notes;

View File

@@ -73,6 +73,11 @@ public class SceneJpaEntity {
@Builder.Default
private List<String> relatedPageIds = new ArrayList<>();
@Column(name = "illustration_image_ids", columnDefinition = "TEXT")
@Convert(converter = StringListJsonConverter.class)
@Builder.Default
private List<String> illustrationImageIds = new ArrayList<>();
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;

View File

@@ -1,6 +1,7 @@
package com.loremind.infrastructure.persistence.entity;
import com.loremind.infrastructure.persistence.converter.StringListJsonConverter;
import com.loremind.domain.lorecontext.TemplateField;
import com.loremind.infrastructure.persistence.converter.TemplateFieldListJsonConverter;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
@@ -14,7 +15,8 @@ import java.util.List;
* Entité JPA pour la persistance des Templates en PostgreSQL.
* - loreId et defaultNodeId : colonnes typées (FK logiques, pas de @ManyToOne
* pour respecter l'isolation des Bounded Contexts).
* - fields : stocké en JSON (TEXT) via StringListJsonConverter.
* - fields : stocké en JSON (TEXT) via TemplateFieldListJsonConverter.
* Les anciens templates (format legacy ["a","b"]) sont lus de maniere tolerante.
*/
@Entity
@Table(name = "templates")
@@ -41,8 +43,8 @@ public class TemplateJpaEntity {
private Long defaultNodeId;
@Column(name = "fields", columnDefinition = "TEXT")
@Convert(converter = StringListJsonConverter.class)
private List<String> fields;
@Convert(converter = TemplateFieldListJsonConverter.class)
private List<TemplateField> fields;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;

View File

@@ -0,0 +1,13 @@
package com.loremind.infrastructure.persistence.jpa;
import com.loremind.infrastructure.persistence.entity.ImageJpaEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
/**
* Repository Spring Data JPA pour ImageJpaEntity.
* Ne contient aucune requete custom pour l'instant : CRUD standard suffit.
*/
@Repository
public interface ImageJpaRepository extends JpaRepository<ImageJpaEntity, Long> {
}

View File

@@ -80,6 +80,9 @@ public class PostgresArcRepository implements ArcRepository {
.relatedPageIds(jpaEntity.getRelatedPageIds() != null
? new ArrayList<>(jpaEntity.getRelatedPageIds())
: new ArrayList<>())
.illustrationImageIds(jpaEntity.getIllustrationImageIds() != null
? new ArrayList<>(jpaEntity.getIllustrationImageIds())
: new ArrayList<>())
.createdAt(jpaEntity.getCreatedAt())
.updatedAt(jpaEntity.getUpdatedAt())
.build();
@@ -101,6 +104,9 @@ public class PostgresArcRepository implements ArcRepository {
.relatedPageIds(arc.getRelatedPageIds() != null
? new ArrayList<>(arc.getRelatedPageIds())
: new ArrayList<>())
.illustrationImageIds(arc.getIllustrationImageIds() != null
? new ArrayList<>(arc.getIllustrationImageIds())
: new ArrayList<>())
.createdAt(arc.getCreatedAt())
.updatedAt(arc.getUpdatedAt())
.build();

View File

@@ -77,6 +77,9 @@ public class PostgresChapterRepository implements ChapterRepository {
.relatedPageIds(jpaEntity.getRelatedPageIds() != null
? new ArrayList<>(jpaEntity.getRelatedPageIds())
: new ArrayList<>())
.illustrationImageIds(jpaEntity.getIllustrationImageIds() != null
? new ArrayList<>(jpaEntity.getIllustrationImageIds())
: new ArrayList<>())
.createdAt(jpaEntity.getCreatedAt())
.updatedAt(jpaEntity.getUpdatedAt())
.build();
@@ -96,6 +99,9 @@ public class PostgresChapterRepository implements ChapterRepository {
.relatedPageIds(chapter.getRelatedPageIds() != null
? new ArrayList<>(chapter.getRelatedPageIds())
: new ArrayList<>())
.illustrationImageIds(chapter.getIllustrationImageIds() != null
? new ArrayList<>(chapter.getIllustrationImageIds())
: new ArrayList<>())
.createdAt(chapter.getCreatedAt())
.updatedAt(chapter.getUpdatedAt())
.build();

View File

@@ -0,0 +1,69 @@
package com.loremind.infrastructure.persistence.postgres;
import com.loremind.domain.images.Image;
import com.loremind.domain.images.ports.ImageRepository;
import com.loremind.infrastructure.persistence.entity.ImageJpaEntity;
import com.loremind.infrastructure.persistence.jpa.ImageJpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
/**
* Adaptateur de sortie : implemente le port ImageRepository du domaine.
* Fait la traduction Image (domaine) <-> ImageJpaEntity (JPA).
*/
@Repository
public class PostgresImageRepository implements ImageRepository {
private final ImageJpaRepository jpaRepository;
public PostgresImageRepository(ImageJpaRepository jpaRepository) {
this.jpaRepository = jpaRepository;
}
@Override
public Image save(Image image) {
ImageJpaEntity saved = jpaRepository.save(toJpa(image));
return toDomain(saved);
}
@Override
public Optional<Image> findById(String id) {
return jpaRepository.findById(Long.parseLong(id)).map(this::toDomain);
}
@Override
public void deleteById(String id) {
jpaRepository.deleteById(Long.parseLong(id));
}
@Override
public boolean existsById(String id) {
return jpaRepository.existsById(Long.parseLong(id));
}
// --- Conversions -------------------------------------------------------
private Image toDomain(ImageJpaEntity e) {
return Image.builder()
.id(e.getId().toString())
.filename(e.getFilename())
.contentType(e.getContentType())
.sizeBytes(e.getSizeBytes())
.storageKey(e.getStorageKey())
.uploadedAt(e.getUploadedAt())
.build();
}
private ImageJpaEntity toJpa(Image img) {
Long id = img.getId() != null ? Long.parseLong(img.getId()) : null;
return ImageJpaEntity.builder()
.id(id)
.filename(img.getFilename())
.contentType(img.getContentType())
.sizeBytes(img.getSizeBytes())
.storageKey(img.getStorageKey())
.uploadedAt(img.getUploadedAt())
.build();
}
}

View File

@@ -103,6 +103,7 @@ public class PostgresPageRepository implements PageRepository {
.templateId(e.getTemplateId() != null ? e.getTemplateId().toString() : null)
.title(e.getTitle())
.values(e.getValues() != null ? new HashMap<>(e.getValues()) : new HashMap<>())
.imageValues(e.getImageValues() != null ? new HashMap<>(e.getImageValues()) : new HashMap<>())
.notes(e.getNotes())
.tags(e.getTags() != null ? new ArrayList<>(e.getTags()) : new ArrayList<>())
.relatedPageIds(e.getRelatedPageIds() != null ? new ArrayList<>(e.getRelatedPageIds()) : new ArrayList<>())
@@ -120,6 +121,7 @@ public class PostgresPageRepository implements PageRepository {
? Long.parseLong(p.getTemplateId()) : null)
.title(p.getTitle())
.values(p.getValues() != null ? new HashMap<>(p.getValues()) : new HashMap<>())
.imageValues(p.getImageValues() != null ? new HashMap<>(p.getImageValues()) : new HashMap<>())
.notes(p.getNotes())
.tags(p.getTags() != null ? new ArrayList<>(p.getTags()) : new ArrayList<>())
.relatedPageIds(p.getRelatedPageIds() != null ? new ArrayList<>(p.getRelatedPageIds()) : new ArrayList<>())

View File

@@ -82,6 +82,9 @@ public class PostgresSceneRepository implements SceneRepository {
.relatedPageIds(jpaEntity.getRelatedPageIds() != null
? new ArrayList<>(jpaEntity.getRelatedPageIds())
: new ArrayList<>())
.illustrationImageIds(jpaEntity.getIllustrationImageIds() != null
? new ArrayList<>(jpaEntity.getIllustrationImageIds())
: new ArrayList<>())
.createdAt(jpaEntity.getCreatedAt())
.updatedAt(jpaEntity.getUpdatedAt())
.build();
@@ -106,6 +109,9 @@ public class PostgresSceneRepository implements SceneRepository {
.relatedPageIds(scene.getRelatedPageIds() != null
? new ArrayList<>(scene.getRelatedPageIds())
: new ArrayList<>())
.illustrationImageIds(scene.getIllustrationImageIds() != null
? new ArrayList<>(scene.getIllustrationImageIds())
: new ArrayList<>())
.createdAt(scene.getCreatedAt())
.updatedAt(scene.getUpdatedAt())
.build();

View File

@@ -1,6 +1,7 @@
package com.loremind.infrastructure.persistence.postgres;
import com.loremind.domain.lorecontext.Template;
import com.loremind.domain.lorecontext.TemplateField;
import com.loremind.domain.lorecontext.ports.TemplateRepository;
import com.loremind.infrastructure.persistence.entity.TemplateJpaEntity;
import com.loremind.infrastructure.persistence.jpa.TemplateJpaRepository;
@@ -76,7 +77,9 @@ public class PostgresTemplateRepository implements TemplateRepository {
.name(e.getName())
.description(e.getDescription())
.defaultNodeId(e.getDefaultNodeId() != null ? e.getDefaultNodeId().toString() : null)
.fields(e.getFields() != null ? new ArrayList<>(e.getFields()) : new ArrayList<>())
.fields(e.getFields() != null
? new ArrayList<TemplateField>(e.getFields())
: new ArrayList<TemplateField>())
.createdAt(e.getCreatedAt())
.updatedAt(e.getUpdatedAt())
.build();
@@ -89,7 +92,9 @@ public class PostgresTemplateRepository implements TemplateRepository {
.name(t.getName())
.description(t.getDescription())
.defaultNodeId(t.getDefaultNodeId() != null ? Long.parseLong(t.getDefaultNodeId()) : null)
.fields(t.getFields() != null ? new ArrayList<>(t.getFields()) : new ArrayList<>())
.fields(t.getFields() != null
? new ArrayList<TemplateField>(t.getFields())
: new ArrayList<TemplateField>())
.createdAt(t.getCreatedAt())
.updatedAt(t.getUpdatedAt())
.build();

View File

@@ -0,0 +1,61 @@
package com.loremind.infrastructure.storage;
import io.minio.BucketExistsArgs;
import io.minio.MakeBucketArgs;
import io.minio.MinioClient;
import jakarta.annotation.PostConstruct;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Configuration Spring pour le client MinIO (S3-compatible).
*
* Expose un bean MinioClient singleton injecte dans MinioImageStorageAdapter.
* S'assure au demarrage que le bucket configure existe (filet de securite :
* normalement docker-compose/minio-init l'a deja cree).
*/
@Configuration
public class MinioConfig {
@Value("${minio.endpoint}")
private String endpoint;
@Value("${minio.access-key}")
private String accessKey;
@Value("${minio.secret-key}")
private String secretKey;
@Value("${minio.bucket}")
private String bucket;
@Bean
public MinioClient minioClient() {
return MinioClient.builder()
.endpoint(endpoint)
.credentials(accessKey, secretKey)
.build();
}
/**
* Garantit l'existence du bucket au demarrage. Si MinIO n'est pas joignable,
* on loggue juste l'erreur sans planter l'application : le developpeur
* recevra une erreur claire au premier upload plutot qu'au boot.
*/
@PostConstruct
public void ensureBucketExists() {
try {
MinioClient client = minioClient();
boolean exists = client.bucketExists(BucketExistsArgs.builder().bucket(bucket).build());
if (!exists) {
client.makeBucket(MakeBucketArgs.builder().bucket(bucket).build());
System.out.println("[MinIO] Bucket '" + bucket + "' cree.");
}
} catch (Exception e) {
System.err.println("[MinIO] Initialisation impossible (endpoint=" + endpoint
+ "). Les uploads d'images echoueront tant que MinIO n'est pas joignable. "
+ "Cause : " + e.getMessage());
}
}
}

View File

@@ -0,0 +1,97 @@
package com.loremind.infrastructure.storage;
import com.loremind.domain.images.ports.ImageStorage;
import io.minio.GetObjectArgs;
import io.minio.MinioClient;
import io.minio.PutObjectArgs;
import io.minio.RemoveObjectArgs;
import io.minio.errors.ErrorResponseException;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.io.InputStream;
import java.util.UUID;
/**
* Adaptateur d'infrastructure : implemente le port ImageStorage en utilisant
* MinIO (compatible S3) comme backend de stockage d'objets.
*
* Le domaine ne sait rien de MinIO : il manipule juste des cles opaques.
*/
@Component
public class MinioImageStorageAdapter implements ImageStorage {
private final MinioClient minioClient;
private final String bucket;
public MinioImageStorageAdapter(MinioClient minioClient,
@Value("${minio.bucket}") String bucket) {
this.minioClient = minioClient;
this.bucket = bucket;
}
@Override
public String upload(String filename, String contentType, InputStream data, long sizeBytes) {
String storageKey = generateStorageKey(filename);
try {
minioClient.putObject(
PutObjectArgs.builder()
.bucket(bucket)
.object(storageKey)
.stream(data, sizeBytes, -1)
.contentType(contentType)
.build()
);
return storageKey;
} catch (Exception e) {
throw new RuntimeException("Echec de l'upload de l'image vers MinIO : " + e.getMessage(), e);
}
}
@Override
public InputStream download(String storageKey) {
try {
return minioClient.getObject(
GetObjectArgs.builder().bucket(bucket).object(storageKey).build()
);
} catch (ErrorResponseException e) {
// Objet inexistant (cle orpheline) : on retourne null plutot que de propager.
if ("NoSuchKey".equals(e.errorResponse().code())) {
return null;
}
throw new RuntimeException("Echec du download MinIO : " + e.getMessage(), e);
} catch (Exception e) {
throw new RuntimeException("Echec du download MinIO : " + e.getMessage(), e);
}
}
@Override
public void delete(String storageKey) {
try {
minioClient.removeObject(
RemoveObjectArgs.builder().bucket(bucket).object(storageKey).build()
);
} catch (Exception e) {
// Suppression idempotente : on loggue mais on ne propage pas.
System.err.println("[MinIO] Erreur suppression (non bloquante) : " + e.getMessage());
}
}
/**
* Genere une cle unique tout en gardant l'extension d'origine (utile pour
* le Content-Disposition et les outils comme Foundry qui s'en servent).
*/
private String generateStorageKey(String originalFilename) {
String ext = extractExtension(originalFilename);
return "images/" + UUID.randomUUID() + ext;
}
private String extractExtension(String filename) {
if (filename == null) return "";
int dot = filename.lastIndexOf('.');
if (dot < 0 || dot == filename.length() - 1) return "";
String ext = filename.substring(dot).toLowerCase();
// On n'accepte que les extensions connues pour eviter les injections de path.
return ext.matches("\\.(jpg|jpeg|png|webp|gif)") ? ext : "";
}
}

View File

@@ -1,8 +1,10 @@
package com.loremind.infrastructure.web.controller;
import com.loremind.application.generationcontext.StreamChatForCampaignUseCase;
import com.loremind.application.generationcontext.StreamChatForLoreUseCase;
import com.loremind.domain.generationcontext.ChatMessage;
import com.loremind.infrastructure.web.dto.generationcontext.ChatMessageDTO;
import com.loremind.infrastructure.web.dto.generationcontext.ChatStreamCampaignRequestDTO;
import com.loremind.infrastructure.web.dto.generationcontext.ChatStreamRequestDTO;
import org.springframework.core.task.AsyncTaskExecutor;
import org.springframework.http.MediaType;
@@ -19,7 +21,10 @@ import java.util.stream.Collectors;
/**
* REST Controller pour le chat IA streamé (Server-Sent Events).
*
* POST /api/ai/chat/stream → flux SSE de tokens
* Deux endpoints :
* - POST /api/ai/chat/stream → chat ancré sur un Lore
* - POST /api/ai/chat/stream-campaign → chat ancré sur une Campagne
* (qui tire automatiquement son Lore)
*
* Le streaming est lancé dans un thread séparé (AsyncTaskExecutor) pour
* ne pas bloquer le thread servlet pendant toute la durée de la génération.
@@ -34,38 +39,49 @@ public class AiChatController {
private static final long SSE_TIMEOUT_MS = 5 * 60 * 1000L;
private final StreamChatForLoreUseCase streamChatForLoreUseCase;
private final StreamChatForCampaignUseCase streamChatForCampaignUseCase;
private final AsyncTaskExecutor taskExecutor;
public AiChatController(
StreamChatForLoreUseCase streamChatForLoreUseCase,
StreamChatForCampaignUseCase streamChatForCampaignUseCase,
AsyncTaskExecutor taskExecutor) {
this.streamChatForLoreUseCase = streamChatForLoreUseCase;
this.streamChatForCampaignUseCase = streamChatForCampaignUseCase;
this.taskExecutor = taskExecutor;
}
// --- Endpoints ----------------------------------------------------------
@PostMapping(value = "/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter chatStream(@RequestBody ChatStreamRequestDTO body) {
SseEmitter emitter = new SseEmitter(SSE_TIMEOUT_MS);
List<ChatMessage> messages = toDomainMessages(body.getMessages());
taskExecutor.execute(() -> runStreaming(emitter, body.getLoreId(), body.getPageId(), messages));
taskExecutor.execute(() -> runLoreStreaming(emitter, body.getLoreId(), body.getPageId(), messages));
return emitter;
}
@PostMapping(value = "/chat/stream-campaign", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter chatStreamCampaign(@RequestBody ChatStreamCampaignRequestDTO body) {
SseEmitter emitter = new SseEmitter(SSE_TIMEOUT_MS);
List<ChatMessage> messages = toDomainMessages(body.getMessages());
taskExecutor.execute(() -> runCampaignStreaming(
emitter, body.getCampaignId(), body.getEntityType(), body.getEntityId(), messages));
return emitter;
}
// --- Exécution du streaming dans un thread dédié ------------------------
private void runStreaming(SseEmitter emitter, String loreId, String pageId, List<ChatMessage> messages) {
private void runLoreStreaming(
SseEmitter emitter, String loreId, String pageId, List<ChatMessage> messages) {
try {
streamChatForLoreUseCase.execute(
loreId,
pageId,
messages,
loreId, pageId, messages,
token -> sendToken(emitter, token),
() -> complete(emitter),
error -> fail(emitter, error)
);
error -> fail(emitter, error));
} catch (IllegalArgumentException e) {
// Lore ou Page introuvable : on envoie un event error puis on termine proprement.
fail(emitter, e);
@@ -74,6 +90,25 @@ public class AiChatController {
}
}
private void runCampaignStreaming(
SseEmitter emitter,
String campaignId,
String entityType,
String entityId,
List<ChatMessage> messages) {
try {
streamChatForCampaignUseCase.execute(
campaignId, entityType, entityId, messages,
token -> sendToken(emitter, token),
() -> complete(emitter),
error -> fail(emitter, error));
} catch (IllegalArgumentException e) {
fail(emitter, e);
} catch (Exception e) {
fail(emitter, e);
}
}
// --- Helpers SSE (un seul point d'écriture par type d'événement) --------
private void sendToken(SseEmitter emitter, String token) {

View File

@@ -0,0 +1,103 @@
package com.loremind.infrastructure.web.controller;
import com.loremind.application.images.ImageService;
import com.loremind.domain.images.Image;
import com.loremind.infrastructure.web.dto.images.ImageDTO;
import org.springframework.core.io.InputStreamResource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.io.InputStream;
import java.util.Optional;
/**
* REST Controller pour le Shared Kernel images.
*
* Expose :
* - POST /api/images (multipart/form-data, champ "file")
* - GET /api/images/{id} (metadonnees JSON)
* - GET /api/images/{id}/content (binaire, pour <img src=...>)
* - DELETE /api/images/{id}
*/
@RestController
@RequestMapping("/api/images")
public class ImageController {
private final ImageService imageService;
public ImageController(ImageService imageService) {
this.imageService = imageService;
}
@PostMapping
public ResponseEntity<ImageDTO> upload(@RequestParam("file") MultipartFile file) throws IOException {
if (file.isEmpty()) {
return ResponseEntity.badRequest().build();
}
try (InputStream in = file.getInputStream()) {
Image saved = imageService.upload(
file.getOriginalFilename(),
file.getContentType(),
in,
file.getSize()
);
return ResponseEntity.ok(toDTO(saved));
} catch (IllegalArgumentException ex) {
// Validation metier : MIME non autorise, fichier vide, taille excessive...
return ResponseEntity.badRequest().body(null);
}
}
@GetMapping("/{id}")
public ResponseEntity<ImageDTO> getMetadata(@PathVariable String id) {
return imageService.getById(id)
.map(img -> ResponseEntity.ok(toDTO(img)))
.orElse(ResponseEntity.notFound().build());
}
/**
* Streaming du binaire. Le navigateur pourra directement l'utiliser dans
* une balise <img src="/api/images/42/content">.
*/
@GetMapping("/{id}/content")
public ResponseEntity<InputStreamResource> getContent(@PathVariable String id) {
Optional<Image> metadata = imageService.getById(id);
if (metadata.isEmpty()) {
return ResponseEntity.notFound().build();
}
Image img = metadata.get();
InputStream stream = imageService.downloadById(id).orElse(null);
if (stream == null) {
// Metadonnees presentes mais binaire perdu -> incoherence, on renvoie 404.
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok()
.contentType(MediaType.parseMediaType(img.getContentType()))
.contentLength(img.getSizeBytes())
.header(HttpHeaders.CACHE_CONTROL, "public, max-age=31536000, immutable")
.body(new InputStreamResource(stream));
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable String id) {
imageService.deleteById(id);
return ResponseEntity.noContent().build();
}
// --- Mapping -----------------------------------------------------------
private ImageDTO toDTO(Image img) {
ImageDTO dto = new ImageDTO();
dto.setId(img.getId());
dto.setFilename(img.getFilename());
dto.setContentType(img.getContentType());
dto.setSizeBytes(img.getSizeBytes());
dto.setUrl("/api/images/" + img.getId() + "/content");
dto.setUploadedAt(img.getUploadedAt());
return dto;
}
}

View File

@@ -2,7 +2,9 @@ package com.loremind.infrastructure.web.controller;
import com.loremind.application.lorecontext.TemplateService;
import com.loremind.domain.lorecontext.Template;
import com.loremind.domain.lorecontext.TemplateField;
import com.loremind.infrastructure.web.dto.lorecontext.TemplateDTO;
import com.loremind.infrastructure.web.mapper.TemplateFieldMapper;
import com.loremind.infrastructure.web.mapper.TemplateMapper;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@@ -21,20 +23,27 @@ public class TemplateController {
private final TemplateService templateService;
private final TemplateMapper templateMapper;
private final TemplateFieldMapper fieldMapper;
public TemplateController(TemplateService templateService, TemplateMapper templateMapper) {
public TemplateController(TemplateService templateService,
TemplateMapper templateMapper,
TemplateFieldMapper fieldMapper) {
this.templateService = templateService;
this.templateMapper = templateMapper;
this.fieldMapper = fieldMapper;
}
@PostMapping
public ResponseEntity<TemplateDTO> createTemplate(@RequestBody TemplateDTO dto) {
List<TemplateField> fields = dto.getFields() == null
? List.of()
: dto.getFields().stream().map(fieldMapper::toDomain).toList();
Template created = templateService.createTemplate(
dto.getLoreId(),
dto.getName(),
dto.getDescription(),
dto.getDefaultNodeId(),
dto.getFields()
fields
);
return ResponseEntity.ok(templateMapper.toDTO(created));
}

View File

@@ -26,4 +26,7 @@ public class ArcDTO {
/** IDs des pages du Lore liées à cet arc (weak cross-context references). */
private List<String> relatedPageIds = new ArrayList<>();
/** IDs des images (Shared Kernel) illustrant cet arc. */
private List<String> illustrationImageIds = new ArrayList<>();
}

View File

@@ -24,4 +24,7 @@ public class ChapterDTO {
/** IDs des pages du Lore liées (weak cross-context references). */
private List<String> relatedPageIds = new ArrayList<>();
/** IDs des images (Shared Kernel) illustrant ce chapitre. */
private List<String> illustrationImageIds = new ArrayList<>();
}

View File

@@ -29,4 +29,7 @@ public class SceneDTO {
/** IDs des pages du Lore liées (weak cross-context references). */
private List<String> relatedPageIds = new ArrayList<>();
/** IDs des images (Shared Kernel) illustrant cette scene. */
private List<String> illustrationImageIds = new ArrayList<>();
}

View File

@@ -0,0 +1,31 @@
package com.loremind.infrastructure.web.dto.generationcontext;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
/**
* DTO HTTP de requête pour POST /api/ai/chat/stream-campaign.
*
* Le Core charge lui-même :
* - la carte narrative à partir de {campaignId} ;
* - la carte du Lore associé si la campagne a un `loreId` (asymétrie :
* une Campagne voit son Lore, l'inverse n'est pas vrai) ;
* - l'entité narrative focalisée si {entityType}+{entityId} sont fournis.
*
* Le frontend n'a qu'à envoyer l'historique + les IDs.
*/
@Data
@NoArgsConstructor
public class ChatStreamCampaignRequestDTO {
private String campaignId;
/** Optionnel : "arc", "chapter" ou "scene". Si fourni, doit être accompagné d'entityId. */
private String entityType;
/** Optionnel : ID de l'entité narrative en cours d'édition. */
private String entityId;
private List<ChatMessageDTO> messages;
}

View File

@@ -0,0 +1,24 @@
package com.loremind.infrastructure.web.dto.images;
import lombok.Data;
import java.time.LocalDateTime;
/**
* DTO de retour pour les metadonnees d'une image.
* Ne contient PAS le binaire : celui-ci est servi separement via
* GET /api/images/{id}/content.
*/
@Data
public class ImageDTO {
private String id;
private String filename;
private String contentType;
private long sizeBytes;
/**
* URL relative pour telecharger le binaire.
* Le front construit l'URL absolue en prefixant le baseUrl de l'API.
*/
private String url;
private LocalDateTime uploadedAt;
}

View File

@@ -18,6 +18,8 @@ public class PageDTO {
private String templateId;
private String title;
private Map<String, String> values;
/** Pour chaque champ IMAGE du template, la liste ordonnee des IDs d'images. */
private Map<String, List<String>> imageValues;
private String notes;
private List<String> tags;
private List<String> relatedPageIds;

View File

@@ -17,6 +17,6 @@ public class TemplateDTO {
private String name;
private String description;
private String defaultNodeId;
private List<String> fields;
private List<TemplateFieldDTO> fields;
private int fieldCount;
}

View File

@@ -0,0 +1,20 @@
package com.loremind.infrastructure.web.dto.lorecontext;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* DTO pour un champ de Template.
*
* Miroir wire-friendly de {@link com.loremind.domain.lorecontext.TemplateField}.
* Le type est serialise en string (TEXT/IMAGE) pour interop facile avec Angular.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class TemplateFieldDTO {
private String name;
/** "TEXT" ou "IMAGE" (string pour serialisation JSON transparente). */
private String type;
}

View File

@@ -31,6 +31,9 @@ public class ArcMapper {
dto.setRelatedPageIds(arc.getRelatedPageIds() != null
? new ArrayList<>(arc.getRelatedPageIds())
: new ArrayList<>());
dto.setIllustrationImageIds(arc.getIllustrationImageIds() != null
? new ArrayList<>(arc.getIllustrationImageIds())
: new ArrayList<>());
return dto;
}
@@ -53,6 +56,9 @@ public class ArcMapper {
.relatedPageIds(dto.getRelatedPageIds() != null
? new ArrayList<>(dto.getRelatedPageIds())
: new ArrayList<>())
.illustrationImageIds(dto.getIllustrationImageIds() != null
? new ArrayList<>(dto.getIllustrationImageIds())
: new ArrayList<>())
.build();
}
}

View File

@@ -29,6 +29,9 @@ public class ChapterMapper {
dto.setRelatedPageIds(chapter.getRelatedPageIds() != null
? new ArrayList<>(chapter.getRelatedPageIds())
: new ArrayList<>());
dto.setIllustrationImageIds(chapter.getIllustrationImageIds() != null
? new ArrayList<>(chapter.getIllustrationImageIds())
: new ArrayList<>());
return dto;
}
@@ -49,6 +52,9 @@ public class ChapterMapper {
.relatedPageIds(dto.getRelatedPageIds() != null
? new ArrayList<>(dto.getRelatedPageIds())
: new ArrayList<>())
.illustrationImageIds(dto.getIllustrationImageIds() != null
? new ArrayList<>(dto.getIllustrationImageIds())
: new ArrayList<>())
.build();
}
}

View File

@@ -24,6 +24,7 @@ public class PageMapper {
dto.setTemplateId(page.getTemplateId());
dto.setTitle(page.getTitle());
dto.setValues(page.getValues() != null ? new HashMap<>(page.getValues()) : new HashMap<>());
dto.setImageValues(page.getImageValues() != null ? new HashMap<>(page.getImageValues()) : new HashMap<>());
dto.setNotes(page.getNotes());
dto.setTags(page.getTags() != null ? new ArrayList<>(page.getTags()) : new ArrayList<>());
dto.setRelatedPageIds(page.getRelatedPageIds() != null ? new ArrayList<>(page.getRelatedPageIds()) : new ArrayList<>());
@@ -41,6 +42,7 @@ public class PageMapper {
.templateId(dto.getTemplateId())
.title(dto.getTitle())
.values(dto.getValues() != null ? new HashMap<>(dto.getValues()) : new HashMap<>())
.imageValues(dto.getImageValues() != null ? new HashMap<>(dto.getImageValues()) : new HashMap<>())
.notes(dto.getNotes())
.tags(dto.getTags() != null ? new ArrayList<>(dto.getTags()) : new ArrayList<>())
.relatedPageIds(dto.getRelatedPageIds() != null ? new ArrayList<>(dto.getRelatedPageIds()) : new ArrayList<>())

View File

@@ -34,6 +34,9 @@ public class SceneMapper {
dto.setRelatedPageIds(scene.getRelatedPageIds() != null
? new ArrayList<>(scene.getRelatedPageIds())
: new ArrayList<>());
dto.setIllustrationImageIds(scene.getIllustrationImageIds() != null
? new ArrayList<>(scene.getIllustrationImageIds())
: new ArrayList<>());
return dto;
}
@@ -59,6 +62,9 @@ public class SceneMapper {
.relatedPageIds(dto.getRelatedPageIds() != null
? new ArrayList<>(dto.getRelatedPageIds())
: new ArrayList<>())
.illustrationImageIds(dto.getIllustrationImageIds() != null
? new ArrayList<>(dto.getIllustrationImageIds())
: new ArrayList<>())
.build();
}
}

View File

@@ -0,0 +1,34 @@
package com.loremind.infrastructure.web.mapper;
import com.loremind.domain.lorecontext.FieldType;
import com.loremind.domain.lorecontext.TemplateField;
import com.loremind.infrastructure.web.dto.lorecontext.TemplateFieldDTO;
import org.springframework.stereotype.Component;
/**
* Mapper pour convertir entre {@link TemplateField} (domaine) et
* {@link TemplateFieldDTO} (wire).
*
* Tolerance : un type inconnu recu du client est interprete comme TEXT
* (plus safe que de rejeter la requete et d'interrompre la sauvegarde).
*/
@Component
public class TemplateFieldMapper {
public TemplateFieldDTO toDTO(TemplateField field) {
if (field == null) return null;
String typeStr = field.getType() != null ? field.getType().name() : FieldType.TEXT.name();
return new TemplateFieldDTO(field.getName(), typeStr);
}
public TemplateField toDomain(TemplateFieldDTO dto) {
if (dto == null) return null;
FieldType type;
try {
type = dto.getType() != null ? FieldType.valueOf(dto.getType()) : FieldType.TEXT;
} catch (IllegalArgumentException ex) {
type = FieldType.TEXT;
}
return new TemplateField(dto.getName(), type);
}
}

View File

@@ -1,17 +1,27 @@
package com.loremind.infrastructure.web.mapper;
import com.loremind.domain.lorecontext.Template;
import com.loremind.domain.lorecontext.TemplateField;
import com.loremind.infrastructure.web.dto.lorecontext.TemplateDTO;
import com.loremind.infrastructure.web.dto.lorecontext.TemplateFieldDTO;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
/**
* Mapper pour convertir entre Template (entité de domaine) et TemplateDTO.
* Delegue la conversion de chaque champ a {@link TemplateFieldMapper}.
*/
@Component
public class TemplateMapper {
private final TemplateFieldMapper fieldMapper;
public TemplateMapper(TemplateFieldMapper fieldMapper) {
this.fieldMapper = fieldMapper;
}
public TemplateDTO toDTO(Template template) {
if (template == null) {
return null;
@@ -22,9 +32,7 @@ public class TemplateMapper {
dto.setName(template.getName());
dto.setDescription(template.getDescription());
dto.setDefaultNodeId(template.getDefaultNodeId());
dto.setFields(template.getFields() != null
? new ArrayList<>(template.getFields())
: new ArrayList<>());
dto.setFields(mapFieldsToDto(template.getFields()));
dto.setFieldCount(template.fieldCount());
return dto;
}
@@ -39,9 +47,21 @@ public class TemplateMapper {
.name(dto.getName())
.description(dto.getDescription())
.defaultNodeId(dto.getDefaultNodeId())
.fields(dto.getFields() != null
? new ArrayList<>(dto.getFields())
: new ArrayList<>())
.fields(mapFieldsToDomain(dto.getFields()))
.build();
}
private List<TemplateFieldDTO> mapFieldsToDto(List<TemplateField> fields) {
if (fields == null) return new ArrayList<>();
List<TemplateFieldDTO> result = new ArrayList<>(fields.size());
for (TemplateField f : fields) result.add(fieldMapper.toDTO(f));
return result;
}
private List<TemplateField> mapFieldsToDomain(List<TemplateFieldDTO> dtos) {
if (dtos == null) return new ArrayList<>();
List<TemplateField> result = new ArrayList<>(dtos.size());
for (TemplateFieldDTO d : dtos) result.add(fieldMapper.toDomain(d));
return result;
}
}

View File

@@ -26,3 +26,14 @@ spring.web.cors.allow-credentials=true
# Configuration du Brain (service IA Python)
brain.base-url=http://localhost:8000
brain.timeout-seconds=120
# Configuration MinIO (Shared Kernel images - Object Storage)
# Le bucket est cree automatiquement par le service minio-init (docker-compose up -d).
minio.endpoint=http://localhost:9000
minio.access-key=minioadmin
minio.secret-key=minioadmin
minio.bucket=loremind-images
# Limites d'upload d'images (MB)
spring.servlet.multipart.max-file-size=10MB
spring.servlet.multipart.max-request-size=10MB