Mise à jour avec la possibilité de mettre des images
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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).");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
56
core/src/main/java/com/loremind/domain/images/Image.java
Normal file
56
core/src/main/java/com/loremind/domain/images/Image.java
Normal 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/");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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<>();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<>() {};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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> {
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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<>())
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 : "";
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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<>();
|
||||
}
|
||||
|
||||
@@ -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<>();
|
||||
}
|
||||
|
||||
@@ -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<>();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<>())
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user