Ajout d'un script pour installation automatique du produit
Some checks failed
E2E Tests / e2e (push) Failing after 19s
Build & Push Images / build (brain) (push) Successful in 45s
Build & Push Images / build (core) (push) Successful in 1m16s
Build & Push Images / build (web) (push) Successful in 1m26s

Ajout d'une partie mise à jour automatique : plus besoin de docker pull en ligne de commande ; on peut passer par l'interface
Refactoring partie Java pour respecter d'avantage le DDD : plus de jackson dans la partie domain

Passage version 0.6.6
This commit is contained in:
2026-04-25 13:24:32 +02:00
parent 550078268c
commit 41fda9aeee
58 changed files with 1859 additions and 812 deletions

View File

@@ -38,3 +38,15 @@ LLM_MODEL=gemma4:26b
# 1min.ai (si LLM_PROVIDER=onemin)
ONEMIN_API_KEY=
ONEMIN_MODEL=gpt-4o-mini
# --- Mises a jour automatiques (Watchtower) ------------------------------
# Watchtower verifie les nouvelles versions de core/brain/web et permet
# le declenchement manuel via l'UI (bouton "Mettre a jour"). Postgres et
# MinIO sont exclus volontairement.
#
# Activer : COMPOSE_PROFILES=autoupdate + WATCHTOWER_TOKEN non vide.
# COMPOSE_PROFILES=autoupdate
# WATCHTOWER_TOKEN=change-me-use-openssl-rand-hex-32
# WATCHTOWER_MONITOR_ONLY=false # true = detecter sans appliquer
# WATCHTOWER_SCHEDULE=0 0 4 * * *
# TZ=Europe/Paris

View File

@@ -40,7 +40,7 @@ from app.infrastructure.onemin_adapter import OneMinAiLLMProvider
app = FastAPI(
title="LoreMind Brain",
description="Backend IA pour la génération de contenu narratif.",
version="0.6.5",
version="0.6.6",
)

View File

@@ -14,7 +14,7 @@
<groupId>com.loremind</groupId>
<artifactId>loremind-core</artifactId>
<version>0.6.5</version>
<version>0.6.6</version>
<name>LoreMind Core</name>
<description>Backend Core - Architecture Hexagonale</description>

View File

@@ -98,7 +98,7 @@ public class SceneService {
.collect(Collectors.toSet());
for (SceneBranch b : branches) {
String target = b.getTargetSceneId();
String target = b.targetSceneId();
if (target == null || target.isBlank()) {
throw new IllegalArgumentException("Une branche doit avoir une scène de destination");
}

View File

@@ -45,11 +45,7 @@ public class GameSystemContextBuilder {
private GameSystemContext build(GameSystem gs, GenerationIntent intent) {
Map<String, String> allSections = parseH2Sections(gs.getRulesMarkdown());
Map<String, String> filtered = filterByIntent(allSections, intent);
return GameSystemContext.builder()
.systemName(gs.getName())
.systemDescription(gs.getDescription())
.sections(filtered)
.build();
return new GameSystemContext(gs.getName(), gs.getDescription(), filtered);
}
/**

View File

@@ -79,12 +79,11 @@ public class CampaignStructuralContextBuilder {
.map(this::toCharacterSummary)
.collect(Collectors.toList());
return CampaignStructuralContext.builder()
.campaignName(campaign.getName())
.campaignDescription(campaign.getDescription())
.arcs(arcs)
.characters(characters)
.build();
return new CampaignStructuralContext(
campaign.getName(),
campaign.getDescription(),
arcs,
characters);
}
/**
@@ -93,10 +92,7 @@ public class CampaignStructuralContextBuilder {
* sans injecter toute sa fiche.
*/
private CharacterSummary toCharacterSummary(Character c) {
return CharacterSummary.builder()
.name(c.getName())
.snippet(extractSnippet(c.getMarkdownContent()))
.build();
return new CharacterSummary(c.getName(), extractSnippet(c.getMarkdownContent()));
}
private static String extractSnippet(String markdown) {
@@ -115,12 +111,11 @@ public class CampaignStructuralContextBuilder {
.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();
return new ArcSummary(
arc.getName(),
arc.getDescription(),
countImages(arc.getIllustrationImageIds()),
chapters);
}
private ChapterSummary toChapterSummary(Chapter chapter) {
@@ -137,32 +132,28 @@ public class CampaignStructuralContextBuilder {
.map(s -> toSceneSummary(s, nameById))
.collect(Collectors.toList());
return ChapterSummary.builder()
.name(chapter.getName())
.description(chapter.getDescription())
.illustrationCount(countImages(chapter.getIllustrationImageIds()))
.scenes(summaries)
.build();
return new ChapterSummary(
chapter.getName(),
chapter.getDescription(),
countImages(chapter.getIllustrationImageIds()),
summaries);
}
private SceneSummary toSceneSummary(Scene scene, Map<String, String> nameById) {
List<BranchHint> hints = scene.getBranches() == null
? List.of()
: scene.getBranches().stream()
.map(b -> BranchHint.builder()
.label(b.getLabel())
.targetSceneName(nameById.getOrDefault(
b.getTargetSceneId(), "(scène inconnue)"))
.condition(b.getCondition())
.build())
.map(b -> new BranchHint(
b.label(),
nameById.getOrDefault(b.targetSceneId(), "(scène inconnue)"),
b.condition()))
.collect(Collectors.toList());
return SceneSummary.builder()
.name(scene.getName())
.description(scene.getDescription())
.illustrationCount(countImages(scene.getIllustrationImageIds()))
.branches(hints)
.build();
return new SceneSummary(
scene.getName(),
scene.getDescription(),
countImages(scene.getIllustrationImageIds()),
hints);
}
/** Helper defensif : compte les illustrations attachees (null-safe). */

View File

@@ -67,16 +67,15 @@ public class GeneratePageValuesUseCase {
requireNonEmptyFields(template);
GenerationContext context = GenerationContext.builder()
.loreName(lore.getName())
.loreDescription(lore.getDescription())
.folderName(folder.getName())
.templateName(template.getName())
// 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();
// Seuls les champs TEXT sont envoyes a l'IA : les champs IMAGE
// necessitent un workflow different (pas de generation LLM texte).
GenerationContext context = new GenerationContext(
lore.getName(),
lore.getDescription(),
folder.getName(),
template.getName(),
template.textFieldNames(),
page.getTitle());
GenerationResult result = aiProvider.generatePage(context);
return result.values();

View File

@@ -82,12 +82,11 @@ public class LoreStructuralContextBuilder {
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();
return new LoreStructuralContext(
lore.getName(),
lore.getDescription(),
buildFoldersMap(nodes, pages, templateNameById, pageTitleById),
extractUniqueTags(pages));
}
private Map<String, List<PageSummary>> buildFoldersMap(
@@ -118,13 +117,12 @@ public class LoreStructuralContextBuilder {
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();
return new PageSummary(
page.getTitle(),
templateNameById.getOrDefault(page.getTemplateId(), "?"),
truncatedValues(page.getValues()),
page.getTags() != null ? List.copyOf(page.getTags()) : Collections.emptyList(),
resolveRelatedTitles(page.getRelatedPageIds(), pageTitleById));
}
/**

View File

@@ -91,11 +91,7 @@ public class NarrativeEntityContextBuilder {
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();
return new NarrativeEntityContext("arc", a.getName(), fields);
}
private NarrativeEntityContext fromChapter(Chapter c) {
@@ -104,11 +100,7 @@ public class NarrativeEntityContextBuilder {
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();
return new NarrativeEntityContext("chapter", c.getName(), fields);
}
private NarrativeEntityContext fromScene(Scene s) {
@@ -122,21 +114,13 @@ public class NarrativeEntityContextBuilder {
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();
return new NarrativeEntityContext("scene", s.getName(), fields);
}
private NarrativeEntityContext fromCharacter(Character c) {
Map<String, String> fields = new LinkedHashMap<>();
putField(fields, "fiche complète (markdown)", c.getMarkdownContent());
return NarrativeEntityContext.builder()
.entityType("character")
.title(c.getName())
.fields(fields)
.build();
return new NarrativeEntityContext("character", c.getName(), fields);
}
/** Null/blank devient chaîne vide — uniforme côté prompt, pas de NPE côté LLM. */

View File

@@ -107,11 +107,6 @@ public class StreamChatForLoreUseCase {
? page.getValues()
: Collections.emptyMap();
return PageContext.builder()
.title(page.getTitle())
.templateName(templateName)
.templateFields(templateFields)
.values(values)
.build();
return new PageContext(page.getTitle(), templateName, templateFields, values);
}
}

View File

@@ -1,31 +1,25 @@
package com.loremind.domain.campaigncontext;
import lombok.Builder;
import lombok.Value;
import lombok.extern.jackson.Jacksonized;
/**
* Value Object représentant une "sortie" narrative depuis une Scene.
* Décrit un choix offert aux joueurs et la scène de destination associée.
* <p>
* Immuable (@Value) : pour "modifier" une branche on la remplace.
* @Jacksonized : permet à Jackson (sérialisation JSON via le converter JPA)
* de reconstruire l'objet en passant par le builder malgré l'absence de setters.
* Record Java : immuable par construction, sans aucune dépendance technique
* (pas de Lombok, pas de Jackson). Jackson 2.12+ sait sérialiser/désérialiser
* les records nativement via le constructeur canonique — c'est ce dont
* dépend le SceneBranchListJsonConverter pour le stockage JSONB.
* <p>
* Règle métier : targetSceneId DOIT pointer vers une Scene du MÊME Chapter
* (validation portée par SceneService).
*
* @param label Libellé du choix (ex: "Si les joueurs attaquent le garde").
* @param targetSceneId Id de la Scene de destination, intra-chapitre uniquement.
* @param condition Notes MJ privées sur la condition de déclenchement (optionnel).
*/
@Value
@Builder
@Jacksonized
public class SceneBranch {
public record SceneBranch(String label, String targetSceneId, String condition) {
/** Libellé du choix (ex: "Si les joueurs attaquent le garde"). */
String label;
/** Id de la Scene de destination, intra-chapitre uniquement. */
String targetSceneId;
/** Notes MJ privées sur la condition de déclenchement (optionnel). */
String condition;
/** Raccourci pour construire une branche sans condition (cas le plus courant). */
public static SceneBranch of(String label, String targetSceneId) {
return new SceneBranch(label, targetSceneId, null);
}
}

View File

@@ -1,9 +1,5 @@
package com.loremind.domain.generationcontext;
import lombok.Builder;
import lombok.Singular;
import lombok.Value;
import java.util.List;
/**
@@ -22,16 +18,16 @@ import java.util.List;
* <p>
* La liste `arcs` préserve l'ordre narratif (tri sur `order` ascendant
* fait par le use case côté application layer).
* <p>
* Record Java : pur domaine, aucune dépendance technique.
*
* @param characters Personnages joueurs (PJ) de la campagne. Vide si aucun.
*/
@Value
@Builder
public class CampaignStructuralContext {
String campaignName;
String campaignDescription;
@Singular List<ArcSummary> arcs;
/** Personnages joueurs (PJ) de la campagne. Vide si aucun. */
@Singular List<CharacterSummary> characters;
public record CampaignStructuralContext(
String campaignName,
String campaignDescription,
List<ArcSummary> arcs,
List<CharacterSummary> characters) {
/**
* Résumé d'un PJ : nom + snippet court du markdown.
@@ -40,53 +36,44 @@ public class CampaignStructuralContext {
* La fiche complète n'est injectée que si le PJ est l'entité focus
* (via NarrativeEntityContext, entity_type="character").
*/
@Value
@Builder
public static class CharacterSummary {
String name;
String snippet;
public record CharacterSummary(String name, String snippet) {
}
/** 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 arc : nom + description courte + ses chapitres.
*
* @param illustrationCount Nombre d'illustrations attachees a cet arc (pour hint dans le prompt IA).
*/
public record ArcSummary(
String name,
String description,
int illustrationCount,
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;
public record ChapterSummary(
String name,
String description,
int illustrationCount,
List<SceneSummary> scenes) {
}
/** Résumé d'une scène : nom + description courte + branches narratives. */
@Value
@Builder
public static class SceneSummary {
String name;
String description;
int illustrationCount;
@Singular List<BranchHint> branches;
public record SceneSummary(
String name,
String description,
int illustrationCount,
List<BranchHint> branches) {
}
/** Indice d'une branche narrative vers une autre scène du même chapitre. */
@Value
@Builder
public static class BranchHint {
/** Libellé du choix joueur (ex: "Si les joueurs attaquent le garde"). */
String label;
/** Nom de la scène cible (résolu depuis targetSceneId côté builder). */
String targetSceneName;
/** Condition MJ privée (optionnel). */
String condition;
/**
* Indice d'une branche narrative vers une autre scène du même chapitre.
*
* @param label Libellé du choix joueur (ex: "Si les joueurs attaquent le garde").
* @param targetSceneName Nom de la scène cible (résolu depuis targetSceneId côté builder).
* @param condition Condition MJ privée (optionnel).
*/
public record BranchHint(String label, String targetSceneName, String condition) {
}
}

View File

@@ -1,8 +1,5 @@
package com.loremind.domain.generationcontext;
import lombok.Builder;
import lombok.Value;
import java.util.List;
/**
@@ -21,28 +18,74 @@ import java.util.List;
* 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).
* <p>
* Record Java : pur domaine. Builder manuel fourni en raison des 6 champs
* dont 5 sont nullables — l'API fluide reste plus lisible aux call sites
* qu'un constructeur à 6 paramètres souvent à null.
*
* @param loreContext Optionnel : carte structurelle du Lore. Null si campagne non liée à un Lore.
* @param pageContext Optionnel : contexte d'une page précise en cours d'édition (chat Lore uniquement).
* @param campaignContext Optionnel : carte narrative d'une Campagne (chat Campagne uniquement).
* @param narrativeEntity Optionnel : entité narrative en cours d'édition (arc/chapter/scene).
* @param gameSystemContext Optionnel : règles du système de JDR de la campagne (filtrées par intent).
* Null si la campagne n'a pas de GameSystem associé. Campagne uniquement au MVP.
*/
@Value
@Builder
public class ChatRequest {
public record ChatRequest(
List<ChatMessage> messages,
LoreStructuralContext loreContext,
PageContext pageContext,
CampaignStructuralContext campaignContext,
NarrativeEntityContext narrativeEntity,
GameSystemContext gameSystemContext) {
List<ChatMessage> messages;
public static Builder builder() {
return new Builder();
}
/** Optionnel : carte structurelle du Lore. Null si campagne non liée à un Lore. */
LoreStructuralContext loreContext;
/** Builder fluide : permet d'omettre les contextes non pertinents. */
public static final class Builder {
private List<ChatMessage> messages;
private LoreStructuralContext loreContext;
private PageContext pageContext;
private CampaignStructuralContext campaignContext;
private NarrativeEntityContext narrativeEntity;
private GameSystemContext gameSystemContext;
/** Optionnel : contexte d'une page précise en cours d'édition (chat Lore uniquement). */
PageContext pageContext;
private Builder() {}
/** Optionnel : carte narrative d'une Campagne (chat Campagne uniquement). */
CampaignStructuralContext campaignContext;
public Builder messages(List<ChatMessage> messages) {
this.messages = messages;
return this;
}
/** Optionnel : entité narrative en cours d'édition (arc/chapter/scene). */
NarrativeEntityContext narrativeEntity;
public Builder loreContext(LoreStructuralContext loreContext) {
this.loreContext = loreContext;
return this;
}
/**
* Optionnel : règles du système de JDR de la campagne (filtrées par intent).
* Null si la campagne n'a pas de GameSystem associé. Campagne uniquement au MVP.
*/
GameSystemContext gameSystemContext;
public Builder pageContext(PageContext pageContext) {
this.pageContext = pageContext;
return this;
}
public Builder campaignContext(CampaignStructuralContext campaignContext) {
this.campaignContext = campaignContext;
return this;
}
public Builder narrativeEntity(NarrativeEntityContext narrativeEntity) {
this.narrativeEntity = narrativeEntity;
return this;
}
public Builder gameSystemContext(GameSystemContext gameSystemContext) {
this.gameSystemContext = gameSystemContext;
return this;
}
public ChatRequest build() {
return new ChatRequest(messages, loreContext, pageContext,
campaignContext, narrativeEntity, gameSystemContext);
}
}
}

View File

@@ -1,8 +1,5 @@
package com.loremind.domain.generationcontext;
import lombok.Builder;
import lombok.Value;
import java.util.Map;
/**
@@ -11,20 +8,14 @@ import java.util.Map;
* Contient uniquement les sections pertinentes pour l'intent de génération
* en cours (sélection effectuée par GameSystemContextBuilder). Les sections
* sont indexées par leur titre H2 original (ex : "Combat", "Classes").
*
* @param systemName Nom du système de JDR (ex : "Nimble", "D&D 5.1 SRD").
* @param systemDescription Description courte du système (nullable).
* @param sections Sections de règles pertinentes, indexées par titre H2.
* Vide si le GameSystem n'a aucune règle ou si aucune section ne matche l'intent.
*/
@Value
@Builder
public class GameSystemContext {
/** Nom du système de JDR (ex : "Nimble", "D&D 5.1 SRD"). */
String systemName;
/** Description courte du système (nullable). */
String systemDescription;
/**
* Sections de règles pertinentes, indexées par titre H2.
* Vide si le GameSystem n'a aucune règle ou si aucune section ne matche l'intent.
*/
Map<String, String> sections;
public record GameSystemContext(
String systemName,
String systemDescription,
Map<String, String> sections) {
}

View File

@@ -1,8 +1,5 @@
package com.loremind.domain.generationcontext;
import lombok.Builder;
import lombok.Value;
import java.util.List;
/**
@@ -10,19 +7,16 @@ import java.util.List;
* pour remplir une Page à partir d'un Template.
* <p>
* Équivalent Java du PageGenerationContext Python (brain/app/domain/models.py).
* Entité pure du domaine : aucune dépendance technique.
* <p>
* Immuable via @Value (Lombok) : pas de setters, tous les champs final.
* C'est un DTO de domaine entrant dans le port AiProvider.
* Record Java : pur domaine, aucune dépendance technique.
*
* @param templateFields Champs à générer (clés attendues dans la réponse).
* @param folderName Nom du LoreNode parent (ex: "PNJ", "Lieux").
*/
@Value
@Builder
public class GenerationContext {
String loreName;
String loreDescription;
String folderName; // Nom du LoreNode parent (ex: "PNJ", "Lieux")
String templateName;
List<String> templateFields; // Champs à générer (clés attendues dans la réponse)
String pageTitle;
public record GenerationContext(
String loreName,
String loreDescription,
String folderName,
String templateName,
List<String> templateFields,
String pageTitle) {
}

View File

@@ -1,9 +1,5 @@
package com.loremind.domain.generationcontext;
import lombok.Builder;
import lombok.Singular;
import lombok.Value;
import java.util.List;
import java.util.Map;
@@ -16,15 +12,14 @@ import java.util.Map;
* <p>
* 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).
* <p>
* Record Java : pur domaine, aucune dépendance technique.
*/
@Value
@Builder
public class LoreStructuralContext {
String loreName;
String loreDescription;
Map<String, List<PageSummary>> folders;
@Singular List<String> tags;
public record LoreStructuralContext(
String loreName,
String loreDescription,
Map<String, List<PageSummary>> folders,
List<String> tags) {
/**
* Résumé projeté d'une page pour l'IA.
@@ -40,13 +35,11 @@ public class LoreStructuralContext {
* uniquement ce qui est partageable en narration — les secrets MJ
* restent confinés à leur page d'édition).
*/
@Value
@Builder
public static class PageSummary {
String title;
String templateName;
Map<String, String> values;
List<String> tags;
List<String> relatedPageTitles;
public record PageSummary(
String title,
String templateName,
Map<String, String> values,
List<String> tags,
List<String> relatedPageTitles) {
}
}

View File

@@ -1,8 +1,5 @@
package com.loremind.domain.generationcontext;
import lombok.Builder;
import lombok.Value;
import java.util.Map;
/**
@@ -17,13 +14,11 @@ import java.util.Map;
* `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é).
*
* @param entityType "arc", "chapter" ou "scene" — utilisé pour libeller le bloc du prompt.
*/
@Value
@Builder
public class NarrativeEntityContext {
/** "arc", "chapter" ou "scene" — utilisé pour libeller le bloc du prompt. */
String entityType;
String title;
Map<String, String> fields;
public record NarrativeEntityContext(
String entityType,
String title,
Map<String, String> fields) {
}

View File

@@ -1,8 +1,5 @@
package com.loremind.domain.generationcontext;
import lombok.Builder;
import lombok.Value;
import java.util.List;
import java.util.Map;
@@ -14,14 +11,11 @@ import java.util.Map;
* à l'IA de focaliser ses suggestions sur les bons champs sans déborder
* sur d'autres pages/templates.
* <p>
* Object de valeur immuable, pur domaine aucune dépendance technique.
* Record Java : immuable, pur domaine, aucune dépendance technique.
*/
@Value
@Builder
public class PageContext {
String title;
String templateName;
List<String> templateFields;
Map<String, String> values;
public record PageContext(
String title,
String templateName,
List<String> templateFields,
Map<String, String> values) {
}

View File

@@ -53,12 +53,12 @@ public class BrainAiClient implements AiProvider {
private BrainGeneratePageRequest toBrainRequest(GenerationContext context) {
return new BrainGeneratePageRequest(
context.getLoreName(),
context.getLoreDescription(),
context.getFolderName(),
context.getTemplateName(),
context.getTemplateFields(),
context.getPageTitle()
context.loreName(),
context.loreDescription(),
context.folderName(),
context.templateName(),
context.templateFields(),
context.pageTitle()
);
}

View File

@@ -38,35 +38,35 @@ public class BrainChatPayloadBuilder {
public Map<String, Object> build(ChatRequest request) {
Map<String, Object> root = new LinkedHashMap<>();
root.put("messages", request.getMessages().stream()
root.put("messages", request.messages().stream()
.map(this::messageToMap)
.collect(Collectors.toList()));
if (request.getLoreContext() != null) {
root.put("lore_context", loreContextToMap(request.getLoreContext()));
if (request.loreContext() != null) {
root.put("lore_context", loreContextToMap(request.loreContext()));
}
if (request.getPageContext() != null) {
root.put("page_context", pageContextToMap(request.getPageContext()));
if (request.pageContext() != null) {
root.put("page_context", pageContextToMap(request.pageContext()));
}
if (request.getCampaignContext() != null) {
root.put("campaign_context", campaignContextToMap(request.getCampaignContext()));
if (request.campaignContext() != null) {
root.put("campaign_context", campaignContextToMap(request.campaignContext()));
}
if (request.getNarrativeEntity() != null) {
root.put("narrative_entity", narrativeEntityToMap(request.getNarrativeEntity()));
if (request.narrativeEntity() != null) {
root.put("narrative_entity", narrativeEntityToMap(request.narrativeEntity()));
}
if (request.getGameSystemContext() != null) {
root.put("game_system_context", gameSystemContextToMap(request.getGameSystemContext()));
if (request.gameSystemContext() != null) {
root.put("game_system_context", gameSystemContextToMap(request.gameSystemContext()));
}
return root;
}
private Map<String, Object> gameSystemContextToMap(GameSystemContext gs) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("system_name", gs.getSystemName());
if (gs.getSystemDescription() != null && !gs.getSystemDescription().isBlank()) {
map.put("system_description", gs.getSystemDescription());
map.put("system_name", gs.systemName());
if (gs.systemDescription() != null && !gs.systemDescription().isBlank()) {
map.put("system_description", gs.systemDescription());
}
map.put("sections", gs.getSections() != null ? gs.getSections() : Map.of());
map.put("sections", gs.sections() != null ? gs.sections() : Map.of());
return map;
}
@@ -79,56 +79,56 @@ public class BrainChatPayloadBuilder {
private Map<String, Object> loreContextToMap(LoreStructuralContext ctx) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("lore_name", ctx.getLoreName());
map.put("lore_description", ctx.getLoreDescription());
map.put("lore_name", ctx.loreName());
map.put("lore_description", ctx.loreDescription());
Map<String, Object> foldersMap = new LinkedHashMap<>();
for (Map.Entry<String, List<PageSummary>> e : ctx.getFolders().entrySet()) {
for (Map.Entry<String, List<PageSummary>> e : ctx.folders().entrySet()) {
foldersMap.put(e.getKey(), e.getValue().stream()
.map(this::pageSummaryToMap)
.collect(Collectors.toList()));
}
map.put("folders", foldersMap);
map.put("tags", ctx.getTags());
map.put("tags", ctx.tags());
return map;
}
private Map<String, Object> pageSummaryToMap(PageSummary ps) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("title", ps.getTitle());
map.put("template_name", ps.getTemplateName());
map.put("title", ps.title());
map.put("template_name", ps.templateName());
// values/tags/related_page_titles : omis si vides pour alléger le payload.
if (ps.getValues() != null && !ps.getValues().isEmpty()) {
map.put("values", ps.getValues());
if (ps.values() != null && !ps.values().isEmpty()) {
map.put("values", ps.values());
}
if (ps.getTags() != null && !ps.getTags().isEmpty()) {
map.put("tags", ps.getTags());
if (ps.tags() != null && !ps.tags().isEmpty()) {
map.put("tags", ps.tags());
}
if (ps.getRelatedPageTitles() != null && !ps.getRelatedPageTitles().isEmpty()) {
map.put("related_page_titles", ps.getRelatedPageTitles());
if (ps.relatedPageTitles() != null && !ps.relatedPageTitles().isEmpty()) {
map.put("related_page_titles", ps.relatedPageTitles());
}
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());
map.put("title", pc.title());
map.put("template_name", pc.templateName());
map.put("template_fields", pc.templateFields());
map.put("values", pc.values());
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.put("campaign_name", ctx.campaignName());
map.put("campaign_description", ctx.campaignDescription());
map.put("arcs", ctx.arcs().stream()
.map(this::arcSummaryToMap)
.collect(Collectors.toList()));
// Liste des PJ : omise si aucun pour alléger le prompt des campagnes sans fiches.
if (ctx.getCharacters() != null && !ctx.getCharacters().isEmpty()) {
map.put("characters", ctx.getCharacters().stream()
if (ctx.characters() != null && !ctx.characters().isEmpty()) {
map.put("characters", ctx.characters().stream()
.map(this::characterSummaryToMap)
.collect(Collectors.toList()));
}
@@ -137,9 +137,9 @@ public class BrainChatPayloadBuilder {
private Map<String, Object> characterSummaryToMap(CharacterSummary c) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("name", c.getName());
if (c.getSnippet() != null && !c.getSnippet().isBlank()) {
map.put("snippet", c.getSnippet());
map.put("name", c.name());
if (c.snippet() != null && !c.snippet().isBlank()) {
map.put("snippet", c.snippet());
}
return map;
}
@@ -167,10 +167,10 @@ public class BrainChatPayloadBuilder {
private Map<String, Object> arcSummaryToMap(ArcSummary a) {
return structuralSummaryToMap(
a,
ArcSummary::getName,
ArcSummary::getDescription,
ArcSummary::getIllustrationCount,
(map, arc) -> map.put("chapters", arc.getChapters().stream()
ArcSummary::name,
ArcSummary::description,
ArcSummary::illustrationCount,
(map, arc) -> map.put("chapters", arc.chapters().stream()
.map(this::chapterSummaryToMap)
.collect(Collectors.toList())));
}
@@ -178,10 +178,10 @@ public class BrainChatPayloadBuilder {
private Map<String, Object> chapterSummaryToMap(ChapterSummary c) {
return structuralSummaryToMap(
c,
ChapterSummary::getName,
ChapterSummary::getDescription,
ChapterSummary::getIllustrationCount,
(map, chapter) -> map.put("scenes", chapter.getScenes().stream()
ChapterSummary::name,
ChapterSummary::description,
ChapterSummary::illustrationCount,
(map, chapter) -> map.put("scenes", chapter.scenes().stream()
.map(this::sceneSummaryToMap)
.collect(Collectors.toList())));
}
@@ -189,13 +189,13 @@ public class BrainChatPayloadBuilder {
private Map<String, Object> sceneSummaryToMap(SceneSummary s) {
return structuralSummaryToMap(
s,
SceneSummary::getName,
SceneSummary::getDescription,
SceneSummary::getIllustrationCount,
SceneSummary::name,
SceneSummary::description,
SceneSummary::illustrationCount,
(map, scene) -> {
// Branches narratives : omises si absentes (scènes linéaires classiques).
if (s.getBranches() != null && !s.getBranches().isEmpty()) {
map.put("branches", s.getBranches().stream()
if (s.branches() != null && !s.branches().isEmpty()) {
map.put("branches", s.branches().stream()
.map(this::branchHintToMap)
.collect(Collectors.toList()));
}
@@ -204,19 +204,19 @@ public class BrainChatPayloadBuilder {
private Map<String, Object> branchHintToMap(BranchHint b) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("label", b.getLabel());
map.put("target_scene_name", b.getTargetSceneName());
if (b.getCondition() != null && !b.getCondition().isBlank()) {
map.put("condition", b.getCondition());
map.put("label", b.label());
map.put("target_scene_name", b.targetSceneName());
if (b.condition() != null && !b.condition().isBlank()) {
map.put("condition", b.condition());
}
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());
map.put("entity_type", ne.entityType());
map.put("title", ne.title());
map.put("fields", ne.fields());
return map;
}
}

View File

@@ -0,0 +1,283 @@
package com.loremind.infrastructure.updates;
import jakarta.annotation.PostConstruct;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.client.RestTemplate;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* Detection des mises a jour disponibles + declenchement via Watchtower.
*
* Strategie :
* - Au demarrage, on interroge le registry pour le digest courant de chaque
* image suivie ({@code update-check.images}). On stocke ces digests comme
* "baseline" (= ce que le conteneur en cours d'execution est cense faire
* tourner, puisque le `docker compose pull` precede toujours `up -d`).
* - {@link #check()} re-interroge le registry et compare. Si un digest a
* change, une mise a jour est disponible.
* - {@link #apply()} POST sur /v1/update de Watchtower (qui doit etre lance
* avec WATCHTOWER_HTTP_API_UPDATE=true et le meme token).
*
* Apres un apply reussi, Watchtower redemarre core => ce service est
* re-instancie => baseline re-aligne sur le registry => check renvoie
* "pas de MAJ" (etat coherent).
*
* La feature est <b>desactivee silencieusement</b> si {@code WATCHTOWER_TOKEN}
* n'est pas defini : check/apply renvoient des reponses neutres et l'UI
* masque le badge / bouton.
*/
@Service
public class UpdateCheckService {
private static final Logger log = LoggerFactory.getLogger(UpdateCheckService.class);
private static final List<MediaType> MANIFEST_ACCEPT = List.of(
MediaType.parseMediaType("application/vnd.docker.distribution.manifest.v2+json"),
MediaType.parseMediaType("application/vnd.docker.distribution.manifest.list.v2+json"),
MediaType.parseMediaType("application/vnd.oci.image.manifest.v1+json"),
MediaType.parseMediaType("application/vnd.oci.image.index.v1+json")
);
private final RestTemplate http;
private final String registry;
private final List<String> images;
private final String tag;
private final String watchtowerUrl;
private final String watchtowerToken;
private final Map<String, String> baselineDigests = new ConcurrentHashMap<>();
public UpdateCheckService(
RestTemplateBuilder builder,
@Value("${update-check.registry:}") String registry,
@Value("${update-check.images:}") String imagesCsv,
@Value("${update-check.tag:latest}") String tag,
@Value("${update-check.watchtower-url:http://watchtower:8080}") String watchtowerUrl,
@Value("${update-check.watchtower-token:}") String watchtowerToken) {
this.http = builder
.setConnectTimeout(Duration.ofSeconds(5))
.setReadTimeout(Duration.ofSeconds(15))
.build();
this.registry = normalizeRegistry(registry);
this.images = parseImages(imagesCsv);
this.tag = tag;
this.watchtowerUrl = watchtowerUrl;
this.watchtowerToken = watchtowerToken;
}
@PostConstruct
void initBaseline() {
if (!isEnabled()) {
log.info("Update check disabled (WATCHTOWER_TOKEN not set)");
return;
}
log.info("Update check enabled - registry={} images={} tag={}", registry, images, tag);
for (String image : images) {
try {
String digest = fetchRemoteDigest(image);
if (digest != null) {
baselineDigests.put(image, digest);
log.debug("Baseline digest for {} = {}", image, digest);
}
} catch (Exception e) {
log.warn("Cannot baseline digest for {}: {}", image, e.getMessage());
}
}
}
public boolean isEnabled() {
return watchtowerToken != null && !watchtowerToken.isBlank() && !images.isEmpty();
}
public UpdateStatus check() {
if (!isEnabled()) {
return new UpdateStatus(false, false, List.of(), Instant.now());
}
List<ImageStatus> statuses = new ArrayList<>();
boolean anyUpdate = false;
for (String image : images) {
String baseline = baselineDigests.get(image);
String remote = null;
try {
remote = fetchRemoteDigest(image);
} catch (Exception e) {
log.warn("Check failed for {}: {}", image, e.getMessage());
}
// Si on n'a pas de baseline (echec au boot), on l'aligne maintenant
// pour eviter un faux positif "MAJ dispo".
if (baseline == null && remote != null) {
baselineDigests.put(image, remote);
baseline = remote;
}
boolean updateAvailable = baseline != null && remote != null && !baseline.equals(remote);
if (updateAvailable) anyUpdate = true;
statuses.add(new ImageStatus(image, baseline, remote, updateAvailable));
}
return new UpdateStatus(true, anyUpdate, statuses, Instant.now());
}
public void apply() {
if (!isEnabled()) {
throw new IllegalStateException("Update apply not configured (WATCHTOWER_TOKEN missing)");
}
HttpHeaders headers = new HttpHeaders();
headers.setBearerAuth(watchtowerToken);
// Watchtower /v1/update declenche un scan+update immediat de tous les
// conteneurs labellises. La reponse est synchrone et peut prendre
// plusieurs secondes; en cas de redemarrage de core, le client
// recevra une connexion coupee — c'est attendu, l'UI le gere.
http.exchange(
watchtowerUrl + "/v1/update",
HttpMethod.POST,
new HttpEntity<>(headers),
Void.class);
}
// -----------------------------------------------------------------------
// Registry HTTP API v2
// -----------------------------------------------------------------------
private String fetchRemoteDigest(String image) {
String url = registry + "/v2/" + image + "/manifests/" + tag;
HttpHeaders headers = new HttpHeaders();
headers.setAccept(MANIFEST_ACCEPT);
try {
return digestCall(url, headers);
} catch (HttpClientErrorException.Unauthorized e) {
String www = e.getResponseHeaders() == null ? null
: e.getResponseHeaders().getFirst(HttpHeaders.WWW_AUTHENTICATE);
String token = obtainBearerToken(www);
if (token == null) {
log.warn("Cannot obtain bearer token for {} (registry response: {})", image, www);
return null;
}
headers.setBearerAuth(token);
return digestCall(url, headers);
}
}
private String digestCall(String url, HttpHeaders headers) {
ResponseEntity<Void> resp = http.exchange(
url, HttpMethod.HEAD, new HttpEntity<>(headers), Void.class);
return resp.getHeaders().getFirst("Docker-Content-Digest");
}
/**
* Suit le challenge {@code WWW-Authenticate: Bearer realm="...",service="...",scope="..."}
* pour obtenir un jeton (anonyme — suffisant pour les images publiques).
*/
@SuppressWarnings("rawtypes")
private String obtainBearerToken(String wwwAuth) {
if (wwwAuth == null) return null;
String prefix = "Bearer ";
if (!wwwAuth.regionMatches(true, 0, prefix, 0, prefix.length())) return null;
Map<String, String> params = parseAuthParams(wwwAuth.substring(prefix.length()));
String realm = params.get("realm");
if (realm == null) return null;
StringBuilder url = new StringBuilder(realm);
boolean hasQuery = realm.contains("?");
for (String key : new String[]{"service", "scope"}) {
String v = params.get(key);
if (v != null) {
url.append(hasQuery ? '&' : '?')
.append(key).append('=')
.append(URLEncoder.encode(v, StandardCharsets.UTF_8));
hasQuery = true;
}
}
try {
ResponseEntity<Map> resp = http.getForEntity(url.toString(), Map.class);
Map<?, ?> body = resp.getBody();
if (body == null) return null;
Object t = body.get("token");
if (t == null) t = body.get("access_token");
return t == null ? null : t.toString();
} catch (Exception e) {
log.warn("Bearer token request failed: {}", e.getMessage());
return null;
}
}
/** Parser minimaliste pour {@code key="value", key2="value2"}. */
private static Map<String, String> parseAuthParams(String s) {
Map<String, String> out = new HashMap<>();
int i = 0;
int n = s.length();
while (i < n) {
while (i < n && (s.charAt(i) == ',' || s.charAt(i) == ' ')) i++;
int eq = s.indexOf('=', i);
if (eq < 0) break;
String key = s.substring(i, eq).trim();
int valStart = eq + 1;
String val;
if (valStart < n && s.charAt(valStart) == '"') {
int valEnd = s.indexOf('"', valStart + 1);
if (valEnd < 0) break;
val = s.substring(valStart + 1, valEnd);
i = valEnd + 1;
} else {
int valEnd = s.indexOf(',', valStart);
if (valEnd < 0) valEnd = n;
val = s.substring(valStart, valEnd).trim();
i = valEnd;
}
out.put(key, val);
}
return out;
}
private static String normalizeRegistry(String value) {
if (value == null || value.isBlank()) return "";
String v = value.trim();
if (!v.startsWith("http://") && !v.startsWith("https://")) {
v = "https://" + v;
}
if (v.endsWith("/")) v = v.substring(0, v.length() - 1);
return v;
}
private static List<String> parseImages(String csv) {
if (csv == null || csv.isBlank()) return List.of();
List<String> out = new ArrayList<>();
for (String part : csv.split(",")) {
String p = part.trim();
if (!p.isEmpty()) out.add(p);
}
return out;
}
// -----------------------------------------------------------------------
// Records de retour (sortis sous forme JSON par Jackson)
// -----------------------------------------------------------------------
public record UpdateStatus(
boolean enabled,
boolean updateAvailable,
List<ImageStatus> images,
Instant checkedAt) {}
public record ImageStatus(
String image,
String localDigest,
String remoteDigest,
boolean updateAvailable) {}
}

View File

@@ -66,6 +66,7 @@ public class SecurityConfig {
// Preflight CORS toujours libre (le browser n'envoie pas Authorization sur OPTIONS)
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
.requestMatchers("/api/settings/**").hasRole("ADMIN")
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().permitAll()
)
.httpBasic(basic -> {});

View File

@@ -1,5 +1,6 @@
package com.loremind.infrastructure.web.controller;
import com.loremind.infrastructure.updates.UpdateCheckService;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
@@ -18,13 +19,18 @@ import java.util.Map;
public class ConfigController {
private final boolean demoMode;
private final UpdateCheckService updates;
public ConfigController(@Value("${app.demo-mode:false}") boolean demoMode) {
public ConfigController(@Value("${app.demo-mode:false}") boolean demoMode,
UpdateCheckService updates) {
this.demoMode = demoMode;
this.updates = updates;
}
@GetMapping
public Map<String, Object> getPublicConfig() {
return Map.of("demoMode", demoMode);
return Map.of(
"demoMode", demoMode,
"updateCheckEnabled", updates.isEnabled());
}
}

View File

@@ -0,0 +1,76 @@
package com.loremind.infrastructure.web.controller;
import com.loremind.infrastructure.updates.UpdateCheckService;
import com.loremind.infrastructure.updates.UpdateCheckService.UpdateStatus;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import java.util.Map;
/**
* Endpoints admin pour la verification et le declenchement des mises a jour
* des conteneurs LoreMind (core/brain/web).
*
* Protege par HTTP Basic via SecurityConfig (path /api/admin/**).
* Si la feature n'est pas configuree (WATCHTOWER_TOKEN vide), check renvoie
* {enabled:false} et apply repond 503.
*/
@RestController
@RequestMapping("/api/admin/updates")
public class UpdatesController {
private static final Logger log = LoggerFactory.getLogger(UpdatesController.class);
private final UpdateCheckService updates;
private final boolean demoMode;
public UpdatesController(UpdateCheckService updates,
@Value("${app.demo-mode:false}") boolean demoMode) {
this.updates = updates;
this.demoMode = demoMode;
}
@GetMapping("/check")
public UpdateStatus check() {
guardDemoMode();
return updates.check();
}
@PostMapping("/apply")
public ResponseEntity<Map<String, Object>> apply() {
guardDemoMode();
if (!updates.isEnabled()) {
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
.body(Map.of("error", "Update apply not configured"));
}
try {
updates.apply();
return ResponseEntity.accepted()
.body(Map.of("status", "triggered",
"message", "Watchtower va telecharger et redemarrer les conteneurs."));
} catch (Exception e) {
log.error("Apply update failed", e);
return ResponseEntity.status(HttpStatus.BAD_GATEWAY)
.body(Map.of("error", "Watchtower unreachable: " + e.getMessage()));
}
}
/**
* En mode demo, les instances ne doivent pas se mettre a jour ni meme
* exposer leur statut (pas de surface d'attaque, et pas de redemarrage
* intempestif d'une demo en cours). Cohérent avec SettingsController.
*/
private void guardDemoMode() {
if (demoMode) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Updates disabled in demo mode");
}
}
}

View File

@@ -87,18 +87,14 @@ public class SceneMapper {
private List<SceneBranchDTO> toBranchDTOs(List<SceneBranch> branches) {
if (branches == null) return new ArrayList<>();
return branches.stream()
.map(b -> new SceneBranchDTO(b.getLabel(), b.getTargetSceneId(), b.getCondition()))
.map(b -> new SceneBranchDTO(b.label(), b.targetSceneId(), b.condition()))
.collect(Collectors.toList());
}
private List<SceneBranch> toBranchDomain(List<SceneBranchDTO> dtos) {
if (dtos == null) return new ArrayList<>();
return dtos.stream()
.map(d -> SceneBranch.builder()
.label(d.getLabel())
.targetSceneId(d.getTargetSceneId())
.condition(d.getCondition())
.build())
.map(d -> new SceneBranch(d.getLabel(), d.getTargetSceneId(), d.getCondition()))
.collect(Collectors.toList());
}
}

View File

@@ -54,3 +54,11 @@ spring.servlet.multipart.max-request-size=10MB
# Mode demo : masque Settings/Export cote front et bloque les PUT /api/settings
# cote serveur. Activer via DEMO_MODE=true sur les deploiements publics.
app.demo-mode=${DEMO_MODE:false}
# Detection des mises a jour des conteneurs Docker (registry HTTP API + Watchtower).
# Feature desactivee si UPDATE_CHECK_WATCHTOWER_TOKEN est vide.
update-check.registry=${UPDATE_CHECK_REGISTRY:}
update-check.images=${UPDATE_CHECK_IMAGES:}
update-check.tag=${UPDATE_CHECK_TAG:latest}
update-check.watchtower-url=${WATCHTOWER_URL:http://watchtower:8080}
update-check.watchtower-token=${WATCHTOWER_TOKEN:}

View File

@@ -178,10 +178,7 @@ public class SceneServiceTest {
@Test
void testUpdateScene_WithValidBranches() {
// Arrange
SceneBranch branch = SceneBranch.builder()
.targetSceneId("scene-2")
.label("Go to scene 2")
.build();
SceneBranch branch = SceneBranch.of("Go to scene 2", "scene-2");
Scene updatedScene = Scene.builder()
.name("Updated Scene")
.branches(List.of(branch))
@@ -203,10 +200,7 @@ public class SceneServiceTest {
@Test
void testUpdateScene_WithBranchToSelf() {
// Arrange
SceneBranch branch = SceneBranch.builder()
.targetSceneId("scene-1")
.label("Self-reference")
.build();
SceneBranch branch = SceneBranch.of("Self-reference", "scene-1");
Scene updatedScene = Scene.builder()
.name("Updated Scene")
.branches(List.of(branch))
@@ -228,10 +222,7 @@ public class SceneServiceTest {
@Test
void testUpdateScene_WithBranchToDifferentChapter() {
// Arrange
SceneBranch branch = SceneBranch.builder()
.targetSceneId("scene-other-chapter")
.label("Go to other chapter")
.build();
SceneBranch branch = SceneBranch.of("Go to other chapter", "scene-other-chapter");
Scene updatedScene = Scene.builder()
.name("Updated Scene")
.branches(List.of(branch))
@@ -253,10 +244,7 @@ public class SceneServiceTest {
@Test
void testUpdateScene_WithBranchNullTarget() {
// Arrange
SceneBranch branch = SceneBranch.builder()
.targetSceneId(null)
.label("Null target")
.build();
SceneBranch branch = SceneBranch.of("Null target", null);
Scene updatedScene = Scene.builder()
.name("Updated Scene")
.branches(List.of(branch))
@@ -277,10 +265,7 @@ public class SceneServiceTest {
@Test
void testUpdateScene_WithBranchBlankTarget() {
// Arrange
SceneBranch branch = SceneBranch.builder()
.targetSceneId(" ")
.label("Blank target")
.build();
SceneBranch branch = SceneBranch.of("Blank target", " ");
Scene updatedScene = Scene.builder()
.name("Updated Scene")
.branches(List.of(branch))

View File

@@ -74,9 +74,9 @@ public class CampaignStructuralContextBuilderTest {
CampaignStructuralContext ctx = builder.build("camp-1");
assertEquals("Les Terres Brisées", ctx.getCampaignName());
assertEquals("Campagne dark fantasy", ctx.getCampaignDescription());
assertTrue(ctx.getArcs().isEmpty());
assertEquals("Les Terres Brisées", ctx.campaignName());
assertEquals("Campagne dark fantasy", ctx.campaignDescription());
assertTrue(ctx.arcs().isEmpty());
}
@Test
@@ -100,19 +100,19 @@ public class CampaignStructuralContextBuilderTest {
CampaignStructuralContext ctx = builder.build("camp-1");
assertEquals(2, ctx.getArcs().size());
assertEquals("Arc A", ctx.getArcs().get(0).getName());
assertEquals("Arc B", ctx.getArcs().get(1).getName());
assertEquals(2, ctx.arcs().size());
assertEquals("Arc A", ctx.arcs().get(0).name());
assertEquals("Arc B", ctx.arcs().get(1).name());
// Chapitres tries : ch2 (order 1) avant ch1 (order 2)
assertEquals(2, ctx.getArcs().get(0).getChapters().size());
assertEquals("Ch B", ctx.getArcs().get(0).getChapters().get(0).getName());
assertEquals("Ch A", ctx.getArcs().get(0).getChapters().get(1).getName());
assertEquals(2, ctx.arcs().get(0).chapters().size());
assertEquals("Ch B", ctx.arcs().get(0).chapters().get(0).name());
assertEquals("Ch A", ctx.arcs().get(0).chapters().get(1).name());
// Scenes dans ch-1 : s2 (order 1) avant s1 (order 2)
var chADto = ctx.getArcs().get(0).getChapters().get(1);
assertEquals("Scene B", chADto.getScenes().get(0).getName());
assertEquals("Scene A", chADto.getScenes().get(1).getName());
var chADto = ctx.arcs().get(0).chapters().get(1);
assertEquals("Scene B", chADto.scenes().get(0).name());
assertEquals("Scene A", chADto.scenes().get(1).name());
}
@Test
@@ -120,15 +120,8 @@ public class CampaignStructuralContextBuilderTest {
Arc arc = Arc.builder().id("arc-1").name("Arc").description("").order(1).build();
Chapter ch = Chapter.builder().id("ch-1").arcId("arc-1").name("Ch").description("").order(1).build();
SceneBranch validBranch = SceneBranch.builder()
.label("Si les joueurs fuient")
.targetSceneId("s-2")
.condition("en cas de combat perdu")
.build();
SceneBranch danglingBranch = SceneBranch.builder()
.label("Vers l'inconnu")
.targetSceneId("s-inconnu")
.build();
SceneBranch validBranch = new SceneBranch("Si les joueurs fuient", "s-2", "en cas de combat perdu");
SceneBranch danglingBranch = SceneBranch.of("Vers l'inconnu", "s-inconnu");
Scene s1 = Scene.builder().id("s-1").chapterId("ch-1").name("Entrée").description("")
.order(1)
@@ -143,12 +136,12 @@ public class CampaignStructuralContextBuilderTest {
CampaignStructuralContext ctx = builder.build("camp-1");
var scene1Summary = ctx.getArcs().get(0).getChapters().get(0).getScenes().get(0);
assertEquals(2, scene1Summary.getBranches().size());
assertEquals("Fuite", scene1Summary.getBranches().get(0).getTargetSceneName());
assertEquals("en cas de combat perdu", scene1Summary.getBranches().get(0).getCondition());
var scene1Summary = ctx.arcs().get(0).chapters().get(0).scenes().get(0);
assertEquals(2, scene1Summary.branches().size());
assertEquals("Fuite", scene1Summary.branches().get(0).targetSceneName());
assertEquals("en cas de combat perdu", scene1Summary.branches().get(0).condition());
// ID inconnu → libellé de fallback
assertEquals("(scène inconnue)", scene1Summary.getBranches().get(1).getTargetSceneName());
assertEquals("(scène inconnue)", scene1Summary.branches().get(1).targetSceneName());
}
@Test
@@ -170,9 +163,9 @@ public class CampaignStructuralContextBuilderTest {
CampaignStructuralContext ctx = builder.build("camp-1");
assertEquals(2, ctx.getArcs().get(0).getIllustrationCount());
assertEquals(0, ctx.getArcs().get(0).getChapters().get(0).getIllustrationCount());
assertEquals(1, ctx.getArcs().get(0).getChapters().get(0).getScenes().get(0).getIllustrationCount());
assertTrue(ctx.getArcs().get(0).getChapters().get(0).getScenes().get(0).getBranches().isEmpty());
assertEquals(2, ctx.arcs().get(0).illustrationCount());
assertEquals(0, ctx.arcs().get(0).chapters().get(0).illustrationCount());
assertEquals(1, ctx.arcs().get(0).chapters().get(0).scenes().get(0).illustrationCount());
assertTrue(ctx.arcs().get(0).chapters().get(0).scenes().get(0).branches().isEmpty());
}
}

View File

@@ -89,13 +89,13 @@ public class GeneratePageValuesUseCaseTest {
verify(aiProvider).generatePage(captor.capture());
GenerationContext ctx = captor.getValue();
assertEquals("Aetheria", ctx.getLoreName());
assertEquals("monde aérien", ctx.getLoreDescription());
assertEquals("PNJ", ctx.getFolderName());
assertEquals("Personnage", ctx.getTemplateName());
assertEquals("Alice", ctx.getPageTitle());
assertEquals("Aetheria", ctx.loreName());
assertEquals("monde aérien", ctx.loreDescription());
assertEquals("PNJ", ctx.folderName());
assertEquals("Personnage", ctx.templateName());
assertEquals("Alice", ctx.pageTitle());
// Seuls les champs TEXT doivent etre envoyes (pas "Portrait" IMAGE).
assertEquals(List.of("Histoire", "Apparence"), ctx.getTemplateFields());
assertEquals(List.of("Histoire", "Apparence"), ctx.templateFields());
}
@Test

View File

@@ -71,10 +71,10 @@ public class LoreStructuralContextBuilderTest {
LoreStructuralContext ctx = builder.build("lore-1");
assertEquals("Aetheria", ctx.getLoreName());
assertEquals("Monde aérien", ctx.getLoreDescription());
assertTrue(ctx.getFolders().isEmpty());
assertTrue(ctx.getTags().isEmpty());
assertEquals("Aetheria", ctx.loreName());
assertEquals("Monde aérien", ctx.loreDescription());
assertTrue(ctx.folders().isEmpty());
assertTrue(ctx.tags().isEmpty());
}
@Test
@@ -110,32 +110,32 @@ public class LoreStructuralContextBuilderTest {
LoreStructuralContext ctx = builder.build("lore-1");
assertEquals(2, ctx.getFolders().size());
assertTrue(ctx.getFolders().containsKey("PNJ"));
assertTrue(ctx.getFolders().containsKey("Lieux"));
assertEquals(2, ctx.folders().size());
assertTrue(ctx.folders().containsKey("PNJ"));
assertTrue(ctx.folders().containsKey("Lieux"));
var pnjPages = ctx.getFolders().get("PNJ");
var pnjPages = ctx.folders().get("PNJ");
assertEquals(1, pnjPages.size());
var aliceSummary = pnjPages.get(0);
assertEquals("Alice", aliceSummary.getTitle());
assertEquals("Personnage", aliceSummary.getTemplateName());
assertEquals("Alice", aliceSummary.title());
assertEquals("Personnage", aliceSummary.templateName());
// Blank/null filtrés
assertEquals(1, aliceSummary.getValues().size());
assertEquals("Il était une fois...", aliceSummary.getValues().get("Histoire"));
assertEquals(List.of("hero", "magic"), aliceSummary.getTags());
assertEquals(1, aliceSummary.values().size());
assertEquals("Il était une fois...", aliceSummary.values().get("Histoire"));
assertEquals(List.of("hero", "magic"), aliceSummary.tags());
// p-2 resolved into title, p-ghost dropped silently
assertEquals(List.of("La Forêt"), aliceSummary.getRelatedPageTitles());
assertEquals(List.of("La Forêt"), aliceSummary.relatedPageTitles());
var forestSummary = ctx.getFolders().get("Lieux").get(0);
var forestSummary = ctx.folders().get("Lieux").get(0);
// Template introuvable → "?"
assertEquals("?", forestSummary.getTemplateName());
assertTrue(forestSummary.getValues().isEmpty());
assertTrue(forestSummary.getRelatedPageTitles().isEmpty());
assertEquals("?", forestSummary.templateName());
assertTrue(forestSummary.values().isEmpty());
assertTrue(forestSummary.relatedPageTitles().isEmpty());
// Tags uniques entre les 2 pages
assertEquals(2, ctx.getTags().size());
assertTrue(ctx.getTags().contains("hero"));
assertTrue(ctx.getTags().contains("magic"));
assertEquals(2, ctx.tags().size());
assertTrue(ctx.tags().contains("hero"));
assertTrue(ctx.tags().contains("magic"));
}
@Test
@@ -160,7 +160,7 @@ public class LoreStructuralContextBuilderTest {
LoreStructuralContext ctx = builder.build("lore-1");
String truncated = ctx.getFolders().get("PNJ").get(0).getValues().get("Histoire");
String truncated = ctx.folders().get("PNJ").get(0).values().get("Histoire");
assertNotNull(truncated);
assertEquals(500 + 1, truncated.length()); // 500 + ellipse
assertTrue(truncated.endsWith(""));
@@ -185,9 +185,9 @@ public class LoreStructuralContextBuilderTest {
LoreStructuralContext ctx = builder.build("lore-1");
var summary = ctx.getFolders().get("PNJ").get(0);
assertTrue(summary.getValues().isEmpty());
assertTrue(summary.getTags().isEmpty());
assertTrue(summary.getRelatedPageTitles().isEmpty());
var summary = ctx.folders().get("PNJ").get(0);
assertTrue(summary.values().isEmpty());
assertTrue(summary.tags().isEmpty());
assertTrue(summary.relatedPageTitles().isEmpty());
}
}

View File

@@ -44,14 +44,14 @@ public class NarrativeEntityContextBuilderTest {
NarrativeEntityContext ctx = builder.build("arc", "arc-1");
assertEquals("arc", ctx.getEntityType());
assertEquals("L'arc sombre", ctx.getTitle());
assertEquals("synopsis", ctx.getFields().get("description (synopsis)"));
assertEquals("trahison", ctx.getFields().get("themes"));
assertEquals("vie ou mort", ctx.getFields().get("stakes"));
assertEquals("pouvoir", ctx.getFields().get("rewards"));
assertEquals("le roi meurt", ctx.getFields().get("resolution"));
assertEquals("secret", ctx.getFields().get("gmNotes"));
assertEquals("arc", ctx.entityType());
assertEquals("L'arc sombre", ctx.title());
assertEquals("synopsis", ctx.fields().get("description (synopsis)"));
assertEquals("trahison", ctx.fields().get("themes"));
assertEquals("vie ou mort", ctx.fields().get("stakes"));
assertEquals("pouvoir", ctx.fields().get("rewards"));
assertEquals("le roi meurt", ctx.fields().get("resolution"));
assertEquals("secret", ctx.fields().get("gmNotes"));
}
@Test
@@ -64,12 +64,12 @@ public class NarrativeEntityContextBuilderTest {
NarrativeEntityContext ctx = builder.build("chapter", "ch-1");
assertEquals("chapter", ctx.getEntityType());
assertEquals("Chapitre 1", ctx.getTitle());
assertEquals("", ctx.getFields().get("description (synopsis)"));
assertEquals("", ctx.getFields().get("playerObjectives"));
assertEquals("haut", ctx.getFields().get("narrativeStakes"));
assertEquals("", ctx.getFields().get("gmNotes"));
assertEquals("chapter", ctx.entityType());
assertEquals("Chapitre 1", ctx.title());
assertEquals("", ctx.fields().get("description (synopsis)"));
assertEquals("", ctx.fields().get("playerObjectives"));
assertEquals("haut", ctx.fields().get("narrativeStakes"));
assertEquals("", ctx.fields().get("gmNotes"));
}
@Test
@@ -85,17 +85,17 @@ public class NarrativeEntityContextBuilderTest {
NarrativeEntityContext ctx = builder.build("scene", "s-1");
assertEquals("scene", ctx.getEntityType());
assertEquals("L'auberge", ctx.getTitle());
assertEquals("lieu calme", ctx.getFields().get("description"));
assertEquals("Taverne", ctx.getFields().get("location"));
assertEquals("Soir", ctx.getFields().get("timing"));
assertEquals("tendue", ctx.getFields().get("atmosphere"));
assertEquals("Vous entrez...", ctx.getFields().get("playerNarration"));
assertEquals("option A...", ctx.getFields().get("choicesConsequences"));
assertEquals("moyen", ctx.getFields().get("combatDifficulty"));
assertEquals("3 bandits", ctx.getFields().get("enemies"));
assertEquals("trésor caché", ctx.getFields().get("gmSecretNotes"));
assertEquals("scene", ctx.entityType());
assertEquals("L'auberge", ctx.title());
assertEquals("lieu calme", ctx.fields().get("description"));
assertEquals("Taverne", ctx.fields().get("location"));
assertEquals("Soir", ctx.fields().get("timing"));
assertEquals("tendue", ctx.fields().get("atmosphere"));
assertEquals("Vous entrez...", ctx.fields().get("playerNarration"));
assertEquals("option A...", ctx.fields().get("choicesConsequences"));
assertEquals("moyen", ctx.fields().get("combatDifficulty"));
assertEquals("3 bandits", ctx.fields().get("enemies"));
assertEquals("trésor caché", ctx.fields().get("gmSecretNotes"));
}
@Test
@@ -104,7 +104,7 @@ public class NarrativeEntityContextBuilderTest {
when(arcRepository.findById("arc-1")).thenReturn(Optional.of(arc));
NarrativeEntityContext ctx = builder.build(" ARC ", "arc-1");
assertEquals("arc", ctx.getEntityType());
assertEquals("arc", ctx.entityType());
}
@Test

View File

@@ -55,9 +55,7 @@ public class StreamChatForCampaignUseCaseTest {
@SuppressWarnings("unchecked")
@BeforeEach
void setUp() {
campaignCtx = CampaignStructuralContext.builder()
.campaignName("X").campaignDescription("d")
.build();
campaignCtx = new CampaignStructuralContext("X", "d", List.of(), List.of());
messages = List.of();
onUsage = mock(Consumer.class);
onToken = mock(Consumer.class);
@@ -85,10 +83,10 @@ public class StreamChatForCampaignUseCaseTest {
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
verify(aiChatProvider).streamChat(captor.capture(), eq(onUsage), eq(onToken), eq(onComplete), eq(onError));
ChatRequest req = captor.getValue();
assertSame(campaignCtx, req.getCampaignContext());
assertNull(req.getLoreContext());
assertNull(req.getNarrativeEntity());
assertNull(req.getPageContext());
assertSame(campaignCtx, req.campaignContext());
assertNull(req.loreContext());
assertNull(req.narrativeEntity());
assertNull(req.pageContext());
verifyNoInteractions(loreContextBuilder);
verifyNoInteractions(narrativeEntityContextBuilder);
}
@@ -96,8 +94,8 @@ public class StreamChatForCampaignUseCaseTest {
@Test
void testExecute_LinkedCampaign_LoadsLoreContext() {
Campaign linked = Campaign.builder().id("c-1").name("C").loreId("lore-1").build();
LoreStructuralContext loreCtx = LoreStructuralContext.builder()
.loreName("L").loreDescription("d").folders(Collections.emptyMap()).build();
LoreStructuralContext loreCtx = new LoreStructuralContext(
"L", "d", Collections.emptyMap(), List.of());
when(campaignRepository.findById("c-1")).thenReturn(Optional.of(linked));
when(campaignContextBuilder.build("c-1")).thenReturn(campaignCtx);
@@ -107,7 +105,7 @@ public class StreamChatForCampaignUseCaseTest {
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any(), any());
assertSame(loreCtx, captor.getValue().getLoreContext());
assertSame(loreCtx, captor.getValue().loreContext());
}
@Test
@@ -122,15 +120,14 @@ public class StreamChatForCampaignUseCaseTest {
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any(), any());
assertNull(captor.getValue().getLoreContext());
assertNull(captor.getValue().loreContext());
// La requete doit tout de meme partir (pas d'exception).
}
@Test
void testExecute_WithEntityFocus_BuildsNarrativeEntity() {
Campaign standalone = Campaign.builder().id("c-1").name("C").loreId(null).build();
NarrativeEntityContext entity = NarrativeEntityContext.builder()
.entityType("scene").title("L'auberge").fields(Map.of()).build();
NarrativeEntityContext entity = new NarrativeEntityContext("scene", "L'auberge", Map.of());
when(campaignRepository.findById("c-1")).thenReturn(Optional.of(standalone));
when(campaignContextBuilder.build("c-1")).thenReturn(campaignCtx);
@@ -140,7 +137,7 @@ public class StreamChatForCampaignUseCaseTest {
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any(), any());
assertSame(entity, captor.getValue().getNarrativeEntity());
assertSame(entity, captor.getValue().narrativeEntity());
}
@Test
@@ -153,7 +150,7 @@ public class StreamChatForCampaignUseCaseTest {
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any(), any());
assertNull(captor.getValue().getNarrativeEntity());
assertNull(captor.getValue().narrativeEntity());
verifyNoInteractions(narrativeEntityContextBuilder);
}
}

View File

@@ -55,10 +55,7 @@ public class StreamChatForLoreUseCaseTest {
@SuppressWarnings("unchecked")
@BeforeEach
void setUp() {
loreCtx = LoreStructuralContext.builder()
.loreName("Aetheria").loreDescription("d")
.folders(Collections.emptyMap())
.build();
loreCtx = new LoreStructuralContext("Aetheria", "d", Collections.emptyMap(), List.of());
messages = List.of();
onUsage = mock(Consumer.class);
onToken = mock(Consumer.class);
@@ -75,9 +72,9 @@ public class StreamChatForLoreUseCaseTest {
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
verify(aiChatProvider).streamChat(captor.capture(), eq(onUsage), eq(onToken), eq(onComplete), eq(onError));
ChatRequest req = captor.getValue();
assertSame(loreCtx, req.getLoreContext());
assertNull(req.getPageContext());
assertNull(req.getCampaignContext());
assertSame(loreCtx, req.loreContext());
assertNull(req.pageContext());
assertNull(req.campaignContext());
}
@Test
@@ -88,7 +85,7 @@ public class StreamChatForLoreUseCaseTest {
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any(), any());
assertNull(captor.getValue().getPageContext());
assertNull(captor.getValue().pageContext());
verifyNoInteractions(pageRepository);
}
@@ -116,12 +113,12 @@ public class StreamChatForLoreUseCaseTest {
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any(), any());
ChatRequest req = captor.getValue();
assertNotNull(req.getPageContext());
assertEquals("Alice", req.getPageContext().getTitle());
assertEquals("Personnage", req.getPageContext().getTemplateName());
assertNotNull(req.pageContext());
assertEquals("Alice", req.pageContext().title());
assertEquals("Personnage", req.pageContext().templateName());
// Seuls les champs TEXT exposes
assertEquals(List.of("Histoire"), req.getPageContext().getTemplateFields());
assertEquals(values, req.getPageContext().getValues());
assertEquals(List.of("Histoire"), req.pageContext().templateFields());
assertEquals(values, req.pageContext().values());
}
@Test
@@ -137,12 +134,12 @@ public class StreamChatForLoreUseCaseTest {
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any(), any());
var pageCtx = captor.getValue().getPageContext();
var pageCtx = captor.getValue().pageContext();
assertNotNull(pageCtx);
assertEquals("Orphan", pageCtx.getTitle());
assertEquals("?", pageCtx.getTemplateName());
assertTrue(pageCtx.getTemplateFields().isEmpty());
assertTrue(pageCtx.getValues().isEmpty());
assertEquals("Orphan", pageCtx.title());
assertEquals("?", pageCtx.templateName());
assertTrue(pageCtx.templateFields().isEmpty());
assertTrue(pageCtx.values().isEmpty());
verifyNoInteractions(templateRepository);
}
@@ -160,9 +157,9 @@ public class StreamChatForLoreUseCaseTest {
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any(), any());
var pageCtx = captor.getValue().getPageContext();
assertEquals("?", pageCtx.getTemplateName());
assertTrue(pageCtx.getTemplateFields().isEmpty());
var pageCtx = captor.getValue().pageContext();
assertEquals("?", pageCtx.templateName());
assertTrue(pageCtx.templateFields().isEmpty());
}
@Test

View File

@@ -9,48 +9,36 @@ import static org.junit.jupiter.api.Assertions.assertNull;
/**
* Tests unitaires pour SceneBranch (Value Object).
* Verifie :
* - l'immuabilite (pas de setters : seul le builder permet la construction),
* - l'egalite structurelle generee par @Value (equals/hashCode sur tous les
* - l'immuabilite (record : aucun setter, constructeur canonique uniquement),
* - l'egalite structurelle generee par record (equals/hashCode sur tous les
* champs) — deux branches aux memes champs sont strictement egales,
* - le support du champ optionnel {@code condition}.
*/
class SceneBranchTest {
@Test
void builder_exposesAllFields() {
SceneBranch branch = SceneBranch.builder()
.label("Si les joueurs attaquent le garde")
.targetSceneId("sc-combat")
.condition("initiative > 15")
.build();
void constructor_exposesAllFields() {
SceneBranch branch = new SceneBranch(
"Si les joueurs attaquent le garde",
"sc-combat",
"initiative > 15");
assertEquals("Si les joueurs attaquent le garde", branch.getLabel());
assertEquals("sc-combat", branch.getTargetSceneId());
assertEquals("initiative > 15", branch.getCondition());
assertEquals("Si les joueurs attaquent le garde", branch.label());
assertEquals("sc-combat", branch.targetSceneId());
assertEquals("initiative > 15", branch.condition());
}
@Test
void condition_isOptional() {
SceneBranch branch = SceneBranch.builder()
.label("sortie par la porte")
.targetSceneId("sc-corridor")
.build();
SceneBranch branch = SceneBranch.of("sortie par la porte", "sc-corridor");
assertNull(branch.getCondition());
assertNull(branch.condition());
}
@Test
void twoBranches_withSameFields_areEqual() {
SceneBranch a = SceneBranch.builder()
.label("fuite")
.targetSceneId("sc-2")
.condition(null)
.build();
SceneBranch b = SceneBranch.builder()
.label("fuite")
.targetSceneId("sc-2")
.condition(null)
.build();
SceneBranch a = new SceneBranch("fuite", "sc-2", null);
SceneBranch b = new SceneBranch("fuite", "sc-2", null);
assertEquals(a, b);
assertEquals(a.hashCode(), b.hashCode());
@@ -58,16 +46,16 @@ class SceneBranchTest {
@Test
void twoBranches_differingOnTargetSceneId_areNotEqual() {
SceneBranch a = SceneBranch.builder().label("X").targetSceneId("sc-1").build();
SceneBranch b = SceneBranch.builder().label("X").targetSceneId("sc-2").build();
SceneBranch a = SceneBranch.of("X", "sc-1");
SceneBranch b = SceneBranch.of("X", "sc-2");
assertNotEquals(a, b);
}
@Test
void twoBranches_differingOnCondition_areNotEqual() {
SceneBranch a = SceneBranch.builder().label("X").targetSceneId("sc-1").condition("A").build();
SceneBranch b = SceneBranch.builder().label("X").targetSceneId("sc-1").condition("B").build();
SceneBranch a = new SceneBranch("X", "sc-1", "A");
SceneBranch b = new SceneBranch("X", "sc-1", "B");
assertNotEquals(a, b);
}

View File

@@ -60,15 +60,15 @@ class SceneTest {
@Test
void builder_preservesBranches_whenProvided() {
SceneBranch b1 = SceneBranch.builder().label("fuite").targetSceneId("sc-2").build();
SceneBranch b2 = SceneBranch.builder().label("combat").targetSceneId("sc-3").build();
SceneBranch b1 = SceneBranch.of("fuite", "sc-2");
SceneBranch b2 = SceneBranch.of("combat", "sc-3");
Scene scene = Scene.builder()
.branches(List.of(b1, b2))
.build();
assertEquals(2, scene.getBranches().size());
assertEquals("fuite", scene.getBranches().get(0).getLabel());
assertEquals("sc-3", scene.getBranches().get(1).getTargetSceneId());
assertEquals("fuite", scene.getBranches().get(0).label());
assertEquals("sc-3", scene.getBranches().get(1).targetSceneId());
}
}

View File

@@ -6,108 +6,97 @@ import com.loremind.domain.generationcontext.CampaignStructuralContext.ChapterSu
import com.loremind.domain.generationcontext.CampaignStructuralContext.SceneSummary;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Tests unitaires pour CampaignStructuralContext et ses types imbriques.
* Focus sur les annotations @Singular (chapters/scenes/branches/arcs) qui
* permettent une construction incrementale du graphe narratif.
* Records purs : aucune dependance technique.
*/
class CampaignStructuralContextTest {
@Test
void builder_constructsFullNarrativeTree() {
BranchHint branch = BranchHint.builder()
.label("si les PJ fuient")
.targetSceneName("La poursuite")
.condition("PJ < moitie des HP")
.build();
void constructor_buildsFullNarrativeTree() {
BranchHint branch = new BranchHint("si les PJ fuient", "La poursuite", "PJ < moitie des HP");
SceneSummary scene = SceneSummary.builder()
.name("L'auberge")
.description("Rencontre tendue avec le tavernier")
.illustrationCount(2)
.branch(branch)
.build();
SceneSummary scene = new SceneSummary(
"L'auberge",
"Rencontre tendue avec le tavernier",
2,
List.of(branch));
ChapterSummary chapter = ChapterSummary.builder()
.name("L'arrivee")
.description("Les PJ decouvrent la ville")
.scene(scene)
.build();
ChapterSummary chapter = new ChapterSummary(
"L'arrivee",
"Les PJ decouvrent la ville",
0,
List.of(scene));
ArcSummary arc = ArcSummary.builder()
.name("Acte I")
.description("Mise en place")
.illustrationCount(1)
.chapter(chapter)
.build();
ArcSummary arc = new ArcSummary(
"Acte I",
"Mise en place",
1,
List.of(chapter));
CampaignStructuralContext ctx = CampaignStructuralContext.builder()
.campaignName("Les Ombres")
.campaignDescription("Une campagne dark fantasy")
.arc(arc)
.build();
CampaignStructuralContext ctx = new CampaignStructuralContext(
"Les Ombres",
"Une campagne dark fantasy",
List.of(arc),
List.of());
assertEquals("Les Ombres", ctx.getCampaignName());
assertEquals(1, ctx.getArcs().size());
assertEquals(1, ctx.getArcs().get(0).getChapters().size());
assertEquals(1, ctx.getArcs().get(0).getChapters().get(0).getScenes().size());
assertEquals(1, ctx.getArcs().get(0).getChapters().get(0).getScenes().get(0).getBranches().size());
assertEquals("Les Ombres", ctx.campaignName());
assertEquals(1, ctx.arcs().size());
assertEquals(1, ctx.arcs().get(0).chapters().size());
assertEquals(1, ctx.arcs().get(0).chapters().get(0).scenes().size());
assertEquals(1, ctx.arcs().get(0).chapters().get(0).scenes().get(0).branches().size());
}
// --- BranchHint ---------------------------------------------------------
@Test
void branchHint_preservesAllFields() {
BranchHint b = BranchHint.builder()
.label("combat")
.targetSceneName("La confrontation")
.condition("initiative > 15")
.build();
BranchHint b = new BranchHint("combat", "La confrontation", "initiative > 15");
assertEquals("combat", b.getLabel());
assertEquals("La confrontation", b.getTargetSceneName());
assertEquals("initiative > 15", b.getCondition());
assertEquals("combat", b.label());
assertEquals("La confrontation", b.targetSceneName());
assertEquals("initiative > 15", b.condition());
}
@Test
void branchHint_conditionIsOptional() {
BranchHint b = BranchHint.builder()
.label("suite normale")
.targetSceneName("Scene 2")
.build();
BranchHint b = new BranchHint("suite normale", "Scene 2", null);
assertNull(b.getCondition());
assertNull(b.condition());
}
// --- illustrationCount --------------------------------------------------
@Test
void illustrationCount_defaultsToZero_onAllSummaryTypes() {
ArcSummary arc = ArcSummary.builder().name("X").build();
ChapterSummary chapter = ChapterSummary.builder().name("X").build();
SceneSummary scene = SceneSummary.builder().name("X").build();
ArcSummary arc = new ArcSummary("X", null, 0, List.of());
ChapterSummary chapter = new ChapterSummary("X", null, 0, List.of());
SceneSummary scene = new SceneSummary("X", null, 0, List.of());
assertEquals(0, arc.getIllustrationCount());
assertEquals(0, chapter.getIllustrationCount());
assertEquals(0, scene.getIllustrationCount());
assertEquals(0, arc.illustrationCount());
assertEquals(0, chapter.illustrationCount());
assertEquals(0, scene.illustrationCount());
}
// --- @Singular : accumulation incrementale -----------------------------
// --- Construction incrementale (chapitres multiples) -------------------
@Test
void singular_accumulatesMultipleCalls() {
ArcSummary arc = ArcSummary.builder()
.name("Acte I")
.chapter(ChapterSummary.builder().name("Ch1").build())
.chapter(ChapterSummary.builder().name("Ch2").build())
.chapter(ChapterSummary.builder().name("Ch3").build())
.build();
void multipleChapters_arePreserved() {
ArcSummary arc = new ArcSummary(
"Acte I",
null,
0,
List.of(
new ChapterSummary("Ch1", null, 0, List.of()),
new ChapterSummary("Ch2", null, 0, List.of()),
new ChapterSummary("Ch3", null, 0, List.of())));
assertEquals(3, arc.getChapters().size());
assertTrue(arc.getChapters().stream().anyMatch(c -> "Ch2".equals(c.getName())));
assertEquals(3, arc.chapters().size());
assertEquals("Ch2", arc.chapters().get(1).name());
}
}

View File

@@ -3,6 +3,7 @@ package com.loremind.domain.generationcontext;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
@@ -26,57 +27,45 @@ class ChatRequestTest {
void buildLoreOnly_leavesCampaignAndEntityNull() {
ChatRequest request = ChatRequest.builder()
.messages(sampleMessages)
.loreContext(LoreStructuralContext.builder()
.loreName("Ithoril")
.loreDescription("Royaume sombre")
.folders(java.util.Map.of())
.build())
.loreContext(new LoreStructuralContext("Ithoril", "Royaume sombre", Map.of(), List.of()))
.build();
assertEquals(1, request.getMessages().size());
assertNotNull(request.getLoreContext());
assertEquals("Ithoril", request.getLoreContext().getLoreName());
assertNull(request.getPageContext());
assertNull(request.getCampaignContext());
assertNull(request.getNarrativeEntity());
assertEquals(1, request.messages().size());
assertNotNull(request.loreContext());
assertEquals("Ithoril", request.loreContext().loreName());
assertNull(request.pageContext());
assertNull(request.campaignContext());
assertNull(request.narrativeEntity());
}
@Test
void buildLoreWithPageFocus_hasBothContexts() {
ChatRequest request = ChatRequest.builder()
.messages(sampleMessages)
.loreContext(LoreStructuralContext.builder().folders(java.util.Map.of()).build())
.pageContext(PageContext.builder()
.title("Thorin")
.templateName("PNJ")
.build())
.loreContext(new LoreStructuralContext(null, null, Map.of(), List.of()))
.pageContext(new PageContext("Thorin", "PNJ", null, null))
.build();
assertNotNull(request.getLoreContext());
assertNotNull(request.getPageContext());
assertEquals("Thorin", request.getPageContext().getTitle());
assertNotNull(request.loreContext());
assertNotNull(request.pageContext());
assertEquals("Thorin", request.pageContext().title());
}
@Test
void buildCampaignWithNarrativeEntity_hasBothContexts() {
ChatRequest request = ChatRequest.builder()
.messages(sampleMessages)
.campaignContext(CampaignStructuralContext.builder()
.campaignName("Les Ombres")
.campaignDescription("...")
.build())
.narrativeEntity(NarrativeEntityContext.builder()
.entityType("scene")
.title("L'auberge")
.fields(java.util.Map.of("location", "Taverne"))
.build())
.campaignContext(new CampaignStructuralContext(
"Les Ombres", "...", List.of(), List.of()))
.narrativeEntity(new NarrativeEntityContext(
"scene", "L'auberge", Map.of("location", "Taverne")))
.build();
assertNotNull(request.getCampaignContext());
assertNotNull(request.getNarrativeEntity());
assertEquals("scene", request.getNarrativeEntity().getEntityType());
assertNull(request.getLoreContext());
assertNull(request.getPageContext());
assertNotNull(request.campaignContext());
assertNotNull(request.narrativeEntity());
assertEquals("scene", request.narrativeEntity().entityType());
assertNull(request.loreContext());
assertNull(request.pageContext());
}
@Test
@@ -86,10 +75,10 @@ class ChatRequestTest {
.messages(sampleMessages)
.build();
assertEquals(1, request.getMessages().size());
assertNull(request.getLoreContext());
assertNull(request.getPageContext());
assertNull(request.getCampaignContext());
assertNull(request.getNarrativeEntity());
assertEquals(1, request.messages().size());
assertNull(request.loreContext());
assertNull(request.pageContext());
assertNull(request.campaignContext());
assertNull(request.narrativeEntity());
}
}

View File

@@ -9,41 +9,38 @@ import static org.junit.jupiter.api.Assertions.assertNotEquals;
/**
* Tests unitaires pour GenerationContext (Value Object pour la generation one-shot).
* Verifie la construction via builder et l'egalite structurelle.
* Verifie la construction et l'egalite structurelle (record).
*/
class GenerationContextTest {
@Test
void builder_preservesAllFields() {
GenerationContext ctx = GenerationContext.builder()
.loreName("Ithoril")
.loreDescription("Royaume sombre")
.folderName("PNJ")
.templateName("Fiche PNJ")
.templateFields(List.of("histoire", "motto", "apparence"))
.pageTitle("Thorin")
.build();
void constructor_preservesAllFields() {
GenerationContext ctx = new GenerationContext(
"Ithoril",
"Royaume sombre",
"PNJ",
"Fiche PNJ",
List.of("histoire", "motto", "apparence"),
"Thorin");
assertEquals("Ithoril", ctx.getLoreName());
assertEquals("PNJ", ctx.getFolderName());
assertEquals("Fiche PNJ", ctx.getTemplateName());
assertEquals(3, ctx.getTemplateFields().size());
assertEquals("Thorin", ctx.getPageTitle());
assertEquals("Ithoril", ctx.loreName());
assertEquals("PNJ", ctx.folderName());
assertEquals("Fiche PNJ", ctx.templateName());
assertEquals(3, ctx.templateFields().size());
assertEquals("Thorin", ctx.pageTitle());
}
@Test
void twoContexts_withSameFields_areEqual() {
GenerationContext a = GenerationContext.builder()
.loreName("X").pageTitle("A").templateFields(List.of("f1")).build();
GenerationContext b = GenerationContext.builder()
.loreName("X").pageTitle("A").templateFields(List.of("f1")).build();
GenerationContext a = new GenerationContext("X", null, null, null, List.of("f1"), "A");
GenerationContext b = new GenerationContext("X", null, null, null, List.of("f1"), "A");
assertEquals(a, b);
}
@Test
void twoContexts_differingOnPageTitle_areNotEqual() {
GenerationContext a = GenerationContext.builder().pageTitle("A").build();
GenerationContext b = GenerationContext.builder().pageTitle("B").build();
GenerationContext a = new GenerationContext(null, null, null, null, null, "A");
GenerationContext b = new GenerationContext(null, null, null, null, null, "B");
assertNotEquals(a, b);
}
}

View File

@@ -12,66 +12,61 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Tests unitaires pour LoreStructuralContext et son type imbrique PageSummary.
* Valide le @Singular de Lombok sur {@code tags} (alimentation incrementale via
* {@code tag(...)} vs initialisation groupee via {@code tags(...)}).
* Records purs : aucune dependance technique.
*/
class LoreStructuralContextTest {
@Test
void builder_preservesFoldersAndTags() {
PageSummary pnj = PageSummary.builder()
.title("Thorin")
.templateName("PNJ")
.values(Map.of("histoire", "Nee sous une etoile rouge"))
.tags(List.of("pnj", "allie"))
.relatedPageTitles(List.of("Taverne du Dragon d'Or"))
.build();
void constructor_preservesFoldersAndTags() {
PageSummary pnj = new PageSummary(
"Thorin",
"PNJ",
Map.of("histoire", "Nee sous une etoile rouge"),
List.of("pnj", "allie"),
List.of("Taverne du Dragon d'Or"));
LoreStructuralContext ctx = LoreStructuralContext.builder()
.loreName("Ithoril")
.loreDescription("Royaume sombre")
.folders(Map.of("PNJ", List.of(pnj)))
.tag("royaume")
.tag("dark-fantasy")
.build();
LoreStructuralContext ctx = new LoreStructuralContext(
"Ithoril",
"Royaume sombre",
Map.of("PNJ", List.of(pnj)),
List.of("royaume", "dark-fantasy"));
assertEquals("Ithoril", ctx.getLoreName());
assertEquals(1, ctx.getFolders().size());
assertEquals(1, ctx.getFolders().get("PNJ").size());
assertEquals(2, ctx.getTags().size(), "@Singular doit accumuler les appels tag()");
assertTrue(ctx.getTags().contains("royaume"));
assertTrue(ctx.getTags().contains("dark-fantasy"));
assertEquals("Ithoril", ctx.loreName());
assertEquals(1, ctx.folders().size());
assertEquals(1, ctx.folders().get("PNJ").size());
assertEquals(2, ctx.tags().size());
assertTrue(ctx.tags().contains("royaume"));
assertTrue(ctx.tags().contains("dark-fantasy"));
}
@Test
void emptyFolders_areAllowed() {
// Dossier vide : legitime (ex: dossier "Lieux" cree mais pas encore peuple).
LoreStructuralContext ctx = LoreStructuralContext.builder()
.loreName("Vide")
.loreDescription("")
.folders(Map.of("Lieux", List.of()))
.build();
LoreStructuralContext ctx = new LoreStructuralContext(
"Vide",
"",
Map.of("Lieux", List.of()),
List.of());
assertNotNull(ctx.getFolders().get("Lieux"));
assertTrue(ctx.getFolders().get("Lieux").isEmpty());
assertNotNull(ctx.folders().get("Lieux"));
assertTrue(ctx.folders().get("Lieux").isEmpty());
}
// --- PageSummary --------------------------------------------------------
@Test
void pageSummary_preservesAllFields() {
PageSummary ps = PageSummary.builder()
.title("Le Donjon du Chaos")
.templateName("Lieu")
.values(Map.of("histoire", "Bati il y a 1000 ans..."))
.tags(List.of("donjon", "ancien"))
.relatedPageTitles(List.of("Thorin", "Garde royale"))
.build();
PageSummary ps = new PageSummary(
"Le Donjon du Chaos",
"Lieu",
Map.of("histoire", "Bati il y a 1000 ans..."),
List.of("donjon", "ancien"),
List.of("Thorin", "Garde royale"));
assertEquals("Le Donjon du Chaos", ps.getTitle());
assertEquals("Lieu", ps.getTemplateName());
assertEquals(1, ps.getValues().size());
assertEquals(2, ps.getTags().size());
assertEquals(2, ps.getRelatedPageTitles().size());
assertEquals("Le Donjon du Chaos", ps.title());
assertEquals("Lieu", ps.templateName());
assertEquals(1, ps.values().size());
assertEquals(2, ps.tags().size());
assertEquals(2, ps.relatedPageTitles().size());
}
}

View File

@@ -16,21 +16,17 @@ import static org.junit.jupiter.api.Assertions.assertNotEquals;
class NarrativeEntityContextTest {
@Test
void builder_preservesAllFields() {
void constructor_preservesAllFields() {
Map<String, String> fields = new LinkedHashMap<>();
fields.put("themes", "trahison");
fields.put("stakes", "la survie du royaume");
NarrativeEntityContext ctx = NarrativeEntityContext.builder()
.entityType("arc")
.title("Acte I")
.fields(fields)
.build();
NarrativeEntityContext ctx = new NarrativeEntityContext("arc", "Acte I", fields);
assertEquals("arc", ctx.getEntityType());
assertEquals("Acte I", ctx.getTitle());
assertEquals(2, ctx.getFields().size());
assertEquals("trahison", ctx.getFields().get("themes"));
assertEquals("arc", ctx.entityType());
assertEquals("Acte I", ctx.title());
assertEquals(2, ctx.fields().size());
assertEquals("trahison", ctx.fields().get("themes"));
}
@Test
@@ -41,19 +37,15 @@ class NarrativeEntityContextTest {
fields.put("timing", "Soir");
fields.put("atmosphere", "fumee");
NarrativeEntityContext ctx = NarrativeEntityContext.builder()
.entityType("scene")
.title("L'auberge")
.fields(fields)
.build();
NarrativeEntityContext ctx = new NarrativeEntityContext("scene", "L'auberge", fields);
assertEquals("[location, timing, atmosphere]", ctx.getFields().keySet().toString());
assertEquals("[location, timing, atmosphere]", ctx.fields().keySet().toString());
}
@Test
void twoContexts_differingOnEntityType_areNotEqual() {
NarrativeEntityContext a = NarrativeEntityContext.builder().entityType("arc").title("X").build();
NarrativeEntityContext b = NarrativeEntityContext.builder().entityType("scene").title("X").build();
NarrativeEntityContext a = new NarrativeEntityContext("arc", "X", Map.of());
NarrativeEntityContext b = new NarrativeEntityContext("scene", "X", Map.of());
assertNotEquals(a, b);
}
}

View File

@@ -14,31 +14,29 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
class PageContextTest {
@Test
void builder_preservesAllFields() {
PageContext ctx = PageContext.builder()
.title("Thorin")
.templateName("PNJ")
.templateFields(List.of("histoire", "apparence", "motto"))
.values(Map.of("histoire", "Nee sous une etoile rouge"))
.build();
void constructor_preservesAllFields() {
PageContext ctx = new PageContext(
"Thorin",
"PNJ",
List.of("histoire", "apparence", "motto"),
Map.of("histoire", "Nee sous une etoile rouge"));
assertEquals("Thorin", ctx.getTitle());
assertEquals("PNJ", ctx.getTemplateName());
assertEquals(3, ctx.getTemplateFields().size());
assertEquals(1, ctx.getValues().size());
assertEquals("Thorin", ctx.title());
assertEquals("PNJ", ctx.templateName());
assertEquals(3, ctx.templateFields().size());
assertEquals(1, ctx.values().size());
}
@Test
void emptyValues_areAllowed() {
// Page vierge : template defini mais aucun champ rempli (cas generation ex-nihilo).
PageContext ctx = PageContext.builder()
.title("Nouveau PNJ")
.templateName("PNJ")
.templateFields(List.of("histoire", "apparence"))
.values(Map.of())
.build();
PageContext ctx = new PageContext(
"Nouveau PNJ",
"PNJ",
List.of("histoire", "apparence"),
Map.of());
assertTrue(ctx.getValues().isEmpty());
assertEquals(2, ctx.getTemplateFields().size());
assertTrue(ctx.values().isEmpty());
assertEquals(2, ctx.templateFields().size());
}
}

View File

@@ -83,12 +83,8 @@ class BrainChatPayloadBuilderTest {
@Test
@SuppressWarnings("unchecked")
void build_loreContext_includesBasicFields() {
LoreStructuralContext lore = LoreStructuralContext.builder()
.loreName("Ithoril")
.loreDescription("Royaume sombre")
.folders(Map.of())
.tag("dark-fantasy")
.build();
LoreStructuralContext lore = new LoreStructuralContext(
"Ithoril", "Royaume sombre", Map.of(), List.of("dark-fantasy"));
ChatRequest req = ChatRequest.builder().messages(sampleMessages).loreContext(lore).build();
Map<String, Object> payload = builder.build(req);
@@ -103,17 +99,10 @@ class BrainChatPayloadBuilderTest {
@Test
@SuppressWarnings("unchecked")
void build_pageSummary_omitsEmptyValuesTagsAndRelated() {
PageSummary minimal = PageSummary.builder()
.title("Thorin")
.templateName("PNJ")
.values(Map.of())
.tags(List.of())
.relatedPageTitles(List.of())
.build();
LoreStructuralContext lore = LoreStructuralContext.builder()
.loreName("X").loreDescription("")
.folders(Map.of("PNJ", List.of(minimal)))
.build();
PageSummary minimal = new PageSummary("Thorin", "PNJ",
Map.of(), List.of(), List.of());
LoreStructuralContext lore = new LoreStructuralContext(
"X", "", Map.of("PNJ", List.of(minimal)), List.of());
ChatRequest req = ChatRequest.builder().messages(sampleMessages).loreContext(lore).build();
Map<String, Object> payload = builder.build(req);
@@ -132,17 +121,12 @@ class BrainChatPayloadBuilderTest {
@Test
@SuppressWarnings("unchecked")
void build_pageSummary_includesNonEmptyValuesTagsAndRelated() {
PageSummary full = PageSummary.builder()
.title("Thorin")
.templateName("PNJ")
.values(Map.of("histoire", "Nee sous une etoile rouge"))
.tags(List.of("pnj", "allie"))
.relatedPageTitles(List.of("Taverne du Dragon d'Or"))
.build();
LoreStructuralContext lore = LoreStructuralContext.builder()
.loreName("X").loreDescription("")
.folders(Map.of("PNJ", List.of(full)))
.build();
PageSummary full = new PageSummary("Thorin", "PNJ",
Map.of("histoire", "Nee sous une etoile rouge"),
List.of("pnj", "allie"),
List.of("Taverne du Dragon d'Or"));
LoreStructuralContext lore = new LoreStructuralContext(
"X", "", Map.of("PNJ", List.of(full)), List.of());
ChatRequest req = ChatRequest.builder().messages(sampleMessages).loreContext(lore).build();
Map<String, Object> payload = builder.build(req);
@@ -161,12 +145,8 @@ class BrainChatPayloadBuilderTest {
@Test
@SuppressWarnings("unchecked")
void build_pageContext_includesAllFields() {
PageContext pc = PageContext.builder()
.title("Thorin")
.templateName("PNJ")
.templateFields(List.of("histoire", "motto"))
.values(Map.of("histoire", "..."))
.build();
PageContext pc = new PageContext("Thorin", "PNJ",
List.of("histoire", "motto"), Map.of("histoire", "..."));
ChatRequest req = ChatRequest.builder().messages(sampleMessages).pageContext(pc).build();
Map<String, Object> payload = builder.build(req);
@@ -182,17 +162,12 @@ class BrainChatPayloadBuilderTest {
@Test
@SuppressWarnings("unchecked")
void build_campaignContext_serializesFullNarrativeTree() {
BranchHint branch = BranchHint.builder()
.label("fuite").targetSceneName("La poursuite").condition("HP < 50%").build();
SceneSummary scene = SceneSummary.builder()
.name("L'auberge").description("Rencontre tendue")
.illustrationCount(3).branch(branch).build();
ChapterSummary chapter = ChapterSummary.builder()
.name("L'arrivee").description("...").scene(scene).build();
ArcSummary arc = ArcSummary.builder()
.name("Acte I").description("Mise en place").illustrationCount(1).chapter(chapter).build();
CampaignStructuralContext camp = CampaignStructuralContext.builder()
.campaignName("Les Ombres").campaignDescription("dark fantasy").arc(arc).build();
BranchHint branch = new BranchHint("fuite", "La poursuite", "HP < 50%");
SceneSummary scene = new SceneSummary("L'auberge", "Rencontre tendue", 3, List.of(branch));
ChapterSummary chapter = new ChapterSummary("L'arrivee", "...", 0, List.of(scene));
ArcSummary arc = new ArcSummary("Acte I", "Mise en place", 1, List.of(chapter));
CampaignStructuralContext camp = new CampaignStructuralContext(
"Les Ombres", "dark fantasy", List.of(arc), List.of());
ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build();
Map<String, Object> payload = builder.build(req);
@@ -223,9 +198,9 @@ class BrainChatPayloadBuilderTest {
@Test
@SuppressWarnings("unchecked")
void build_arcSummary_omitsIllustrationCount_whenZero() {
ArcSummary arc = ArcSummary.builder().name("A").description("").illustrationCount(0).build();
CampaignStructuralContext camp = CampaignStructuralContext.builder()
.campaignName("X").campaignDescription("").arc(arc).build();
ArcSummary arc = new ArcSummary("A", "", 0, List.of());
CampaignStructuralContext camp = new CampaignStructuralContext(
"X", "", List.of(arc), List.of());
ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build();
Map<String, Object> payload = builder.build(req);
@@ -238,11 +213,11 @@ class BrainChatPayloadBuilderTest {
@Test
@SuppressWarnings("unchecked")
void build_sceneSummary_omitsBranches_whenEmpty() {
SceneSummary scene = SceneSummary.builder().name("S").description("").build();
ChapterSummary chapter = ChapterSummary.builder().name("Ch").description("").scene(scene).build();
ArcSummary arc = ArcSummary.builder().name("A").description("").chapter(chapter).build();
CampaignStructuralContext camp = CampaignStructuralContext.builder()
.campaignName("X").campaignDescription("").arc(arc).build();
SceneSummary scene = new SceneSummary("S", "", 0, List.of());
ChapterSummary chapter = new ChapterSummary("Ch", "", 0, List.of(scene));
ArcSummary arc = new ArcSummary("A", "", 0, List.of(chapter));
CampaignStructuralContext camp = new CampaignStructuralContext(
"X", "", List.of(arc), List.of());
ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build();
Map<String, Object> payload = builder.build(req);
@@ -256,12 +231,12 @@ class BrainChatPayloadBuilderTest {
@Test
@SuppressWarnings("unchecked")
void build_branchHint_omitsCondition_whenBlank() {
BranchHint branch = BranchHint.builder().label("X").targetSceneName("Y").condition(" ").build();
SceneSummary scene = SceneSummary.builder().name("S").description("").branch(branch).build();
ChapterSummary chapter = ChapterSummary.builder().name("Ch").description("").scene(scene).build();
ArcSummary arc = ArcSummary.builder().name("A").description("").chapter(chapter).build();
CampaignStructuralContext camp = CampaignStructuralContext.builder()
.campaignName("X").campaignDescription("").arc(arc).build();
BranchHint branch = new BranchHint("X", "Y", " ");
SceneSummary scene = new SceneSummary("S", "", 0, List.of(branch));
ChapterSummary chapter = new ChapterSummary("Ch", "", 0, List.of(scene));
ArcSummary arc = new ArcSummary("A", "", 0, List.of(chapter));
CampaignStructuralContext camp = new CampaignStructuralContext(
"X", "", List.of(arc), List.of());
ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build();
Map<String, Object> payload = builder.build(req);
@@ -278,10 +253,8 @@ class BrainChatPayloadBuilderTest {
@Test
@SuppressWarnings("unchecked")
void build_narrativeEntity_includesAllFields() {
NarrativeEntityContext entity = NarrativeEntityContext.builder()
.entityType("scene").title("L'auberge")
.fields(Map.of("location", "Taverne", "timing", "Soir"))
.build();
NarrativeEntityContext entity = new NarrativeEntityContext("scene", "L'auberge",
Map.of("location", "Taverne", "timing", "Soir"));
ChatRequest req = ChatRequest.builder().messages(sampleMessages).narrativeEntity(entity).build();
Map<String, Object> payload = builder.build(req);
@@ -295,10 +268,9 @@ class BrainChatPayloadBuilderTest {
@Test
void build_campaignScenario_includesBothContextsAndEntity() {
CampaignStructuralContext camp = CampaignStructuralContext.builder()
.campaignName("X").campaignDescription("").build();
NarrativeEntityContext entity = NarrativeEntityContext.builder()
.entityType("arc").title("T").fields(Map.of()).build();
CampaignStructuralContext camp = new CampaignStructuralContext(
"X", "", List.of(), List.of());
NarrativeEntityContext entity = new NarrativeEntityContext("arc", "T", Map.of());
ChatRequest req = ChatRequest.builder()
.messages(sampleMessages)
.campaignContext(camp)

View File

@@ -48,27 +48,21 @@ class SceneBranchListJsonConverterTest {
@Test
void roundTrip_preservesAllBranchFields() {
// Test critique : depend de @Jacksonized sur SceneBranch.
// Test critique : Jackson doit reconstruire SceneBranch (record) via
// son constructeur canonique sans aucune annotation.
List<SceneBranch> source = List.of(
SceneBranch.builder()
.label("si les joueurs attaquent")
.targetSceneId("sc-combat")
.condition("initiative > 15")
.build(),
SceneBranch.builder()
.label("si les joueurs fuient")
.targetSceneId("sc-poursuite")
.build()
new SceneBranch("si les joueurs attaquent", "sc-combat", "initiative > 15"),
SceneBranch.of("si les joueurs fuient", "sc-poursuite")
);
String json = converter.convertToDatabaseColumn(source);
List<SceneBranch> back = converter.convertToEntityAttribute(json);
assertEquals(2, back.size());
assertEquals("si les joueurs attaquent", back.get(0).getLabel());
assertEquals("sc-combat", back.get(0).getTargetSceneId());
assertEquals("initiative > 15", back.get(0).getCondition());
assertEquals("sc-poursuite", back.get(1).getTargetSceneId());
assertNull(back.get(1).getCondition(), "condition absente doit rester null apres round-trip");
assertEquals("si les joueurs attaquent", back.get(0).label());
assertEquals("sc-combat", back.get(0).targetSceneId());
assertEquals("initiative > 15", back.get(0).condition());
assertEquals("sc-poursuite", back.get(1).targetSceneId());
assertNull(back.get(1).condition(), "condition absente doit rester null apres round-trip");
}
}

View File

@@ -70,13 +70,13 @@ class PostgresSceneRepositoryTest {
@Test
void save_scenePreservesBranches_viaJsonbRoundTrip() {
// Le critique : le @Jacksonized de SceneBranch doit permettre la
// reconstruction via builder apres serialisation Jackson.
// Le critique : SceneBranch (record) doit etre reconstructible par
// Jackson via le constructeur canonique apres serialisation JSON.
Scene scene = Scene.builder()
.chapterId(chapterId).name("Decision").order(0)
.branches(List.of(
SceneBranch.builder().label("fuite").targetSceneId("sc-2").condition("HP bas").build(),
SceneBranch.builder().label("combat").targetSceneId("sc-3").build()
new SceneBranch("fuite", "sc-2", "HP bas"),
SceneBranch.of("combat", "sc-3")
))
.build();
@@ -84,10 +84,10 @@ class PostgresSceneRepositoryTest {
Scene r = repository.findById(saved.getId()).orElseThrow();
assertEquals(2, r.getBranches().size());
assertEquals("fuite", r.getBranches().get(0).getLabel());
assertEquals("sc-2", r.getBranches().get(0).getTargetSceneId());
assertEquals("HP bas", r.getBranches().get(0).getCondition());
assertEquals("combat", r.getBranches().get(1).getLabel());
assertEquals("fuite", r.getBranches().get(0).label());
assertEquals("sc-2", r.getBranches().get(0).targetSceneId());
assertEquals("HP bas", r.getBranches().get(0).condition());
assertEquals("combat", r.getBranches().get(1).label());
}
@Test

View File

@@ -62,6 +62,8 @@ services:
core:
image: ${REGISTRY:-git.igmlcreation.fr}/ietm64/core:${TAG:-latest}
container_name: loremind-core
labels:
- "com.centurylinklabs.watchtower.enable=true"
depends_on:
postgres:
condition: service_healthy
@@ -79,11 +81,21 @@ services:
MINIO_ENDPOINT: http://minio:9000
MINIO_ACCESS_KEY: ${MINIO_USER:-minioadmin}
MINIO_SECRET_KEY: ${MINIO_PASSWORD:-minioadmin}
# Detection des mises a jour : interroge le registry et delegue le pull/restart
# a Watchtower. Si WATCHTOWER_TOKEN est vide, la feature est desactivee
# (l'UI masque le badge et le bouton).
UPDATE_CHECK_REGISTRY: ${REGISTRY:-git.igmlcreation.fr}
UPDATE_CHECK_IMAGES: ietm64/core,ietm64/brain,ietm64/web
UPDATE_CHECK_TAG: ${TAG:-latest}
WATCHTOWER_URL: http://watchtower:8080
WATCHTOWER_TOKEN: ${WATCHTOWER_TOKEN:-}
restart: unless-stopped
brain:
image: ${REGISTRY:-git.igmlcreation.fr}/ietm64/brain:${TAG:-latest}
container_name: loremind-brain
labels:
- "com.centurylinklabs.watchtower.enable=true"
environment:
LLM_PROVIDER: ${LLM_PROVIDER:-ollama}
OLLAMA_BASE_URL: ${OLLAMA_BASE_URL:-http://host.docker.internal:11434}
@@ -102,6 +114,8 @@ services:
web:
image: ${REGISTRY:-git.igmlcreation.fr}/ietm64/web:${TAG:-latest}
container_name: loremind-web
labels:
- "com.centurylinklabs.watchtower.enable=true"
depends_on:
- core
- brain
@@ -109,6 +123,33 @@ services:
- "${WEB_PORT:-8081}:80"
restart: unless-stopped
# Mises a jour automatiques des images core/brain/web.
# Active uniquement si COMPOSE_PROFILES=autoupdate (gere par l'installeur).
# Postgres et MinIO sont volontairement exclus (donnees persistantes,
# compatibilite de version a verifier manuellement).
watchtower:
image: containrrr/watchtower:latest
container_name: loremind-watchtower
profiles: ["autoupdate"]
volumes:
- /var/run/docker.sock:/var/run/docker.sock
environment:
WATCHTOWER_LABEL_ENABLE: "true"
WATCHTOWER_CLEANUP: "true"
WATCHTOWER_INCLUDE_RESTARTING: "true"
# MONITOR_ONLY=true => detecte sans appliquer (l'UI declenche manuellement).
# MONITOR_ONLY=false => applique automatiquement selon WATCHTOWER_SCHEDULE.
WATCHTOWER_MONITOR_ONLY: "${WATCHTOWER_MONITOR_ONLY:-false}"
WATCHTOWER_SCHEDULE: "${WATCHTOWER_SCHEDULE:-0 0 4 * * *}"
# API HTTP pour declenchement manuel via le bouton UI (Core -> Watchtower).
WATCHTOWER_HTTP_API_UPDATE: "true"
WATCHTOWER_HTTP_API_PERIODIC_POLLS: "true"
WATCHTOWER_HTTP_API_TOKEN: "${WATCHTOWER_TOKEN:?set WATCHTOWER_TOKEN in .env (re-run installer)}"
WATCHTOWER_TIMEOUT: 60s
WATCHTOWER_NOTIFICATIONS_LEVEL: info
TZ: ${TZ:-Europe/Paris}
restart: unless-stopped
volumes:
postgres-data:
minio-data:

109
installers/README.md Normal file
View File

@@ -0,0 +1,109 @@
# LoreMindMJ — Installation rapide
Ces scripts installent Docker (si nécessaire), génèrent un `.env` sécurisé
et lancent la stack. Aucune configuration manuelle requise.
## Windows 10 / 11
Ouvrir **PowerShell** (clic droit → *Exécuter en tant qu'administrateur*) :
```powershell
iwr https://git.igmlcreation.fr/ietm64/loremind/raw/branch/main/installers/install.ps1 -OutFile $env:TEMP\loremind-install.ps1
powershell -ExecutionPolicy Bypass -File $env:TEMP\loremind-install.ps1
```
Le script :
1. Vérifie / installe **WSL2** (un reboot peut être nécessaire — relancer le script après).
2. Vérifie / installe **Docker Desktop** via `winget`.
3. Génère `%LOCALAPPDATA%\LoreMind\.env` avec mots de passe aléatoires.
4. Lance la stack et ouvre `http://localhost:8081`.
## Linux (Debian / Ubuntu / Fedora / Arch)
```bash
curl -fsSL https://git.igmlcreation.fr/ietm64/loremind/raw/branch/main/installers/install.sh | bash
```
Le script :
1. Installe **Docker** via le script officiel `get.docker.com` si absent.
2. Ajoute l'utilisateur courant au groupe `docker` (relogin nécessaire la 1ʳᵉ fois).
3. Installe dans `~/.local/share/loremind`.
4. Lance la stack et ouvre `http://localhost:8081`.
## Variables disponibles
| Variable | Défaut | Effet |
|-------------------|---------------------------------|----------------------------------------|
| `WEB_PORT` | `8081` | Port HTTP de l'UI |
| `INSTALL_DIR` | `~/.local/share/loremind` (Lin) | Dossier d'installation |
| `NON_INTERACTIVE` | `0` | `1` = aucune question, valeurs par défaut |
Exemple Linux non-interactif sur port 9000 :
```bash
WEB_PORT=9000 NON_INTERACTIVE=1 bash install.sh
```
## Mises à jour automatiques (Watchtower)
Si vous avez répondu **oui** à la question "Activer les mises à jour auto",
un container [Watchtower](https://containrrr.dev/watchtower/) est lancé en
parallèle. Il vérifie chaque nuit à 4h les nouvelles versions de
`core`, `brain` et `web` sur le registry, télécharge et redémarre les
conteneurs concernés. **Postgres et MinIO sont volontairement exclus**
(données persistantes — montée de version à valider manuellement).
### Activer / désactiver après coup
Éditer `.env` dans le dossier d'installation :
```env
COMPOSE_PROFILES=autoupdate # active
COMPOSE_PROFILES= # desactive
```
Puis :
```bash
docker compose up -d # applique le changement
docker compose stop watchtower # si on vient de le desactiver
```
### Changer l'horaire
`WATCHTOWER_SCHEDULE` dans `.env` accepte la syntaxe
[cron 6 champs](https://pkg.go.dev/github.com/robfig/cron) (sec min h jour mois j-sem).
Exemples : `0 0 4 * * *` (4h du matin, défaut), `0 30 3 * * 0` (dimanche 3h30).
### Mode "notification seulement" (sans auto-apply)
Si vous préférez être notifié *sans* que les conteneurs redémarrent
automatiquement la nuit, éditez `.env` :
```env
WATCHTOWER_MONITOR_ONLY=true
```
Puis `docker compose up -d watchtower`. Watchtower continuera à vérifier
le registry chaque nuit, le badge **MAJ** apparaîtra dans la sidebar de
l'UI, et un bouton **Mettre à jour maintenant** sera disponible dans
*Paramètres → Mises à jour*.
### Mise à jour manuelle (à tout moment)
Depuis l'interface : *Paramètres → Mises à jour → Mettre à jour maintenant*.
Ou en CLI :
```bash
docker compose pull && docker compose up -d
```
## Désinstallation
```bash
cd <dossier d'install>
docker compose down -v # -v supprime aussi les volumes (données effacées !)
```
Puis supprimer le dossier d'installation.

240
installers/install.ps1 Normal file
View File

@@ -0,0 +1,240 @@
#Requires -Version 5.1
<#
.SYNOPSIS
Installeur LoreMindMJ pour Windows 10/11.
.DESCRIPTION
- Verifie / installe WSL2 et Docker Desktop (via winget)
- Genere un .env avec mots de passe aleatoires
- Recupere le docker-compose.yml officiel
- Lance la stack et ouvre le navigateur
.EXAMPLE
iwr https://git.igmlcreation.fr/ietm64/loremind/raw/branch/main/installers/install.ps1 | iex
#>
[CmdletBinding()]
param(
[string]$InstallDir = "$env:LOCALAPPDATA\LoreMind",
[string]$ComposeUrl = "https://git.igmlcreation.fr/ietm64/loremind/raw/branch/main/docker-compose.yml",
[int]$WebPort = 8081,
[switch]$NonInteractive
)
$ErrorActionPreference = 'Stop'
function Write-Step($msg) { Write-Host "==> $msg" -ForegroundColor Cyan }
function Write-Ok($msg) { Write-Host " OK $msg" -ForegroundColor Green }
function Write-Warn2($msg) { Write-Host " !! $msg" -ForegroundColor Yellow }
function Write-Err($msg) { Write-Host " XX $msg" -ForegroundColor Red }
function Test-Admin {
$current = [Security.Principal.WindowsIdentity]::GetCurrent()
return ([Security.Principal.WindowsPrincipal]$current).IsInRole(
[Security.Principal.WindowsBuiltInRole]::Administrator)
}
function Invoke-Elevated {
Write-Step "Relance en mode administrateur..."
$args = @('-NoProfile','-ExecutionPolicy','Bypass','-File',$PSCommandPath)
Start-Process powershell -Verb RunAs -ArgumentList $args
exit
}
function New-RandomSecret([int]$Length = 32) {
$bytes = New-Object byte[] $Length
[System.Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($bytes)
return ([BitConverter]::ToString($bytes) -replace '-','').ToLower().Substring(0, $Length)
}
function Test-Wsl2 {
try {
$out = wsl.exe --status 2>$null
return ($LASTEXITCODE -eq 0)
} catch { return $false }
}
function Test-Docker {
$cmd = Get-Command docker -ErrorAction SilentlyContinue
if (-not $cmd) { return $false }
docker info *>$null
return ($LASTEXITCODE -eq 0)
}
function Wait-Docker([int]$TimeoutSec = 180) {
Write-Step "Attente du demarrage de Docker Desktop (max ${TimeoutSec}s)..."
$deadline = (Get-Date).AddSeconds($TimeoutSec)
while ((Get-Date) -lt $deadline) {
docker info *>$null
if ($LASTEXITCODE -eq 0) { Write-Ok "Docker repond"; return $true }
Start-Sleep -Seconds 3
}
return $false
}
# ---------------------------------------------------------------------------
# 0. Pre-requis admin
# ---------------------------------------------------------------------------
if (-not (Test-Admin)) { Invoke-Elevated }
Write-Host ""
Write-Host "============================================================"
Write-Host " LoreMindMJ - Installeur Windows" -ForegroundColor Magenta
Write-Host "============================================================"
Write-Host ""
# ---------------------------------------------------------------------------
# 1. WSL2
# ---------------------------------------------------------------------------
Write-Step "Verification de WSL2..."
if (Test-Wsl2) {
Write-Ok "WSL2 deja installe"
} else {
Write-Warn2 "WSL2 absent - installation en cours"
wsl.exe --install --no-launch
Write-Warn2 "REDEMARRAGE REQUIS. Relancez ce script apres reboot."
Read-Host "Appuyez sur Entree pour quitter"
exit 1
}
# ---------------------------------------------------------------------------
# 2. Docker Desktop
# ---------------------------------------------------------------------------
Write-Step "Verification de Docker Desktop..."
if (Test-Docker) {
Write-Ok "Docker fonctionnel"
} else {
if (-not (Get-Command winget -ErrorAction SilentlyContinue)) {
Write-Err "winget introuvable. Installez Docker Desktop manuellement : https://www.docker.com/products/docker-desktop/"
exit 1
}
Write-Warn2 "Installation de Docker Desktop via winget..."
winget install --id Docker.DockerDesktop -e --accept-package-agreements --accept-source-agreements
if ($LASTEXITCODE -ne 0) { Write-Err "Echec winget"; exit 1 }
Write-Step "Lancement de Docker Desktop..."
$dd = "$env:ProgramFiles\Docker\Docker\Docker Desktop.exe"
if (Test-Path $dd) { Start-Process $dd }
if (-not (Wait-Docker 240)) {
Write-Err "Docker n'a pas demarre. Lancez-le manuellement puis relancez ce script."
exit 1
}
}
# ---------------------------------------------------------------------------
# 3. Dossier d'installation + docker-compose.yml
# ---------------------------------------------------------------------------
Write-Step "Preparation du dossier $InstallDir"
New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null
Set-Location $InstallDir
$composePath = Join-Path $InstallDir 'docker-compose.yml'
Write-Step "Telechargement de docker-compose.yml"
Invoke-WebRequest -Uri $ComposeUrl -OutFile $composePath -UseBasicParsing
Write-Ok "docker-compose.yml recupere"
# ---------------------------------------------------------------------------
# 4. Generation du .env
# ---------------------------------------------------------------------------
$envPath = Join-Path $InstallDir '.env'
if (Test-Path $envPath) {
Write-Warn2 ".env deja present - sauvegarde en .env.bak"
Copy-Item $envPath "$envPath.bak" -Force
}
Write-Step "Configuration"
$adminUser = if ($NonInteractive) { 'admin' } else {
$r = Read-Host " Nom d'utilisateur admin [admin]"; if ([string]::IsNullOrWhiteSpace($r)) { 'admin' } else { $r }
}
$adminPass = if ($NonInteractive) { New-RandomSecret 16 } else {
$r = Read-Host " Mot de passe admin (vide = genere automatiquement)"
if ([string]::IsNullOrWhiteSpace($r)) { New-RandomSecret 16 } else { $r }
}
$llmProvider = if ($NonInteractive) { 'ollama' } else {
$r = Read-Host " Provider LLM : [ollama] / onemin"
if ($r -eq 'onemin') { 'onemin' } else { 'ollama' }
}
$onemKey = ''
if ($llmProvider -eq 'onemin' -and -not $NonInteractive) {
$onemKey = Read-Host " Cle API 1min.ai"
}
$autoUpdate = if ($NonInteractive) { $true } else {
$r = Read-Host " Activer les mises a jour auto (chaque nuit a 4h) ? [O/n]"
-not ($r -match '^(n|N|no|non)$')
}
$composeProfiles = if ($autoUpdate) { 'autoupdate' } else { '' }
$envContent = @"
# Genere par install.ps1 le $(Get-Date -Format 'yyyy-MM-dd HH:mm')
REGISTRY=git.igmlcreation.fr
TAG=latest
WEB_PORT=$WebPort
POSTGRES_DB=loremind
POSTGRES_USER=loremind
POSTGRES_PASSWORD=$(New-RandomSecret 24)
ADMIN_USERNAME=$adminUser
ADMIN_PASSWORD=$adminPass
BRAIN_INTERNAL_SECRET=$(New-RandomSecret 32)
MINIO_USER=minioadmin
MINIO_PASSWORD=$(New-RandomSecret 24)
LLM_PROVIDER=$llmProvider
OLLAMA_BASE_URL=http://host.docker.internal:11434
LLM_MODEL=gemma4:26b
ONEMIN_API_KEY=$onemKey
ONEMIN_MODEL=gpt-4o-mini
COMPOSE_PROFILES=$composeProfiles
WATCHTOWER_TOKEN=$(New-RandomSecret 32)
WATCHTOWER_MONITOR_ONLY=false
WATCHTOWER_SCHEDULE=0 0 4 * * *
TZ=Europe/Paris
"@
Set-Content -Path $envPath -Value $envContent -Encoding UTF8
Write-Ok ".env genere ($envPath)"
# ---------------------------------------------------------------------------
# 5. Pull + up
# ---------------------------------------------------------------------------
Write-Step "Telechargement des images Docker (peut prendre quelques minutes)"
docker compose pull
if ($LASTEXITCODE -ne 0) { Write-Err "Echec docker compose pull"; exit 1 }
Write-Step "Demarrage de la stack"
docker compose up -d
if ($LASTEXITCODE -ne 0) { Write-Err "Echec docker compose up"; exit 1 }
# ---------------------------------------------------------------------------
# 6. Recap
# ---------------------------------------------------------------------------
$url = "http://localhost:$WebPort"
Write-Host ""
Write-Host "============================================================" -ForegroundColor Green
Write-Host " LoreMindMJ est lance !" -ForegroundColor Green
Write-Host "============================================================" -ForegroundColor Green
Write-Host " URL : $url"
Write-Host " Identifiant : $adminUser"
Write-Host " Mot de passe : $adminPass"
Write-Host " Dossier : $InstallDir"
if ($autoUpdate) {
Write-Host " Auto-update : active (chaque nuit a 4h via Watchtower)" -ForegroundColor Green
} else {
Write-Host " Auto-update : desactive (mise a jour manuelle uniquement)"
}
Write-Host ""
Write-Host " Commandes utiles (depuis $InstallDir) :"
Write-Host " docker compose ps # etat"
Write-Host " docker compose logs -f # logs"
Write-Host " docker compose down # arret"
Write-Host " docker compose pull && docker compose up -d # mise a jour"
Write-Host ""
Start-Process $url

195
installers/install.sh Normal file
View File

@@ -0,0 +1,195 @@
#!/usr/bin/env bash
# ==========================================================================
# Installeur LoreMindMJ pour Linux (Debian/Ubuntu/Fedora/Arch)
# Usage :
# curl -fsSL https://git.igmlcreation.fr/ietm64/loremind/raw/branch/main/installers/install.sh | bash
# ==========================================================================
set -euo pipefail
INSTALL_DIR="${INSTALL_DIR:-$HOME/.local/share/loremind}"
COMPOSE_URL="${COMPOSE_URL:-https://git.igmlcreation.fr/ietm64/loremind/raw/branch/main/docker-compose.yml}"
WEB_PORT="${WEB_PORT:-8081}"
NON_INTERACTIVE="${NON_INTERACTIVE:-0}"
c_cyan='\033[1;36m'; c_green='\033[1;32m'; c_yellow='\033[1;33m'; c_red='\033[1;31m'; c_off='\033[0m'
step() { echo -e "${c_cyan}==> $*${c_off}"; }
ok() { echo -e " ${c_green}OK${c_off} $*"; }
warn() { echo -e " ${c_yellow}!!${c_off} $*"; }
err() { echo -e " ${c_red}XX${c_off} $*" >&2; }
rand_hex() {
# $1 = nb de caracteres hex
local n="${1:-32}"
if command -v openssl >/dev/null 2>&1; then
openssl rand -hex $((n / 2))
else
head -c $((n * 2)) /dev/urandom | od -An -tx1 | tr -d ' \n' | head -c "$n"
fi
}
ask() {
# ask "prompt" "default"
local prompt="$1" def="${2:-}" reply
if [ "$NON_INTERACTIVE" = "1" ]; then
echo "$def"; return
fi
if [ -n "$def" ]; then
read -r -p " $prompt [$def] " reply </dev/tty || true
else
read -r -p " $prompt " reply </dev/tty || true
fi
echo "${reply:-$def}"
}
detect_pkg() {
if command -v apt-get >/dev/null 2>&1; then echo apt
elif command -v dnf >/dev/null 2>&1; then echo dnf
elif command -v pacman >/dev/null 2>&1; then echo pacman
else echo unknown
fi
}
install_docker() {
step "Installation de Docker..."
local pm; pm="$(detect_pkg)"
case "$pm" in
apt|dnf|pacman)
# Script officiel Docker (gere apt/dnf/pacman)
curl -fsSL https://get.docker.com | sh
;;
*)
err "Gestionnaire de paquets non reconnu. Installez Docker manuellement : https://docs.docker.com/engine/install/"
exit 1
;;
esac
if ! getent group docker >/dev/null; then sudo groupadd docker || true; fi
sudo usermod -aG docker "$USER" || true
sudo systemctl enable --now docker || true
warn "Vous avez ete ajoute au groupe 'docker'. Si docker echoue ensuite, deconnectez-vous puis reconnectez-vous (ou 'newgrp docker')."
}
# ---------------------------------------------------------------------------
echo
echo "============================================================"
echo -e " ${c_cyan}LoreMindMJ - Installeur Linux${c_off}"
echo "============================================================"
echo
# 1. Docker
step "Verification de Docker..."
if ! command -v docker >/dev/null 2>&1; then
install_docker
elif ! docker info >/dev/null 2>&1; then
warn "Docker installe mais inaccessible (daemon arrete ou groupe docker manquant)"
sudo systemctl start docker || true
if ! docker info >/dev/null 2>&1; then
sudo usermod -aG docker "$USER" || true
err "Re-essayez apres 'newgrp docker' ou une nouvelle session."
exit 1
fi
fi
ok "Docker fonctionnel"
# 2. docker compose v2
step "Verification de docker compose..."
if ! docker compose version >/dev/null 2>&1; then
err "Plugin 'docker compose' manquant. Sur Debian/Ubuntu : sudo apt install docker-compose-plugin"
exit 1
fi
ok "docker compose disponible"
# 3. Dossier + compose
step "Preparation du dossier $INSTALL_DIR"
mkdir -p "$INSTALL_DIR"
cd "$INSTALL_DIR"
step "Telechargement de docker-compose.yml"
curl -fsSL "$COMPOSE_URL" -o docker-compose.yml
ok "docker-compose.yml recupere"
# 4. .env
if [ -f .env ]; then
warn ".env existant -> sauvegarde en .env.bak"
cp .env .env.bak
fi
step "Configuration"
ADMIN_USERNAME="$(ask "Nom d'utilisateur admin" "admin")"
ADMIN_PASSWORD="$(ask "Mot de passe admin (vide = genere)" "")"
[ -z "$ADMIN_PASSWORD" ] && ADMIN_PASSWORD="$(rand_hex 16)"
LLM_PROVIDER="$(ask "Provider LLM (ollama / onemin)" "ollama")"
ONEMIN_API_KEY=""
if [ "$LLM_PROVIDER" = "onemin" ] && [ "$NON_INTERACTIVE" != "1" ]; then
ONEMIN_API_KEY="$(ask "Cle API 1min.ai" "")"
fi
AUTO_UPDATE_REPLY="$(ask "Activer les mises a jour auto (chaque nuit a 4h) ? [O/n]" "O")"
case "$AUTO_UPDATE_REPLY" in
n|N|no|non|No|Non) COMPOSE_PROFILES="" ; AUTO_UPDATE=0 ;;
*) COMPOSE_PROFILES="autoupdate" ; AUTO_UPDATE=1 ;;
esac
cat > .env <<EOF
# Genere par install.sh le $(date '+%Y-%m-%d %H:%M')
REGISTRY=git.igmlcreation.fr
TAG=latest
WEB_PORT=${WEB_PORT}
POSTGRES_DB=loremind
POSTGRES_USER=loremind
POSTGRES_PASSWORD=$(rand_hex 24)
ADMIN_USERNAME=${ADMIN_USERNAME}
ADMIN_PASSWORD=${ADMIN_PASSWORD}
BRAIN_INTERNAL_SECRET=$(rand_hex 32)
MINIO_USER=minioadmin
MINIO_PASSWORD=$(rand_hex 24)
LLM_PROVIDER=${LLM_PROVIDER}
OLLAMA_BASE_URL=http://host.docker.internal:11434
LLM_MODEL=gemma4:26b
ONEMIN_API_KEY=${ONEMIN_API_KEY}
ONEMIN_MODEL=gpt-4o-mini
COMPOSE_PROFILES=${COMPOSE_PROFILES}
WATCHTOWER_TOKEN=$(rand_hex 32)
WATCHTOWER_MONITOR_ONLY=false
WATCHTOWER_SCHEDULE=0 0 4 * * *
TZ=$(timedatectl show -p Timezone --value 2>/dev/null || echo Europe/Paris)
EOF
chmod 600 .env
ok ".env genere ($INSTALL_DIR/.env)"
# 5. Pull + up
step "Telechargement des images (peut prendre quelques minutes)"
docker compose pull
step "Demarrage de la stack"
docker compose up -d
# 6. Recap
URL="http://localhost:${WEB_PORT}"
echo
echo -e "${c_green}============================================================${c_off}"
echo -e "${c_green} LoreMindMJ est lance !${c_off}"
echo -e "${c_green}============================================================${c_off}"
echo " URL : $URL"
echo " Identifiant : $ADMIN_USERNAME"
echo " Mot de passe : $ADMIN_PASSWORD"
echo " Dossier : $INSTALL_DIR"
if [ "$AUTO_UPDATE" = "1" ]; then
echo -e " Auto-update : ${c_green}active${c_off} (chaque nuit a 4h via Watchtower)"
else
echo " Auto-update : desactive (mise a jour manuelle uniquement)"
fi
echo
echo " Commandes utiles (depuis $INSTALL_DIR) :"
echo " docker compose ps # etat"
echo " docker compose logs -f # logs"
echo " docker compose down # arret"
echo " docker compose pull && docker compose up -d # mise a jour"
echo
if command -v xdg-open >/dev/null 2>&1; then xdg-open "$URL" >/dev/null 2>&1 || true; fi

4
web/package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "loremind-web",
"version": "0.6.5",
"version": "0.6.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "loremind-web",
"version": "0.6.5",
"version": "0.6.6",
"dependencies": {
"@angular/animations": "^17.0.0",
"@angular/common": "^17.0.0",

View File

@@ -1,6 +1,6 @@
{
"name": "loremind-web",
"version": "0.6.5",
"version": "0.6.6",
"description": "LoreMind Frontend - Angular",
"scripts": {
"ng": "ng",

View File

@@ -8,11 +8,12 @@ import { firstValueFrom } from 'rxjs';
*/
export interface PublicConfig {
demoMode: boolean;
updateCheckEnabled: boolean;
}
@Injectable({ providedIn: 'root' })
export class ConfigService {
private config: PublicConfig = { demoMode: false };
private config: PublicConfig = { demoMode: false, updateCheckEnabled: false };
constructor(private http: HttpClient) {}
@@ -28,4 +29,8 @@ export class ConfigService {
get demoMode(): boolean {
return this.config.demoMode;
}
get updateCheckEnabled(): boolean {
return this.config.updateCheckEnabled;
}
}

View File

@@ -0,0 +1,62 @@
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { BehaviorSubject, Observable, catchError, of, tap } from 'rxjs';
/**
* Reflet de UpdateCheckService.UpdateStatus cote backend.
*/
export interface ImageStatus {
image: string;
localDigest: string | null;
remoteDigest: string | null;
updateAvailable: boolean;
}
export interface UpdateStatus {
enabled: boolean;
updateAvailable: boolean;
images: ImageStatus[];
checkedAt: string;
}
/**
* Service de detection / declenchement des mises a jour des conteneurs
* LoreMind. Endpoints proteges par HTTP Basic (admin) — withCredentials
* comme pour SettingsService.
*
* `updateAvailable$` est un signal global consomme par la sidebar pour
* afficher un badge. Il est rafraichi via {@link checkNow}.
*/
@Injectable({ providedIn: 'root' })
export class UpdatesService {
private readonly apiUrl = '/api/admin/updates';
private readonly authOptions = { withCredentials: true };
private readonly _updateAvailable$ = new BehaviorSubject<boolean>(false);
readonly updateAvailable$ = this._updateAvailable$.asObservable();
constructor(private http: HttpClient) {}
/**
* Interroge le backend. Met a jour `updateAvailable$` au passage.
* Renvoie `null` en cas d'erreur (pas authentifie, feature off, etc.)
* pour ne pas faire crasher l'UI au boot.
*/
checkNow(): Observable<UpdateStatus | null> {
return this.http.get<UpdateStatus>(`${this.apiUrl}/check`, this.authOptions).pipe(
tap(s => this._updateAvailable$.next(!!s?.updateAvailable)),
catchError(() => {
this._updateAvailable$.next(false);
return of(null);
})
);
}
apply(): Observable<{ status: string; message: string } | null> {
return this.http.post<{ status: string; message: string }>(
`${this.apiUrl}/apply`, null, this.authOptions
).pipe(
catchError(() => of(null))
);
}
}

View File

@@ -148,4 +148,54 @@
</button>
</div>
<!-- Bloc Mises a jour -->
<section class="card" *ngIf="config.updateCheckEnabled">
<h2>Mises a jour</h2>
<p class="hint">Verifie aupres du registry Docker si une nouvelle version
des conteneurs (core, brain, web) est disponible. Postgres et MinIO sont
exclus — ils sont mis a jour manuellement.</p>
<div class="form-row">
<button type="button" class="btn-secondary" (click)="checkUpdates()" [disabled]="updateChecking">
<lucide-icon [img]="RefreshCw" [size]="14"></lucide-icon>
<span>{{ updateChecking ? 'Verification...' : 'Verifier maintenant' }}</span>
</button>
</div>
<div *ngIf="updateStatus && !updateStatus.enabled" class="hint">
Feature non configuree (WATCHTOWER_TOKEN absent).
</div>
<div *ngIf="updateStatus?.enabled">
<div *ngIf="updateStatus?.updateAvailable" class="alert alert-success">
<lucide-icon [img]="Download" [size]="16"></lucide-icon>
<span>Une mise a jour est disponible.</span>
</div>
<div *ngIf="!updateStatus?.updateAvailable" class="hint">
Tout est a jour (verifie le {{ updateStatus?.checkedAt | date:'short' }}).
</div>
<ul class="update-images" *ngIf="updateStatus?.images?.length">
<li *ngFor="let img of updateStatus?.images">
<strong>{{ img.image }}</strong>
<span *ngIf="img.updateAvailable" class="badge-update">MAJ dispo</span>
<span *ngIf="!img.updateAvailable && img.remoteDigest" class="badge-ok">a jour</span>
<span *ngIf="!img.remoteDigest" class="badge-warn">indisponible</span>
</li>
</ul>
<div class="form-row" *ngIf="updateStatus?.updateAvailable">
<button type="button" class="btn-primary" (click)="applyUpdate()" [disabled]="updateApplying">
<lucide-icon [img]="Download" [size]="16"></lucide-icon>
<span>{{ updateApplying ? 'Mise a jour en cours...' : 'Mettre a jour maintenant' }}</span>
</button>
</div>
<div *ngIf="updateMessage" class="alert alert-success">
<lucide-icon [img]="Check" [size]="16"></lucide-icon>
<span>{{ updateMessage }}</span>
</div>
</div>
</section>
</div>

View File

@@ -153,3 +153,46 @@
width: 100%;
accent-color: #6c63ff;
}
.update-images {
list-style: none;
padding: 0;
margin: 0.75rem 0;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.update-images li {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.4rem 0.6rem;
background: rgba(255, 255, 255, 0.03);
border-radius: 4px;
font-size: 0.875rem;
}
.badge-update {
margin-left: auto;
background: #6c63ff;
color: white;
font-size: 0.7rem;
font-weight: 700;
padding: 0.15rem 0.5rem;
border-radius: 3px;
}
.badge-ok {
margin-left: auto;
background: rgba(76, 175, 80, 0.2);
color: #81c784;
font-size: 0.7rem;
padding: 0.15rem 0.5rem;
border-radius: 3px;
}
.badge-warn {
margin-left: auto;
background: rgba(255, 152, 0, 0.2);
color: #ffb74d;
font-size: 0.7rem;
padding: 0.15rem 0.5rem;
border-radius: 3px;
}

View File

@@ -2,8 +2,10 @@ import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { LucideAngularModule, ArrowLeft, RefreshCw, Save, Check, AlertCircle } from 'lucide-angular';
import { LucideAngularModule, ArrowLeft, RefreshCw, Save, Check, AlertCircle, Download } from 'lucide-angular';
import { SettingsService, AppSettings, AppSettingsUpdate, OneMinModelGroup } from '../services/settings.service';
import { UpdatesService, UpdateStatus } from '../services/updates.service';
import { ConfigService } from '../services/config.service';
/**
* Ecran de parametrage du LLM utilise par le Brain.
@@ -30,6 +32,13 @@ export class SettingsComponent implements OnInit {
readonly Save = Save;
readonly Check = Check;
readonly AlertCircle = AlertCircle;
readonly Download = Download;
// Mises a jour conteneurs
updateStatus: UpdateStatus | null = null;
updateChecking = false;
updateApplying = false;
updateMessage = '';
settings: AppSettings | null = null;
ollamaModels: string[] = [];
@@ -61,11 +70,51 @@ export class SettingsComponent implements OnInit {
constructor(
private settingsService: SettingsService,
private router: Router
private router: Router,
private updatesService: UpdatesService,
public config: ConfigService
) {}
ngOnInit(): void {
this.loadSettings();
if (this.config.updateCheckEnabled) {
this.checkUpdates();
}
}
checkUpdates(): void {
this.updateChecking = true;
this.updateMessage = '';
this.updatesService.checkNow().subscribe({
next: (s) => {
this.updateStatus = s;
this.updateChecking = false;
},
error: () => {
this.updateChecking = false;
}
});
}
applyUpdate(): void {
if (!confirm('Telecharger et redemarrer les conteneurs maintenant ? L\'app sera indisponible quelques secondes.')) {
return;
}
this.updateApplying = true;
this.updateMessage = '';
this.updatesService.apply().subscribe({
next: (r) => {
this.updateApplying = false;
// Le redemarrage de core peut couper la connexion avant la reponse —
// dans ce cas r vaut null (gere par catchError dans le service).
this.updateMessage = r?.message
?? 'Mise a jour declenchee. Rechargez la page dans 30s.';
},
error: () => {
this.updateApplying = false;
this.updateMessage = 'Mise a jour declenchee. Rechargez la page dans 30s.';
}
});
}
loadSettings(): void {

View File

@@ -60,6 +60,7 @@
<button class="tool-btn" *ngIf="!config.demoMode" [class.active]="currentRoute.startsWith('/settings')" (click)="navigateTo('/settings')">
<lucide-icon [img]="Settings" [size]="16"></lucide-icon>
<span>Paramètres</span>
<span class="update-badge" *ngIf="updateAvailable$ | async" title="Mise a jour disponible">MAJ</span>
</button>
</div>

View File

@@ -178,6 +178,23 @@
border: 1px solid #3a3f55;
}
.update-badge {
margin-left: auto;
font-size: 0.65rem;
font-weight: 700;
letter-spacing: 0.05em;
background: #6c63ff;
color: white;
padding: 0.15rem 0.4rem;
border-radius: 3px;
animation: update-pulse 2s ease-in-out infinite;
}
@keyframes update-pulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(108, 99, 255, 0.5); }
50% { box-shadow: 0 0 0 4px rgba(108, 99, 255, 0); }
}
.sidebar-footer {
padding-top: 1rem;
border-top: 1px solid #1e1e3a;

View File

@@ -1,10 +1,11 @@
import { Component } from '@angular/core';
import { Component, OnInit } from '@angular/core';
import { AsyncPipe, NgIf, NgFor } from '@angular/common';
import { Router } from '@angular/router';
import { LucideAngularModule, Search, Download, Settings, ArrowLeft, Dices } from 'lucide-angular';
import { LayoutService } from '../services/layout.service';
import { GlobalSearchService } from '../services/global-search.service';
import { ConfigService } from '../services/config.service';
import { UpdatesService } from '../services/updates.service';
// Single source of truth pour la version affichée dans le footer :
// on lit directement package.json à la compilation (resolveJsonModule).
import packageJson from '../../../package.json';
@@ -16,7 +17,7 @@ import packageJson from '../../../package.json';
templateUrl: './sidebar.component.html',
styleUrls: ['./sidebar.component.scss']
})
export class SidebarComponent {
export class SidebarComponent implements OnInit {
currentRoute = '';
readonly Search = Search;
@@ -27,18 +28,30 @@ export class SidebarComponent {
readonly layoutConfig$ = this.layoutService.secondarySidebar$;
readonly appVersion = packageJson.version;
readonly updateAvailable$ = this.updates.updateAvailable$;
constructor(
private router: Router,
private layoutService: LayoutService,
private globalSearch: GlobalSearchService,
public config: ConfigService
public config: ConfigService,
private updates: UpdatesService
) {
this.router.events.subscribe(() => {
this.currentRoute = this.router.url;
});
}
ngOnInit(): void {
// Premier check au boot uniquement si la feature est activee + mode non-demo.
// L'erreur 401 (admin non auth) est silencieusement ignoree par le service —
// le badge ne s'affichera que si l'utilisateur est passe par /settings et a
// saisi ses credentials HTTP Basic. Comportement attendu mono-utilisateur.
if (this.config.updateCheckEnabled && !this.config.demoMode) {
this.updates.checkNow().subscribe();
}
}
navigateTo(route: string): void {
this.router.navigate([route]);
}