diff --git a/brain/app/application/chat.py b/brain/app/application/chat.py
index 9559d5e..68758ac 100644
--- a/brain/app/application/chat.py
+++ b/brain/app/application/chat.py
@@ -27,6 +27,7 @@ from app.domain.models import (
NarrativeEntityContext,
PageContext,
PageSummary,
+ SessionContext,
)
from app.domain.ports import LLMChatProvider
@@ -67,6 +68,7 @@ class ChatUseCase:
campaign_context: CampaignStructuralContext | None = None,
narrative_entity: NarrativeEntityContext | None = None,
game_system_context: GameSystemContext | None = None,
+ session_context: SessionContext | None = None,
) -> AsyncIterator[str]:
"""Streame les tokens de la réponse assistant pour le dernier message user.
@@ -76,7 +78,7 @@ class ChatUseCase:
cette règle à la frontière HTTP.
"""
system_prompt = self._build_system_prompt(
- lore_context, page_context, campaign_context, narrative_entity, game_system_context
+ lore_context, page_context, campaign_context, narrative_entity, game_system_context, session_context
)
async for token in self._llm.stream_chat(
messages,
@@ -92,12 +94,13 @@ class ChatUseCase:
campaign_context: CampaignStructuralContext | None = None,
narrative_entity: NarrativeEntityContext | None = None,
game_system_context: GameSystemContext | None = None,
+ session_context: SessionContext | None = None,
) -> str:
"""Version publique — utilisée par le controller HTTP pour compter
les tokens du system prompt avant de streamer (jauge de contexte).
"""
return self._build_system_prompt(
- lore_context, page_context, campaign_context, narrative_entity, game_system_context
+ lore_context, page_context, campaign_context, narrative_entity, game_system_context, session_context
)
# --- Construction du system prompt --------------------------------------
@@ -109,6 +112,7 @@ class ChatUseCase:
campaign: CampaignStructuralContext | None,
narrative: NarrativeEntityContext | None,
game_system: GameSystemContext | None = None,
+ session: SessionContext | None = None,
) -> str:
sections = [_BASE_SYSTEM]
if lore is not None:
@@ -121,6 +125,8 @@ class ChatUseCase:
sections.append(self._format_page(page))
if narrative is not None:
sections.append(self._format_narrative_entity(narrative))
+ if session is not None:
+ sections.append(self._format_session(session))
return "\n\n".join(sections)
# --- Blocs Lore ---------------------------------------------------------
@@ -342,6 +348,53 @@ class ChatUseCase:
f"{sections_block}"
)
+ # --- Bloc Session de jeu (Play Context) ---------------------------------
+
+ _ENTRY_TYPE_LABELS = {
+ "NOTE": "Note du MJ",
+ "EVENT": "Évènement",
+ "DICE_ROLL": "Jet de dés",
+ "PLAYER_ACTION": "Action joueur",
+ }
+
+ @staticmethod
+ def _format_session(sc: SessionContext) -> str:
+ """Bloc journal de la session en cours.
+
+ Fournit à l'IA le contexte temporel : ce qui s'est passé jusqu'ici,
+ dans l'ordre chronologique. Permet de référencer un PNJ rencontré,
+ rappeler un évènement antérieur, ou rebondir sur une action joueur.
+ """
+ status = "EN COURS" if sc.active else "TERMINÉE"
+ started = f" — démarrée {sc.started_at}" if sc.started_at else ""
+
+ if not sc.entries:
+ entries_block = "(Aucune entrée dans le journal pour l'instant — la session vient de commencer.)"
+ else:
+ lines: list[str] = []
+ for e in sc.entries:
+ label = ChatUseCase._ENTRY_TYPE_LABELS.get(e.type, e.type)
+ ts = f" [{e.occurred_at}]" if e.occurred_at else ""
+ # Indentation sur les contenus multi-lignes pour préserver la lisibilité
+ content = e.content.replace("\n", "\n ")
+ lines.append(f"- {label}{ts} : {content}")
+ entries_block = "\n".join(lines)
+
+ return (
+ "--- SESSION DE JEU EN COURS ---\n"
+ f"Nom : {sc.session_name}\n"
+ f"Statut : {status}{started}\n\n"
+ "Journal chronologique (du plus ancien au plus récent) :\n"
+ f"{entries_block}\n\n"
+ "IMPORTANT : tu es l'assistant du MJ PENDANT la partie. Tes réponses doivent :\n"
+ "- Tenir compte des évènements déjà capturés dans le journal ci-dessus.\n"
+ "- Être concrètes et utiles en temps réel : descriptions sensorielles, "
+ "réactions de PNJ cohérentes avec leur fiche, suggestions de complications "
+ "qui s'enchaînent à ce qui vient de se passer.\n"
+ "- Éviter les longs développements : le MJ est en train d'animer une partie, "
+ "il a besoin d'idées immédiatement actionnables."
+ )
+
@staticmethod
def _format_narrative_entity(ne: NarrativeEntityContext) -> str:
"""Bloc équivalent à _format_page mais pour Arc/Chapter/Scene."""
diff --git a/brain/app/domain/models.py b/brain/app/domain/models.py
index 4b0b3da..449a883 100644
--- a/brain/app/domain/models.py
+++ b/brain/app/domain/models.py
@@ -229,3 +229,30 @@ class GameSystemContext:
system_name: str
system_description: str | None
sections: dict[str, str]
+
+
+@dataclass(frozen=True)
+class JournalEntrySummary:
+ """Une entrée du journal d'une Session : type + contenu + horodatage."""
+
+ type: str
+ content: str
+ occurred_at: str | None
+
+
+@dataclass(frozen=True)
+class SessionContext:
+ """Contexte d'une Session de jeu en cours (Play Context).
+
+ Injecté dans le system prompt pendant qu'une partie est jouée pour que
+ l'IA voit le nom de la session, son statut, et un historique chronologique
+ des évènements/notes/jets capturés par le MJ.
+
+ Le journal a déjà été tronqué côté Core (cap à ~80 entrées récentes)
+ pour ne pas saturer le contexte LLM sur les sessions très longues.
+ """
+
+ session_name: str
+ active: bool
+ started_at: str | None
+ entries: list[JournalEntrySummary]
diff --git a/brain/app/main.py b/brain/app/main.py
index afff8de..d87cb4e 100644
--- a/brain/app/main.py
+++ b/brain/app/main.py
@@ -26,6 +26,7 @@ from app.domain.models import (
NpcSummary,
ChatMessage,
GameSystemContext,
+ JournalEntrySummary,
LoreStructuralContext,
NarrativeEntityContext,
PageContext,
@@ -33,6 +34,7 @@ from app.domain.models import (
PageSummary,
SceneBranchHint,
SceneSummary,
+ SessionContext,
)
from app.domain.ports import LLMProvider, LLMProviderError
from app.infrastructure.ollama_adapter import OllamaLLMProvider
@@ -41,7 +43,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.8.7-beta",
+ version="0.9.0-beta",
)
@@ -243,13 +245,34 @@ class GameSystemContextDTO(BaseModel):
sections: dict[str, str] = Field(default_factory=dict)
+class JournalEntrySummaryDTO(BaseModel):
+ """Une entrée du journal de session — type + contenu + horodatage."""
+
+ type: str
+ content: str
+ occurred_at: str | None = None
+
+
+class SessionContextDTO(BaseModel):
+ """Contexte d'une Session de jeu en cours (Play Context).
+
+ Injecté par le Core quand le chat est ancré sur une Session.
+ Contient le journal chronologique (déjà plafonné côté Core).
+ """
+
+ session_name: str
+ active: bool
+ started_at: str | None = None
+ entries: list[JournalEntrySummaryDTO] = Field(default_factory=list)
+
+
class ChatStreamRequestDTO(BaseModel):
"""Requête de chat streamé : historique + contextes structurels.
- Les 4 contextes (lore, page, campaign, narrative_entity) sont optionnels,
- mais au moins l'un des deux "niveaux haut" (lore_context ou
- campaign_context) doit être fourni. Le validateur `check_scope` applique
- cette règle à la frontière HTTP.
+ Les contextes (lore, page, campaign, narrative_entity, session) sont
+ optionnels, mais au moins l'un des contextes "racines" (lore_context,
+ campaign_context ou session_context) doit être fourni. Le validateur
+ `check_scope` applique cette règle à la frontière HTTP.
"""
messages: list[ChatMessageDTO] = Field(min_length=1)
@@ -258,10 +281,15 @@ class ChatStreamRequestDTO(BaseModel):
campaign_context: CampaignContextDTO | None = None
narrative_entity: NarrativeEntityDTO | None = None
game_system_context: GameSystemContextDTO | None = None
+ session_context: SessionContextDTO | None = None
def has_scope(self) -> bool:
- """Vrai si au moins un contexte racine (Lore ou Campagne) est fourni."""
- return self.lore_context is not None or self.campaign_context is not None
+ """Vrai si au moins un contexte racine (Lore, Campagne ou Session) est fourni."""
+ return (
+ self.lore_context is not None
+ or self.campaign_context is not None
+ or self.session_context is not None
+ )
# --- Factories d'injection de dépendance ---
@@ -385,6 +413,7 @@ async def chat_stream(
campaign_context = _to_campaign_context(body.campaign_context)
narrative_entity = _to_narrative_entity(body.narrative_entity)
game_system_context = _to_game_system_context(body.game_system_context)
+ session_context = _to_session_context(body.session_context)
# --- Comptage tokens pour la jauge de contexte frontend ---
# On construit le system prompt une fois ici pour le compter — le use case
@@ -397,6 +426,7 @@ async def chat_stream(
campaign_context=campaign_context,
narrative_entity=narrative_entity,
game_system_context=game_system_context,
+ session_context=session_context,
)
# Dernier message = "current" (souvent user), le reste = historique accumulé.
current_msg = messages[-1] if messages else None
@@ -421,6 +451,7 @@ async def chat_stream(
campaign_context=campaign_context,
narrative_entity=narrative_entity,
game_system_context=game_system_context,
+ session_context=session_context,
):
# json.dumps avec ensure_ascii=False pour préserver les accents
yield f"data: {json.dumps({'token': token}, ensure_ascii=False)}\n\n"
@@ -867,3 +898,22 @@ def _to_game_system_context(dto: GameSystemContextDTO | None) -> GameSystemConte
system_description=dto.system_description,
sections=dict(dto.sections),
)
+
+
+def _to_session_context(dto: SessionContextDTO | None) -> SessionContext | None:
+ if dto is None:
+ return None
+ entries = [
+ JournalEntrySummary(
+ type=e.type,
+ content=e.content,
+ occurred_at=e.occurred_at,
+ )
+ for e in dto.entries
+ ]
+ return SessionContext(
+ session_name=dto.session_name,
+ active=dto.active,
+ started_at=dto.started_at,
+ entries=entries,
+ )
diff --git a/core/pom.xml b/core/pom.xml
index 951365f..d7dd225 100644
--- a/core/pom.xml
+++ b/core/pom.xml
@@ -14,7 +14,7 @@
com.loremindloremind-core
- 0.8.7-beta
+ 0.9.0-betaLoreMind CoreBackend Core - Architecture Hexagonale
diff --git a/core/src/main/java/com/loremind/application/generationcontext/SessionStructuralContextBuilder.java b/core/src/main/java/com/loremind/application/generationcontext/SessionStructuralContextBuilder.java
new file mode 100644
index 0000000..311abee
--- /dev/null
+++ b/core/src/main/java/com/loremind/application/generationcontext/SessionStructuralContextBuilder.java
@@ -0,0 +1,73 @@
+package com.loremind.application.generationcontext;
+
+import com.loremind.domain.generationcontext.SessionContext;
+import com.loremind.domain.generationcontext.SessionContext.JournalEntrySummary;
+import com.loremind.domain.playcontext.Session;
+import com.loremind.domain.playcontext.SessionEntry;
+import com.loremind.domain.playcontext.ports.SessionEntryRepository;
+import com.loremind.domain.playcontext.ports.SessionRepository;
+import org.springframework.stereotype.Component;
+
+import java.util.List;
+import java.util.Optional;
+import java.util.stream.Collectors;
+
+/**
+ * Construit le SessionContext injecté dans le prompt IA pendant une partie.
+ *
+ *
Charge la Session + les N dernières entrées du journal et les mappe vers
+ * le Value Object {@link SessionContext}. La limite d'entrées évite de saturer
+ * la fenêtre de contexte du LLM sur des sessions très longues.
+ */
+@Component
+public class SessionStructuralContextBuilder {
+
+ /**
+ * Plafond du nombre d'entrées remontées au LLM.
+ * Choisi pour rester dans des limites raisonnables (≈ 5-10k tokens max
+ * pour des entrées moyennes de 200 chars). Si la session déborde,
+ * on garde les entrées les plus récentes (fin de chronologie).
+ */
+ private static final int MAX_ENTRIES = 80;
+
+ private final SessionRepository sessionRepository;
+ private final SessionEntryRepository entryRepository;
+
+ public SessionStructuralContextBuilder(SessionRepository sessionRepository,
+ SessionEntryRepository entryRepository) {
+ this.sessionRepository = sessionRepository;
+ this.entryRepository = entryRepository;
+ }
+
+ public Optional buildOptional(String sessionId) {
+ return sessionRepository.findById(sessionId).map(this::toContext);
+ }
+
+ public SessionContext build(String sessionId) {
+ Session session = sessionRepository.findById(sessionId)
+ .orElseThrow(() -> new IllegalArgumentException("Session introuvable : " + sessionId));
+ return toContext(session);
+ }
+
+ private SessionContext toContext(Session session) {
+ List allEntries = entryRepository.findBySessionId(session.getId());
+ // findBySessionId renvoie en ASC. On garde la fin si la liste dépasse le plafond
+ // — c'est l'info récente qui aide le plus l'IA pendant la partie.
+ List kept = allEntries.size() <= MAX_ENTRIES
+ ? allEntries
+ : allEntries.subList(allEntries.size() - MAX_ENTRIES, allEntries.size());
+
+ List summaries = kept.stream()
+ .map(e -> new JournalEntrySummary(
+ e.getType() != null ? e.getType().name() : "NOTE",
+ e.getContent(),
+ e.getOccurredAt()))
+ .collect(Collectors.toList());
+
+ return new SessionContext(
+ session.getName(),
+ session.isActive(),
+ session.getStartedAt(),
+ summaries);
+ }
+}
diff --git a/core/src/main/java/com/loremind/application/generationcontext/StreamChatForSessionUseCase.java b/core/src/main/java/com/loremind/application/generationcontext/StreamChatForSessionUseCase.java
new file mode 100644
index 0000000..dea49ed
--- /dev/null
+++ b/core/src/main/java/com/loremind/application/generationcontext/StreamChatForSessionUseCase.java
@@ -0,0 +1,111 @@
+package com.loremind.application.generationcontext;
+
+import com.loremind.application.gamesystemcontext.GameSystemContextBuilder;
+import com.loremind.domain.campaigncontext.Campaign;
+import com.loremind.domain.campaigncontext.ports.CampaignRepository;
+import com.loremind.domain.gamesystemcontext.GenerationIntent;
+import com.loremind.domain.generationcontext.CampaignStructuralContext;
+import com.loremind.domain.generationcontext.ChatMessage;
+import com.loremind.domain.generationcontext.ChatRequest;
+import com.loremind.domain.generationcontext.ChatUsage;
+import com.loremind.domain.generationcontext.GameSystemContext;
+import com.loremind.domain.generationcontext.LoreStructuralContext;
+import com.loremind.domain.generationcontext.SessionContext;
+import com.loremind.domain.generationcontext.ports.AiChatProvider;
+import com.loremind.domain.playcontext.Session;
+import com.loremind.domain.playcontext.ports.SessionRepository;
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+import java.util.function.Consumer;
+
+/**
+ * Use case applicatif : chat IA pendant une Session de jeu.
+ *
+ * Orchestre la composition des contextes :
+ * 1. Charge la Session puis la Campagne associée (weak reference).
+ * 2. Construit le CampaignStructuralContext (carte narrative + PJ/PNJ).
+ * 3. Construit le LoreStructuralContext si la campagne est liée à un Lore.
+ * 4. Construit le GameSystemContext si elle a un système de JDR.
+ * 5. Construit le SessionContext (journal horodaté, statut).
+ * 6. Délègue au port {@link AiChatProvider} pour le streaming.
+ *
+ *
+ *
La conversation est éphémère (pas de persistance) : pendant une partie,
+ * l'utilité est d'avoir une assistance immédiate, pas de garder un historique.
+ * Le journal de session joue déjà ce rôle de mémoire persistante.
+ */
+@Service
+public class StreamChatForSessionUseCase {
+
+ private final SessionRepository sessionRepository;
+ private final CampaignRepository campaignRepository;
+ private final CampaignStructuralContextBuilder campaignContextBuilder;
+ private final LoreStructuralContextBuilder loreContextBuilder;
+ private final GameSystemContextBuilder gameSystemContextBuilder;
+ private final SessionStructuralContextBuilder sessionContextBuilder;
+ private final AiChatProvider aiChatProvider;
+
+ public StreamChatForSessionUseCase(
+ SessionRepository sessionRepository,
+ CampaignRepository campaignRepository,
+ CampaignStructuralContextBuilder campaignContextBuilder,
+ LoreStructuralContextBuilder loreContextBuilder,
+ GameSystemContextBuilder gameSystemContextBuilder,
+ SessionStructuralContextBuilder sessionContextBuilder,
+ AiChatProvider aiChatProvider) {
+ this.sessionRepository = sessionRepository;
+ this.campaignRepository = campaignRepository;
+ this.campaignContextBuilder = campaignContextBuilder;
+ this.loreContextBuilder = loreContextBuilder;
+ this.gameSystemContextBuilder = gameSystemContextBuilder;
+ this.sessionContextBuilder = sessionContextBuilder;
+ this.aiChatProvider = aiChatProvider;
+ }
+
+ public void execute(
+ String sessionId,
+ List messages,
+ Consumer onUsage,
+ Consumer onToken,
+ Runnable onComplete,
+ Consumer onError) {
+
+ Session session = sessionRepository.findById(sessionId)
+ .orElseThrow(() -> new IllegalArgumentException("Session introuvable : " + sessionId));
+
+ Campaign campaign = campaignRepository.findById(session.getCampaignId())
+ .orElseThrow(() -> new IllegalArgumentException(
+ "Campagne associée à la session introuvable : " + session.getCampaignId()));
+
+ CampaignStructuralContext campaignContext = campaignContextBuilder.build(campaign.getId());
+ LoreStructuralContext loreContext = loadLoreContextOrNull(campaign);
+ GameSystemContext gameSystemContext = loadGameSystemContextOrNull(campaign);
+ SessionContext sessionContext = sessionContextBuilder.build(sessionId);
+
+ ChatRequest request = ChatRequest.builder()
+ .messages(messages)
+ .loreContext(loreContext)
+ .campaignContext(campaignContext)
+ .gameSystemContext(gameSystemContext)
+ .sessionContext(sessionContext)
+ .build();
+
+ aiChatProvider.streamChat(request, onUsage, onToken, onComplete, onError);
+ }
+
+ private LoreStructuralContext loadLoreContextOrNull(Campaign campaign) {
+ if (!campaign.isLinkedToLore()) return null;
+ return loreContextBuilder.buildOptional(campaign.getLoreId()).orElse(null);
+ }
+
+ /**
+ * Pendant une session active, on injecte les sections les plus utiles en partie
+ * (combats, PNJ, mécaniques) — intent SCENE est le plus proche de ce besoin.
+ */
+ private GameSystemContext loadGameSystemContextOrNull(Campaign campaign) {
+ if (!campaign.isLinkedToGameSystem()) return null;
+ return gameSystemContextBuilder.buildOptional(campaign.getGameSystemId(), GenerationIntent.SCENE)
+ .orElse(null);
+ }
+}
diff --git a/core/src/main/java/com/loremind/application/playcontext/SessionEntryService.java b/core/src/main/java/com/loremind/application/playcontext/SessionEntryService.java
new file mode 100644
index 0000000..69d39b0
--- /dev/null
+++ b/core/src/main/java/com/loremind/application/playcontext/SessionEntryService.java
@@ -0,0 +1,81 @@
+package com.loremind.application.playcontext;
+
+import com.loremind.domain.playcontext.EntryType;
+import com.loremind.domain.playcontext.SessionEntry;
+import com.loremind.domain.playcontext.ports.SessionEntryRepository;
+import com.loremind.domain.playcontext.ports.SessionRepository;
+import org.springframework.stereotype.Service;
+
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * Service d'application pour le journal d'une Session.
+ * Gère le cycle CRUD des entrées (note, évènement, jet, action joueur).
+ */
+@Service
+public class SessionEntryService {
+
+ private final SessionEntryRepository entryRepository;
+ private final SessionRepository sessionRepository;
+
+ public SessionEntryService(SessionEntryRepository entryRepository,
+ SessionRepository sessionRepository) {
+ this.entryRepository = entryRepository;
+ this.sessionRepository = sessionRepository;
+ }
+
+ /** Données fournies par l'API pour créer ou éditer une entrée. */
+ public record EntryData(EntryType type, String content, LocalDateTime occurredAt) {}
+
+ public SessionEntry createEntry(String sessionId, EntryData data) {
+ if (sessionId == null || sessionId.isBlank()) {
+ throw new IllegalArgumentException("sessionId est requis.");
+ }
+ if (!sessionRepository.existsById(sessionId)) {
+ throw new IllegalArgumentException("Session introuvable : " + sessionId);
+ }
+ validateContent(data.content());
+
+ LocalDateTime now = LocalDateTime.now();
+ SessionEntry entry = SessionEntry.builder()
+ .sessionId(sessionId)
+ .type(data.type() != null ? data.type() : EntryType.NOTE)
+ .content(data.content().trim())
+ .occurredAt(data.occurredAt() != null ? data.occurredAt() : now)
+ .build();
+ return entryRepository.save(entry);
+ }
+
+ public SessionEntry updateEntry(String id, EntryData data) {
+ validateContent(data.content());
+ SessionEntry existing = entryRepository.findById(id)
+ .orElseThrow(() -> new IllegalArgumentException("Entrée introuvable : " + id));
+ if (data.type() != null) existing.setType(data.type());
+ existing.setContent(data.content().trim());
+ if (data.occurredAt() != null) existing.setOccurredAt(data.occurredAt());
+ return entryRepository.save(existing);
+ }
+
+ public Optional getById(String id) {
+ return entryRepository.findById(id);
+ }
+
+ public List getBySessionId(String sessionId) {
+ return entryRepository.findBySessionId(sessionId);
+ }
+
+ public void deleteEntry(String id) {
+ if (!entryRepository.existsById(id)) {
+ throw new IllegalArgumentException("Entrée introuvable : " + id);
+ }
+ entryRepository.deleteById(id);
+ }
+
+ private void validateContent(String content) {
+ if (content == null || content.isBlank()) {
+ throw new IllegalArgumentException("Le contenu d'une entrée ne peut pas être vide.");
+ }
+ }
+}
diff --git a/core/src/main/java/com/loremind/application/playcontext/SessionService.java b/core/src/main/java/com/loremind/application/playcontext/SessionService.java
new file mode 100644
index 0000000..9ba6d6e
--- /dev/null
+++ b/core/src/main/java/com/loremind/application/playcontext/SessionService.java
@@ -0,0 +1,117 @@
+package com.loremind.application.playcontext;
+
+import com.loremind.domain.campaigncontext.ports.CampaignRepository;
+import com.loremind.domain.playcontext.Session;
+import com.loremind.domain.playcontext.ports.SessionEntryRepository;
+import com.loremind.domain.playcontext.ports.SessionRepository;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * Service d'application pour le Play Context.
+ * Orchestre le cycle de vie d'une Session (lancement, fin, renommage).
+ * Fait partie de la couche Application de l'Architecture Hexagonale.
+ *
+ *
Règle métier : une seule Session peut être active (endedAt null) à la fois
+ * dans l'application.
+ */
+@Service
+public class SessionService {
+
+ private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd");
+
+ private final SessionRepository sessionRepository;
+ private final SessionEntryRepository entryRepository;
+ private final CampaignRepository campaignRepository;
+
+ public SessionService(SessionRepository sessionRepository,
+ SessionEntryRepository entryRepository,
+ CampaignRepository campaignRepository) {
+ this.sessionRepository = sessionRepository;
+ this.entryRepository = entryRepository;
+ this.campaignRepository = campaignRepository;
+ }
+
+ /**
+ * Lance une nouvelle session sur la campagne donnée.
+ * Échoue si une session est déjà active ou si la campagne n'existe pas.
+ */
+ public Session startSession(String campaignId) {
+ if (campaignId == null || campaignId.isBlank()) {
+ throw new IllegalArgumentException("campaignId est requis pour démarrer une session.");
+ }
+ if (!campaignRepository.existsById(campaignId)) {
+ throw new IllegalArgumentException("Campagne introuvable : " + campaignId);
+ }
+ sessionRepository.findActive().ifPresent(s -> {
+ throw new IllegalStateException("Une session est déjà en cours (id=" + s.getId() + "). Termine-la avant d'en lancer une nouvelle.");
+ });
+
+ LocalDateTime now = LocalDateTime.now();
+ Session session = Session.builder()
+ .name(generateDefaultName(now))
+ .campaignId(campaignId)
+ .startedAt(now)
+ .build();
+ return sessionRepository.save(session);
+ }
+
+ /** Termine la session active si elle correspond à l'id donné. */
+ public Session endSession(String id) {
+ Session session = sessionRepository.findById(id)
+ .orElseThrow(() -> new IllegalArgumentException("Session introuvable : " + id));
+ if (!session.isActive()) {
+ throw new IllegalStateException("Cette session est déjà terminée.");
+ }
+ session.setEndedAt(LocalDateTime.now());
+ return sessionRepository.save(session);
+ }
+
+ public Session renameSession(String id, String newName) {
+ if (newName == null || newName.isBlank()) {
+ throw new IllegalArgumentException("Le nom de la session ne peut pas être vide.");
+ }
+ Session session = sessionRepository.findById(id)
+ .orElseThrow(() -> new IllegalArgumentException("Session introuvable : " + id));
+ session.setName(newName.trim());
+ return sessionRepository.save(session);
+ }
+
+ public Optional getById(String id) {
+ return sessionRepository.findById(id);
+ }
+
+ public Optional getActive() {
+ return sessionRepository.findActive();
+ }
+
+ public List getAll() {
+ return sessionRepository.findAll();
+ }
+
+ public List getByCampaignId(String campaignId) {
+ return sessionRepository.findByCampaignId(campaignId);
+ }
+
+ /**
+ * Supprime une session et toutes ses entrées de journal en cascade.
+ * Transactionnel : soit tout disparaît, soit rien.
+ */
+ @Transactional
+ public void deleteSession(String id) {
+ if (!sessionRepository.existsById(id)) {
+ throw new IllegalArgumentException("Session introuvable : " + id);
+ }
+ entryRepository.deleteBySessionId(id);
+ sessionRepository.deleteById(id);
+ }
+
+ private String generateDefaultName(LocalDateTime startedAt) {
+ return "Session du " + startedAt.format(DATE_FORMATTER);
+ }
+}
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 48f5d1a..0da2e28 100644
--- a/core/src/main/java/com/loremind/domain/generationcontext/ChatRequest.java
+++ b/core/src/main/java/com/loremind/domain/generationcontext/ChatRequest.java
@@ -36,7 +36,8 @@ public record ChatRequest(
PageContext pageContext,
CampaignStructuralContext campaignContext,
NarrativeEntityContext narrativeEntity,
- GameSystemContext gameSystemContext) {
+ GameSystemContext gameSystemContext,
+ SessionContext sessionContext) {
public static Builder builder() {
return new Builder();
@@ -50,6 +51,7 @@ public record ChatRequest(
private CampaignStructuralContext campaignContext;
private NarrativeEntityContext narrativeEntity;
private GameSystemContext gameSystemContext;
+ private SessionContext sessionContext;
private Builder() {}
@@ -83,9 +85,14 @@ public record ChatRequest(
return this;
}
+ public Builder sessionContext(SessionContext sessionContext) {
+ this.sessionContext = sessionContext;
+ return this;
+ }
+
public ChatRequest build() {
return new ChatRequest(messages, loreContext, pageContext,
- campaignContext, narrativeEntity, gameSystemContext);
+ campaignContext, narrativeEntity, gameSystemContext, sessionContext);
}
}
}
diff --git a/core/src/main/java/com/loremind/domain/generationcontext/SessionContext.java b/core/src/main/java/com/loremind/domain/generationcontext/SessionContext.java
new file mode 100644
index 0000000..c7b7bb4
--- /dev/null
+++ b/core/src/main/java/com/loremind/domain/generationcontext/SessionContext.java
@@ -0,0 +1,33 @@
+package com.loremind.domain.generationcontext;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+/**
+ * Contexte structurel d'une Session de jeu — injecté dans le system prompt
+ * de l'IA pour qu'elle ait conscience de la partie en cours et de son journal.
+ *
+ *
Pendant qu'une session se joue, l'IA reçoit en plus du Lore/Campagne/GameSystem :
+ * le nom de la session, son statut (en cours / terminée) et un résumé chronologique
+ * des entrées du journal (notes, évènements, jets, actions joueurs).
+ *
+ *
Value Object du Generation Context — record Java immutable.
+ *
+ * @param sessionName Nom de la session telle qu'affichée au MJ.
+ * @param active True si la session est en cours, false si terminée.
+ * @param startedAt Horodatage de démarrage.
+ * @param entries Entrées du journal triées chronologiquement (anciennes → récentes).
+ * Limité côté builder pour éviter de saturer le contexte LLM.
+ */
+public record SessionContext(
+ String sessionName,
+ boolean active,
+ LocalDateTime startedAt,
+ List entries) {
+
+ /** Résumé d'une entrée de journal — type + contenu + horodatage. */
+ public record JournalEntrySummary(
+ String type,
+ String content,
+ LocalDateTime occurredAt) {}
+}
diff --git a/core/src/main/java/com/loremind/domain/playcontext/EntryType.java b/core/src/main/java/com/loremind/domain/playcontext/EntryType.java
new file mode 100644
index 0000000..09fbddc
--- /dev/null
+++ b/core/src/main/java/com/loremind/domain/playcontext/EntryType.java
@@ -0,0 +1,16 @@
+package com.loremind.domain.playcontext;
+
+/**
+ * Type d'entrée du journal de session.
+ * Permet à l'UI de catégoriser visuellement la timeline (icône, couleur).
+ */
+public enum EntryType {
+ /** Note libre du MJ (défaut). */
+ NOTE,
+ /** Moment marquant du scénario (combat gagné, décision majeure...). */
+ EVENT,
+ /** Jet de dés / test de caractéristique. */
+ DICE_ROLL,
+ /** Action déclarée par un joueur. */
+ PLAYER_ACTION
+}
diff --git a/core/src/main/java/com/loremind/domain/playcontext/Session.java b/core/src/main/java/com/loremind/domain/playcontext/Session.java
new file mode 100644
index 0000000..d9a1298
--- /dev/null
+++ b/core/src/main/java/com/loremind/domain/playcontext/Session.java
@@ -0,0 +1,42 @@
+package com.loremind.domain.playcontext;
+
+import lombok.Builder;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+/**
+ * Entité de domaine représentant une Session de jeu en cours ou passée.
+ *
+ *
Une Session est une instance jouée d'une Campaign. La Campaign reste
+ * un scénario générique réutilisable ; la Session capture une partie réelle
+ * (date, journal, etc.) sans polluer le scénario d'origine.
+ *
+ *
Fait partie du Play Context. Référence la Campaign par weak reference
+ * (campaignId) pour respecter la séparation des Bounded Contexts.
+ *
+ *
{@code endedAt == null} signifie que la session est en cours.
+ * Une seule session peut être en cours dans l'application à la fois.
+ */
+@Data
+@Builder
+public class Session {
+
+ private String id;
+ private String name;
+
+ /** Weak reference vers Campaign — pas de dépendance directe inter-contexte. */
+ private String campaignId;
+
+ private LocalDateTime startedAt;
+
+ /** Null = session en cours ; renseigné = session terminée. */
+ private LocalDateTime endedAt;
+
+ private LocalDateTime createdAt;
+ private LocalDateTime updatedAt;
+
+ public boolean isActive() {
+ return this.endedAt == null;
+ }
+}
diff --git a/core/src/main/java/com/loremind/domain/playcontext/SessionEntry.java b/core/src/main/java/com/loremind/domain/playcontext/SessionEntry.java
new file mode 100644
index 0000000..cdc2e2b
--- /dev/null
+++ b/core/src/main/java/com/loremind/domain/playcontext/SessionEntry.java
@@ -0,0 +1,39 @@
+package com.loremind.domain.playcontext;
+
+import lombok.Builder;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+/**
+ * Entrée du journal d'une Session.
+ * Représente un évènement horodaté capturé pendant ou après une partie :
+ * note libre du MJ, évènement marquant, jet de dés, action de joueur.
+ *
+ *
Fait partie du Play Context. Référence la Session par weak reference
+ * (sessionId) — l'orchestration en cascade est gérée par le service applicatif.
Diffère du {@link AiChatDrawerComponent} :
+ * - conversation 100% éphémère (le journal joue le rôle de mémoire persistante)
+ * - intégré dans le panneau latéral, pas en drawer
+ * - chaque réponse peut être ajoutée au journal en un clic (event {@link saveToJournal})
+ *
+ *
Le backend reçoit le contexte complet via {@code /api/ai/chat/stream-session} :
+ * lore + campagne + GameSystem + journal — l'IA "sait" tout ce qui s'est passé.
Charge à la volée les PJ/PNJ et l'arbre de scènes de la campagne associée
+ * à la session. La navigation vers les fiches s'ouvre dans un nouvel onglet
+ * pour ne pas casser le flux de la session en cours.
+ *
+ *
Le sous-composant {@link SessionDicePanelComponent} émet un événement
+ * de jet qui remonte ici puis vers le parent via {@link rolled}.
+ */
+@Component({
+ selector: 'app-session-reference-panel',
+ standalone: true,
+ imports: [CommonModule, LucideAngularModule, SessionDicePanelComponent, SessionAiChatPanelComponent],
+ templateUrl: './session-reference-panel.component.html',
+ styleUrls: ['./session-reference-panel.component.scss']
+})
+export class SessionReferencePanelComponent implements OnChanges {
+ readonly User = User;
+ readonly Drama = Drama;
+ readonly Swords = Swords;
+ readonly Dices = Dices;
+ readonly ExternalLink = ExternalLink;
+ readonly Sparkles = Sparkles;
+
+ @Input() campaignId!: string;
+ @Input() sessionId!: string;
+ @Input() canAddToJournal = true;
+ @Output() rolled = new EventEmitter();
+ /** Émis quand l'IA répond et que le MJ veut sauvegarder la réponse comme entrée. */
+ @Output() aiReplyToJournal = new EventEmitter();
+
+ activeTab: TabId = 'dice';
+
+ characters: Character[] = [];
+ npcs: Npc[] = [];
+ treeData: CampaignTreeData | null = null;
+
+ loadingChars = false;
+ loadingTree = false;
+ /** True dès qu'un tab "lourd" a été chargé pour éviter de rappeler l'API en boucle. */
+ private charsLoaded = false;
+ private treeLoaded = false;
+
+ constructor(
+ private campaignService: CampaignService,
+ private characterService: CharacterService,
+ private npcService: NpcService
+ ) {}
+
+ ngOnChanges(changes: SimpleChanges): void {
+ if (changes['campaignId']) {
+ this.charsLoaded = false;
+ this.treeLoaded = false;
+ this.characters = [];
+ this.npcs = [];
+ this.treeData = null;
+ }
+ }
+
+ selectTab(tab: TabId): void {
+ this.activeTab = tab;
+ if (tab === 'characters') this.ensureCharactersLoaded();
+ if (tab === 'scenes') this.ensureTreeLoaded();
+ }
+
+ private ensureCharactersLoaded(): void {
+ if (this.charsLoaded || this.loadingChars || !this.campaignId) return;
+ this.loadingChars = true;
+ this.characterService.getByCampaign(this.campaignId).pipe(catchError(() => of([] as Character[])))
+ .subscribe(list => { this.characters = list; this.tryFinishCharsLoad(); });
+ this.npcService.getByCampaign(this.campaignId).pipe(catchError(() => of([] as Npc[])))
+ .subscribe(list => { this.npcs = list; this.tryFinishCharsLoad(); });
+ }
+
+ private tryFinishCharsLoad(): void {
+ // On considère que le chargement est fini quand au moins une des deux listes
+ // a été assignée (vide ou pleine). Le double subscribe ci-dessus garantit
+ // qu'on tombe ici deux fois ; idempotent.
+ this.loadingChars = false;
+ this.charsLoaded = true;
+ }
+
+ private ensureTreeLoaded(): void {
+ if (this.treeLoaded || this.loadingTree || !this.campaignId) return;
+ this.loadingTree = true;
+ loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService).pipe(
+ catchError(() => of({ arcs: [], chaptersByArc: {}, scenesByChapter: {}, characters: [], npcs: [] } as CampaignTreeData))
+ ).subscribe(data => {
+ this.treeData = data;
+ this.loadingTree = false;
+ this.treeLoaded = true;
+ });
+ }
+
+ /**
+ * Ouvre une fiche dans un nouvel onglet pour préserver l'écran de session.
+ * Le MJ peut consulter sans perdre son journal ni son historique de dés.
+ */
+ openInNewTab(path: (string | number)[]): void {
+ const url = path.map(p => String(p)).join('/');
+ window.open('/' + url, '_blank', 'noopener');
+ }
+
+ /** Helpers de typage pour le template (Angular n'infère pas bien sans). */
+ chaptersOf(arc: Arc): Chapter[] {
+ return this.treeData?.chaptersByArc[arc.id!] ?? [];
+ }
+ scenesOf(chapter: Chapter): Scene[] {
+ return this.treeData?.scenesByChapter[chapter.id!] ?? [];
+ }
+
+ onDiceRolled(result: DiceRollResult): void {
+ this.rolled.emit(result);
+ }
+
+ onAiSaveToJournal(content: string): void {
+ this.aiReplyToJournal.emit(content);
+ }
+}