From 41fda9aeeeca94479626df0d8c11be5c1af9b0a4 Mon Sep 17 00:00:00 2001 From: "IETM_FIXE\\ietm6" Date: Sat, 25 Apr 2026 13:24:32 +0200 Subject: [PATCH] =?UTF-8?q?Ajout=20d'un=20script=20pour=20installation=20a?= =?UTF-8?q?utomatique=20du=20produit=20Ajout=20d'une=20partie=20mise=20?= =?UTF-8?q?=C3=A0=20jour=20automatique=20:=20plus=20besoin=20de=20docker?= =?UTF-8?q?=20pull=20en=20ligne=20de=20commande=20;=20on=20peut=20passer?= =?UTF-8?q?=20par=20l'interface=20Refactoring=20partie=20Java=20pour=20res?= =?UTF-8?q?pecter=20d'avantage=20le=20DDD=20:=20plus=20de=20jackson=20dans?= =?UTF-8?q?=20la=20partie=20domain?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Passage version 0.6.6 --- .env.example | 12 + brain/app/main.py | 2 +- core/pom.xml | 2 +- .../campaigncontext/SceneService.java | 2 +- .../GameSystemContextBuilder.java | 6 +- .../CampaignStructuralContextBuilder.java | 59 ++-- .../GeneratePageValuesUseCase.java | 19 +- .../LoreStructuralContextBuilder.java | 24 +- .../NarrativeEntityContextBuilder.java | 24 +- .../StreamChatForLoreUseCase.java | 7 +- .../domain/campaigncontext/SceneBranch.java | 32 +- .../CampaignStructuralContext.java | 89 +++--- .../domain/generationcontext/ChatRequest.java | 83 +++-- .../generationcontext/GameSystemContext.java | 27 +- .../generationcontext/GenerationContext.java | 28 +- .../LoreStructuralContext.java | 33 +- .../NarrativeEntityContext.java | 17 +- .../domain/generationcontext/PageContext.java | 18 +- .../infrastructure/ai/BrainAiClient.java | 12 +- .../ai/BrainChatPayloadBuilder.java | 118 ++++---- .../updates/UpdateCheckService.java | 283 ++++++++++++++++++ .../web/config/SecurityConfig.java | 1 + .../web/controller/ConfigController.java | 10 +- .../web/controller/UpdatesController.java | 76 +++++ .../web/mapper/SceneMapper.java | 8 +- .../src/main/resources/application.properties | 8 + .../campaigncontext/SceneServiceTest.java | 25 +- .../CampaignStructuralContextBuilderTest.java | 53 ++-- .../GeneratePageValuesUseCaseTest.java | 12 +- .../LoreStructuralContextBuilderTest.java | 52 ++-- .../NarrativeEntityContextBuilderTest.java | 52 ++-- .../StreamChatForCampaignUseCaseTest.java | 27 +- .../StreamChatForLoreUseCaseTest.java | 39 ++- .../campaigncontext/SceneBranchTest.java | 48 ++- .../domain/campaigncontext/SceneTest.java | 8 +- .../CampaignStructuralContextTest.java | 119 ++++---- .../generationcontext/ChatRequestTest.java | 65 ++-- .../GenerationContextTest.java | 39 ++- .../LoreStructuralContextTest.java | 79 +++-- .../NarrativeEntityContextTest.java | 28 +- .../generationcontext/PageContextTest.java | 36 ++- .../ai/BrainChatPayloadBuilderTest.java | 106 +++---- .../SceneBranchListJsonConverterTest.java | 24 +- .../postgres/PostgresSceneRepositoryTest.java | 16 +- docker-compose.yml | 41 +++ installers/README.md | 109 +++++++ installers/install.ps1 | 240 +++++++++++++++ installers/install.sh | 195 ++++++++++++ web/package-lock.json | 4 +- web/package.json | 2 +- web/src/app/services/config.service.ts | 7 +- web/src/app/services/updates.service.ts | 62 ++++ web/src/app/settings/settings.component.html | 50 ++++ web/src/app/settings/settings.component.scss | 43 +++ web/src/app/settings/settings.component.ts | 53 +++- web/src/app/sidebar/sidebar.component.html | 1 + web/src/app/sidebar/sidebar.component.scss | 17 ++ web/src/app/sidebar/sidebar.component.ts | 19 +- 58 files changed, 1859 insertions(+), 812 deletions(-) create mode 100644 core/src/main/java/com/loremind/infrastructure/updates/UpdateCheckService.java create mode 100644 core/src/main/java/com/loremind/infrastructure/web/controller/UpdatesController.java create mode 100644 installers/README.md create mode 100644 installers/install.ps1 create mode 100644 installers/install.sh create mode 100644 web/src/app/services/updates.service.ts diff --git a/.env.example b/.env.example index 8ca0b1d..bd0ec26 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/brain/app/main.py b/brain/app/main.py index 24f22f5..10e88b0 100644 --- a/brain/app/main.py +++ b/brain/app/main.py @@ -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", ) diff --git a/core/pom.xml b/core/pom.xml index 750ab69..3ed2dd1 100644 --- a/core/pom.xml +++ b/core/pom.xml @@ -14,7 +14,7 @@ com.loremind loremind-core - 0.6.5 + 0.6.6 LoreMind Core Backend Core - Architecture Hexagonale diff --git a/core/src/main/java/com/loremind/application/campaigncontext/SceneService.java b/core/src/main/java/com/loremind/application/campaigncontext/SceneService.java index b6d6ac3..927e23d 100644 --- a/core/src/main/java/com/loremind/application/campaigncontext/SceneService.java +++ b/core/src/main/java/com/loremind/application/campaigncontext/SceneService.java @@ -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"); } diff --git a/core/src/main/java/com/loremind/application/gamesystemcontext/GameSystemContextBuilder.java b/core/src/main/java/com/loremind/application/gamesystemcontext/GameSystemContextBuilder.java index ba959f3..4b05b1f 100644 --- a/core/src/main/java/com/loremind/application/gamesystemcontext/GameSystemContextBuilder.java +++ b/core/src/main/java/com/loremind/application/gamesystemcontext/GameSystemContextBuilder.java @@ -45,11 +45,7 @@ public class GameSystemContextBuilder { private GameSystemContext build(GameSystem gs, GenerationIntent intent) { Map allSections = parseH2Sections(gs.getRulesMarkdown()); Map filtered = filterByIntent(allSections, intent); - return GameSystemContext.builder() - .systemName(gs.getName()) - .systemDescription(gs.getDescription()) - .sections(filtered) - .build(); + return new GameSystemContext(gs.getName(), gs.getDescription(), filtered); } /** diff --git a/core/src/main/java/com/loremind/application/generationcontext/CampaignStructuralContextBuilder.java b/core/src/main/java/com/loremind/application/generationcontext/CampaignStructuralContextBuilder.java index 8e919d4..28ff306 100644 --- a/core/src/main/java/com/loremind/application/generationcontext/CampaignStructuralContextBuilder.java +++ b/core/src/main/java/com/loremind/application/generationcontext/CampaignStructuralContextBuilder.java @@ -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 nameById) { List 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). */ diff --git a/core/src/main/java/com/loremind/application/generationcontext/GeneratePageValuesUseCase.java b/core/src/main/java/com/loremind/application/generationcontext/GeneratePageValuesUseCase.java index 71c334b..a676055 100644 --- a/core/src/main/java/com/loremind/application/generationcontext/GeneratePageValuesUseCase.java +++ b/core/src/main/java/com/loremind/application/generationcontext/GeneratePageValuesUseCase.java @@ -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(); diff --git a/core/src/main/java/com/loremind/application/generationcontext/LoreStructuralContextBuilder.java b/core/src/main/java/com/loremind/application/generationcontext/LoreStructuralContextBuilder.java index ee665a1..32d58ad 100644 --- a/core/src/main/java/com/loremind/application/generationcontext/LoreStructuralContextBuilder.java +++ b/core/src/main/java/com/loremind/application/generationcontext/LoreStructuralContextBuilder.java @@ -82,12 +82,11 @@ public class LoreStructuralContextBuilder { Map 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> buildFoldersMap( @@ -118,13 +117,12 @@ public class LoreStructuralContextBuilder { Page page, Map templateNameById, Map 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)); } /** diff --git a/core/src/main/java/com/loremind/application/generationcontext/NarrativeEntityContextBuilder.java b/core/src/main/java/com/loremind/application/generationcontext/NarrativeEntityContextBuilder.java index 149e6f5..54256a5 100644 --- a/core/src/main/java/com/loremind/application/generationcontext/NarrativeEntityContextBuilder.java +++ b/core/src/main/java/com/loremind/application/generationcontext/NarrativeEntityContextBuilder.java @@ -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 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. */ diff --git a/core/src/main/java/com/loremind/application/generationcontext/StreamChatForLoreUseCase.java b/core/src/main/java/com/loremind/application/generationcontext/StreamChatForLoreUseCase.java index 3668e30..7c6cfb2 100644 --- a/core/src/main/java/com/loremind/application/generationcontext/StreamChatForLoreUseCase.java +++ b/core/src/main/java/com/loremind/application/generationcontext/StreamChatForLoreUseCase.java @@ -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); } } diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/SceneBranch.java b/core/src/main/java/com/loremind/domain/campaigncontext/SceneBranch.java index c587d21..186751c 100644 --- a/core/src/main/java/com/loremind/domain/campaigncontext/SceneBranch.java +++ b/core/src/main/java/com/loremind/domain/campaigncontext/SceneBranch.java @@ -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. *

- * 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. *

* 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); + } } diff --git a/core/src/main/java/com/loremind/domain/generationcontext/CampaignStructuralContext.java b/core/src/main/java/com/loremind/domain/generationcontext/CampaignStructuralContext.java index f6fbbe7..02e02fe 100644 --- a/core/src/main/java/com/loremind/domain/generationcontext/CampaignStructuralContext.java +++ b/core/src/main/java/com/loremind/domain/generationcontext/CampaignStructuralContext.java @@ -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; *

* La liste `arcs` préserve l'ordre narratif (tri sur `order` ascendant * fait par le use case côté application layer). + *

+ * 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 arcs; - /** Personnages joueurs (PJ) de la campagne. Vide si aucun. */ - @Singular List characters; +public record CampaignStructuralContext( + String campaignName, + String campaignDescription, + List arcs, + List 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 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 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 scenes; + public record ChapterSummary( + String name, + String description, + int illustrationCount, + List 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 branches; + public record SceneSummary( + String name, + String description, + int illustrationCount, + List 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) { } } diff --git a/core/src/main/java/com/loremind/domain/generationcontext/ChatRequest.java b/core/src/main/java/com/loremind/domain/generationcontext/ChatRequest.java index ce002ab..48f5d1a 100644 --- a/core/src/main/java/com/loremind/domain/generationcontext/ChatRequest.java +++ b/core/src/main/java/com/loremind/domain/generationcontext/ChatRequest.java @@ -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). + *

+ * 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 messages, + LoreStructuralContext loreContext, + PageContext pageContext, + CampaignStructuralContext campaignContext, + NarrativeEntityContext narrativeEntity, + GameSystemContext gameSystemContext) { - List 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 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 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); + } + } } diff --git a/core/src/main/java/com/loremind/domain/generationcontext/GameSystemContext.java b/core/src/main/java/com/loremind/domain/generationcontext/GameSystemContext.java index 8e66836..e27e518 100644 --- a/core/src/main/java/com/loremind/domain/generationcontext/GameSystemContext.java +++ b/core/src/main/java/com/loremind/domain/generationcontext/GameSystemContext.java @@ -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 sections; +public record GameSystemContext( + String systemName, + String systemDescription, + Map sections) { } diff --git a/core/src/main/java/com/loremind/domain/generationcontext/GenerationContext.java b/core/src/main/java/com/loremind/domain/generationcontext/GenerationContext.java index 2f0e5ae..dc5b49e 100644 --- a/core/src/main/java/com/loremind/domain/generationcontext/GenerationContext.java +++ b/core/src/main/java/com/loremind/domain/generationcontext/GenerationContext.java @@ -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. *

* Équivalent Java du PageGenerationContext Python (brain/app/domain/models.py). - * Entité pure du domaine : aucune dépendance technique. - *

- * 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 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 templateFields, + String pageTitle) { } diff --git a/core/src/main/java/com/loremind/domain/generationcontext/LoreStructuralContext.java b/core/src/main/java/com/loremind/domain/generationcontext/LoreStructuralContext.java index a404456..3603656 100644 --- a/core/src/main/java/com/loremind/domain/generationcontext/LoreStructuralContext.java +++ b/core/src/main/java/com/loremind/domain/generationcontext/LoreStructuralContext.java @@ -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; *

* 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). + *

+ * Record Java : pur domaine, aucune dépendance technique. */ -@Value -@Builder -public class LoreStructuralContext { - - String loreName; - String loreDescription; - Map> folders; - @Singular List tags; +public record LoreStructuralContext( + String loreName, + String loreDescription, + Map> folders, + List 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 values; - List tags; - List relatedPageTitles; + public record PageSummary( + String title, + String templateName, + Map values, + List tags, + List relatedPageTitles) { } } diff --git a/core/src/main/java/com/loremind/domain/generationcontext/NarrativeEntityContext.java b/core/src/main/java/com/loremind/domain/generationcontext/NarrativeEntityContext.java index e47ac76..f2000f6 100644 --- a/core/src/main/java/com/loremind/domain/generationcontext/NarrativeEntityContext.java +++ b/core/src/main/java/com/loremind/domain/generationcontext/NarrativeEntityContext.java @@ -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 fields; +public record NarrativeEntityContext( + String entityType, + String title, + Map fields) { } diff --git a/core/src/main/java/com/loremind/domain/generationcontext/PageContext.java b/core/src/main/java/com/loremind/domain/generationcontext/PageContext.java index f0129ca..b5d9e6c 100644 --- a/core/src/main/java/com/loremind/domain/generationcontext/PageContext.java +++ b/core/src/main/java/com/loremind/domain/generationcontext/PageContext.java @@ -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. *

- * 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 templateFields; - Map values; +public record PageContext( + String title, + String templateName, + List templateFields, + Map values) { } diff --git a/core/src/main/java/com/loremind/infrastructure/ai/BrainAiClient.java b/core/src/main/java/com/loremind/infrastructure/ai/BrainAiClient.java index 25cd1f2..9e4b6d9 100644 --- a/core/src/main/java/com/loremind/infrastructure/ai/BrainAiClient.java +++ b/core/src/main/java/com/loremind/infrastructure/ai/BrainAiClient.java @@ -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() ); } diff --git a/core/src/main/java/com/loremind/infrastructure/ai/BrainChatPayloadBuilder.java b/core/src/main/java/com/loremind/infrastructure/ai/BrainChatPayloadBuilder.java index 8d8781f..b6eeb3e 100644 --- a/core/src/main/java/com/loremind/infrastructure/ai/BrainChatPayloadBuilder.java +++ b/core/src/main/java/com/loremind/infrastructure/ai/BrainChatPayloadBuilder.java @@ -38,35 +38,35 @@ public class BrainChatPayloadBuilder { public Map build(ChatRequest request) { Map 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 gameSystemContextToMap(GameSystemContext gs) { Map 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 loreContextToMap(LoreStructuralContext ctx) { Map 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 foldersMap = new LinkedHashMap<>(); - for (Map.Entry> e : ctx.getFolders().entrySet()) { + for (Map.Entry> 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 pageSummaryToMap(PageSummary ps) { Map 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 pageContextToMap(PageContext pc) { Map 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 campaignContextToMap(CampaignStructuralContext ctx) { Map 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 characterSummaryToMap(CharacterSummary c) { Map 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 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 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 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 branchHintToMap(BranchHint b) { Map 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 narrativeEntityToMap(NarrativeEntityContext ne) { Map 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; } } diff --git a/core/src/main/java/com/loremind/infrastructure/updates/UpdateCheckService.java b/core/src/main/java/com/loremind/infrastructure/updates/UpdateCheckService.java new file mode 100644 index 0000000..a32e3a3 --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/updates/UpdateCheckService.java @@ -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 desactivee silencieusement 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 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 images; + private final String tag; + private final String watchtowerUrl; + private final String watchtowerToken; + + private final Map 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 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 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 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 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 parseAuthParams(String s) { + Map 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 parseImages(String csv) { + if (csv == null || csv.isBlank()) return List.of(); + List 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 images, + Instant checkedAt) {} + + public record ImageStatus( + String image, + String localDigest, + String remoteDigest, + boolean updateAvailable) {} +} diff --git a/core/src/main/java/com/loremind/infrastructure/web/config/SecurityConfig.java b/core/src/main/java/com/loremind/infrastructure/web/config/SecurityConfig.java index b6e3f69..855b7e4 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/config/SecurityConfig.java +++ b/core/src/main/java/com/loremind/infrastructure/web/config/SecurityConfig.java @@ -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 -> {}); diff --git a/core/src/main/java/com/loremind/infrastructure/web/controller/ConfigController.java b/core/src/main/java/com/loremind/infrastructure/web/controller/ConfigController.java index c6323d5..171c281 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/controller/ConfigController.java +++ b/core/src/main/java/com/loremind/infrastructure/web/controller/ConfigController.java @@ -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 getPublicConfig() { - return Map.of("demoMode", demoMode); + return Map.of( + "demoMode", demoMode, + "updateCheckEnabled", updates.isEnabled()); } } diff --git a/core/src/main/java/com/loremind/infrastructure/web/controller/UpdatesController.java b/core/src/main/java/com/loremind/infrastructure/web/controller/UpdatesController.java new file mode 100644 index 0000000..9871673 --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/web/controller/UpdatesController.java @@ -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> 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"); + } + } +} diff --git a/core/src/main/java/com/loremind/infrastructure/web/mapper/SceneMapper.java b/core/src/main/java/com/loremind/infrastructure/web/mapper/SceneMapper.java index 4cf57e8..d0aeda0 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/mapper/SceneMapper.java +++ b/core/src/main/java/com/loremind/infrastructure/web/mapper/SceneMapper.java @@ -87,18 +87,14 @@ public class SceneMapper { private List toBranchDTOs(List 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 toBranchDomain(List 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()); } } diff --git a/core/src/main/resources/application.properties b/core/src/main/resources/application.properties index 18d9358..951a7fe 100644 --- a/core/src/main/resources/application.properties +++ b/core/src/main/resources/application.properties @@ -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:} diff --git a/core/src/test/java/com/loremind/application/campaigncontext/SceneServiceTest.java b/core/src/test/java/com/loremind/application/campaigncontext/SceneServiceTest.java index 14ecdf4..551a918 100644 --- a/core/src/test/java/com/loremind/application/campaigncontext/SceneServiceTest.java +++ b/core/src/test/java/com/loremind/application/campaigncontext/SceneServiceTest.java @@ -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)) diff --git a/core/src/test/java/com/loremind/application/generationcontext/CampaignStructuralContextBuilderTest.java b/core/src/test/java/com/loremind/application/generationcontext/CampaignStructuralContextBuilderTest.java index dd89b4d..6114243 100644 --- a/core/src/test/java/com/loremind/application/generationcontext/CampaignStructuralContextBuilderTest.java +++ b/core/src/test/java/com/loremind/application/generationcontext/CampaignStructuralContextBuilderTest.java @@ -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()); } } diff --git a/core/src/test/java/com/loremind/application/generationcontext/GeneratePageValuesUseCaseTest.java b/core/src/test/java/com/loremind/application/generationcontext/GeneratePageValuesUseCaseTest.java index 73704e3..5b2924a 100644 --- a/core/src/test/java/com/loremind/application/generationcontext/GeneratePageValuesUseCaseTest.java +++ b/core/src/test/java/com/loremind/application/generationcontext/GeneratePageValuesUseCaseTest.java @@ -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 diff --git a/core/src/test/java/com/loremind/application/generationcontext/LoreStructuralContextBuilderTest.java b/core/src/test/java/com/loremind/application/generationcontext/LoreStructuralContextBuilderTest.java index 3891464..1192270 100644 --- a/core/src/test/java/com/loremind/application/generationcontext/LoreStructuralContextBuilderTest.java +++ b/core/src/test/java/com/loremind/application/generationcontext/LoreStructuralContextBuilderTest.java @@ -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()); } } diff --git a/core/src/test/java/com/loremind/application/generationcontext/NarrativeEntityContextBuilderTest.java b/core/src/test/java/com/loremind/application/generationcontext/NarrativeEntityContextBuilderTest.java index eecf691..82ab6e7 100644 --- a/core/src/test/java/com/loremind/application/generationcontext/NarrativeEntityContextBuilderTest.java +++ b/core/src/test/java/com/loremind/application/generationcontext/NarrativeEntityContextBuilderTest.java @@ -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 diff --git a/core/src/test/java/com/loremind/application/generationcontext/StreamChatForCampaignUseCaseTest.java b/core/src/test/java/com/loremind/application/generationcontext/StreamChatForCampaignUseCaseTest.java index a850efa..42859e4 100644 --- a/core/src/test/java/com/loremind/application/generationcontext/StreamChatForCampaignUseCaseTest.java +++ b/core/src/test/java/com/loremind/application/generationcontext/StreamChatForCampaignUseCaseTest.java @@ -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 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 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 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 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 captor = ArgumentCaptor.forClass(ChatRequest.class); verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any(), any()); - assertNull(captor.getValue().getNarrativeEntity()); + assertNull(captor.getValue().narrativeEntity()); verifyNoInteractions(narrativeEntityContextBuilder); } } diff --git a/core/src/test/java/com/loremind/application/generationcontext/StreamChatForLoreUseCaseTest.java b/core/src/test/java/com/loremind/application/generationcontext/StreamChatForLoreUseCaseTest.java index b40ef08..9968281 100644 --- a/core/src/test/java/com/loremind/application/generationcontext/StreamChatForLoreUseCaseTest.java +++ b/core/src/test/java/com/loremind/application/generationcontext/StreamChatForLoreUseCaseTest.java @@ -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 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 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 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 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 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 diff --git a/core/src/test/java/com/loremind/domain/campaigncontext/SceneBranchTest.java b/core/src/test/java/com/loremind/domain/campaigncontext/SceneBranchTest.java index 6ce488c..8e8a505 100644 --- a/core/src/test/java/com/loremind/domain/campaigncontext/SceneBranchTest.java +++ b/core/src/test/java/com/loremind/domain/campaigncontext/SceneBranchTest.java @@ -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); } diff --git a/core/src/test/java/com/loremind/domain/campaigncontext/SceneTest.java b/core/src/test/java/com/loremind/domain/campaigncontext/SceneTest.java index 2c063cf..bf746a8 100644 --- a/core/src/test/java/com/loremind/domain/campaigncontext/SceneTest.java +++ b/core/src/test/java/com/loremind/domain/campaigncontext/SceneTest.java @@ -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()); } } diff --git a/core/src/test/java/com/loremind/domain/generationcontext/CampaignStructuralContextTest.java b/core/src/test/java/com/loremind/domain/generationcontext/CampaignStructuralContextTest.java index 0f10d85..72ff6a2 100644 --- a/core/src/test/java/com/loremind/domain/generationcontext/CampaignStructuralContextTest.java +++ b/core/src/test/java/com/loremind/domain/generationcontext/CampaignStructuralContextTest.java @@ -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()); } } diff --git a/core/src/test/java/com/loremind/domain/generationcontext/ChatRequestTest.java b/core/src/test/java/com/loremind/domain/generationcontext/ChatRequestTest.java index e0c397a..b677712 100644 --- a/core/src/test/java/com/loremind/domain/generationcontext/ChatRequestTest.java +++ b/core/src/test/java/com/loremind/domain/generationcontext/ChatRequestTest.java @@ -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()); } } diff --git a/core/src/test/java/com/loremind/domain/generationcontext/GenerationContextTest.java b/core/src/test/java/com/loremind/domain/generationcontext/GenerationContextTest.java index 86b8873..41144e1 100644 --- a/core/src/test/java/com/loremind/domain/generationcontext/GenerationContextTest.java +++ b/core/src/test/java/com/loremind/domain/generationcontext/GenerationContextTest.java @@ -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); } } diff --git a/core/src/test/java/com/loremind/domain/generationcontext/LoreStructuralContextTest.java b/core/src/test/java/com/loremind/domain/generationcontext/LoreStructuralContextTest.java index 9b6e3d0..f476db3 100644 --- a/core/src/test/java/com/loremind/domain/generationcontext/LoreStructuralContextTest.java +++ b/core/src/test/java/com/loremind/domain/generationcontext/LoreStructuralContextTest.java @@ -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()); } } diff --git a/core/src/test/java/com/loremind/domain/generationcontext/NarrativeEntityContextTest.java b/core/src/test/java/com/loremind/domain/generationcontext/NarrativeEntityContextTest.java index 42657f3..b0c4e38 100644 --- a/core/src/test/java/com/loremind/domain/generationcontext/NarrativeEntityContextTest.java +++ b/core/src/test/java/com/loremind/domain/generationcontext/NarrativeEntityContextTest.java @@ -16,21 +16,17 @@ import static org.junit.jupiter.api.Assertions.assertNotEquals; class NarrativeEntityContextTest { @Test - void builder_preservesAllFields() { + void constructor_preservesAllFields() { Map 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); } } diff --git a/core/src/test/java/com/loremind/domain/generationcontext/PageContextTest.java b/core/src/test/java/com/loremind/domain/generationcontext/PageContextTest.java index c6348e5..eea54f3 100644 --- a/core/src/test/java/com/loremind/domain/generationcontext/PageContextTest.java +++ b/core/src/test/java/com/loremind/domain/generationcontext/PageContextTest.java @@ -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()); } } diff --git a/core/src/test/java/com/loremind/infrastructure/ai/BrainChatPayloadBuilderTest.java b/core/src/test/java/com/loremind/infrastructure/ai/BrainChatPayloadBuilderTest.java index bc699c4..e100c3d 100644 --- a/core/src/test/java/com/loremind/infrastructure/ai/BrainChatPayloadBuilderTest.java +++ b/core/src/test/java/com/loremind/infrastructure/ai/BrainChatPayloadBuilderTest.java @@ -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 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 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 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 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 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 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 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 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 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) diff --git a/core/src/test/java/com/loremind/infrastructure/persistence/converter/SceneBranchListJsonConverterTest.java b/core/src/test/java/com/loremind/infrastructure/persistence/converter/SceneBranchListJsonConverterTest.java index 57af586..a83187b 100644 --- a/core/src/test/java/com/loremind/infrastructure/persistence/converter/SceneBranchListJsonConverterTest.java +++ b/core/src/test/java/com/loremind/infrastructure/persistence/converter/SceneBranchListJsonConverterTest.java @@ -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 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 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"); } } diff --git a/core/src/test/java/com/loremind/infrastructure/persistence/postgres/PostgresSceneRepositoryTest.java b/core/src/test/java/com/loremind/infrastructure/persistence/postgres/PostgresSceneRepositoryTest.java index 471de25..b7b2738 100644 --- a/core/src/test/java/com/loremind/infrastructure/persistence/postgres/PostgresSceneRepositoryTest.java +++ b/core/src/test/java/com/loremind/infrastructure/persistence/postgres/PostgresSceneRepositoryTest.java @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index 901f8ff..97a754f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/installers/README.md b/installers/README.md new file mode 100644 index 0000000..ac191d8 --- /dev/null +++ b/installers/README.md @@ -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 +docker compose down -v # -v supprime aussi les volumes (données effacées !) +``` + +Puis supprimer le dossier d'installation. diff --git a/installers/install.ps1 b/installers/install.ps1 new file mode 100644 index 0000000..5b4e521 --- /dev/null +++ b/installers/install.ps1 @@ -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 diff --git a/installers/install.sh b/installers/install.sh new file mode 100644 index 0000000..db430a8 --- /dev/null +++ b/installers/install.sh @@ -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/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 </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 diff --git a/web/package-lock.json b/web/package-lock.json index 4f4279d..3e1c0e2 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -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", diff --git a/web/package.json b/web/package.json index a0ee19a..999e706 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "loremind-web", - "version": "0.6.5", + "version": "0.6.6", "description": "LoreMind Frontend - Angular", "scripts": { "ng": "ng", diff --git a/web/src/app/services/config.service.ts b/web/src/app/services/config.service.ts index d5c7924..6e43328 100644 --- a/web/src/app/services/config.service.ts +++ b/web/src/app/services/config.service.ts @@ -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; + } } diff --git a/web/src/app/services/updates.service.ts b/web/src/app/services/updates.service.ts new file mode 100644 index 0000000..b3121e7 --- /dev/null +++ b/web/src/app/services/updates.service.ts @@ -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(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 { + return this.http.get(`${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)) + ); + } +} diff --git a/web/src/app/settings/settings.component.html b/web/src/app/settings/settings.component.html index 98ba74f..8a200f5 100644 --- a/web/src/app/settings/settings.component.html +++ b/web/src/app/settings/settings.component.html @@ -148,4 +148,54 @@ + +

+

Mises a jour

+

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.

+ +
+ +
+ +
+ Feature non configuree (WATCHTOWER_TOKEN absent). +
+ +
+
+ + Une mise a jour est disponible. +
+
+ Tout est a jour (verifie le {{ updateStatus?.checkedAt | date:'short' }}). +
+ +
    +
  • + {{ img.image }} + MAJ dispo + a jour + indisponible +
  • +
+ +
+ +
+ +
+ + {{ updateMessage }} +
+
+
+ diff --git a/web/src/app/settings/settings.component.scss b/web/src/app/settings/settings.component.scss index fbf8f15..d041b51 100644 --- a/web/src/app/settings/settings.component.scss +++ b/web/src/app/settings/settings.component.scss @@ -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; +} diff --git a/web/src/app/settings/settings.component.ts b/web/src/app/settings/settings.component.ts index 858ee6e..6d2309a 100644 --- a/web/src/app/settings/settings.component.ts +++ b/web/src/app/settings/settings.component.ts @@ -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 { diff --git a/web/src/app/sidebar/sidebar.component.html b/web/src/app/sidebar/sidebar.component.html index a05ab00..79192eb 100644 --- a/web/src/app/sidebar/sidebar.component.html +++ b/web/src/app/sidebar/sidebar.component.html @@ -60,6 +60,7 @@ diff --git a/web/src/app/sidebar/sidebar.component.scss b/web/src/app/sidebar/sidebar.component.scss index 222387a..7a0d315 100644 --- a/web/src/app/sidebar/sidebar.component.scss +++ b/web/src/app/sidebar/sidebar.component.scss @@ -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; diff --git a/web/src/app/sidebar/sidebar.component.ts b/web/src/app/sidebar/sidebar.component.ts index 116c395..75f55ab 100644 --- a/web/src/app/sidebar/sidebar.component.ts +++ b/web/src/app/sidebar/sidebar.component.ts @@ -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]); }