14 Commits

Author SHA1 Message Date
0d1c34d1f8 ci: debug token length
Some checks failed
Build & Push Images / build (brain) (push) Failing after 20s
Build & Push Images / build (core) (push) Failing after 21s
Build & Push Images / build (web) (push) Failing after 16s
2026-04-21 08:18:54 +02:00
ca1bc2b573 ci: hardcode registry URL and user
Some checks failed
Build & Push Images / build (core) (push) Failing after 19s
Build & Push Images / build (brain) (push) Failing after 21s
Build & Push Images / build (web) (push) Failing after 16s
2026-04-21 08:16:18 +02:00
570f6819d8 ci: hardcode registry username via REGISTRY_USER variable
Some checks failed
Build & Push Images / build (core) (push) Failing after 20s
Build & Push Images / build (brain) (push) Failing after 21s
Build & Push Images / build (web) (push) Failing after 16s
2026-04-21 08:06:59 +02:00
6b35aa7ef2 Changement yml config
Some checks failed
Build & Push Images / build (brain) (push) Failing after 20s
Build & Push Images / build (core) (push) Failing after 22s
Build & Push Images / build (web) (push) Failing after 16s
2026-04-21 07:54:22 +02:00
319bc15980 Documentation inutile au public
Some checks failed
Build & Push Images / build (brain) (push) Failing after 2m8s
Build & Push Images / build (core) (push) Failing after 2m10s
Build & Push Images / build (web) (push) Failing after 17s
2026-04-21 07:10:20 +02:00
344013fb5c Nettoyage 2026-04-21 06:57:00 +02:00
abb3081294 Changement du properties 2026-04-21 06:53:02 +02:00
7a340285c5 Mise en place docker + mise en place des settings (config ollama / 1min.ai) 2026-04-21 06:51:41 +02:00
67818f0d3d Intégration du graphe et du multi-branche pour la partie campagne 2026-04-21 05:05:11 +02:00
1a5b6f8d79 Mise à jour avec la possibilité de mettre des images 2026-04-21 02:47:09 +02:00
5b133aa2fe Ajout de la partie IA 2026-04-20 14:52:20 +02:00
94bbf8beff Mise à jour readme 2026-04-19 12:20:58 +02:00
e1f37a8dbb Ajout du Readme 2026-04-19 12:18:40 +02:00
094c759f2c Initial commit - LoreMind project 2026-04-19 12:08:16 +02:00
327 changed files with 35358 additions and 0 deletions

30
.env.example Normal file
View File

@@ -0,0 +1,30 @@
# ==========================================================================
# Configuration LoreMindMJ - copier en .env et adapter
# ==========================================================================
# --- Registry Gitea (ou celui de l'editeur) ----------------------------
REGISTRY=git.igmlcreation.fr
TAG=latest
# --- Port d'acces web ----------------------------------------------------
WEB_PORT=8081
# --- PostgreSQL (IMPORTANT : change POSTGRES_PASSWORD) -------------------
POSTGRES_DB=loremind
POSTGRES_USER=loremind
POSTGRES_PASSWORD=change-me-please
# --- MinIO (stockage objet images) ---------------------------------------
MINIO_USER=minioadmin
MINIO_PASSWORD=minioadmin
# --- Provider LLM : "ollama" (local) ou "onemin" (cloud 1min.ai) ---------
LLM_PROVIDER=ollama
# Ollama (si LLM_PROVIDER=ollama)
OLLAMA_BASE_URL=http://host.docker.internal:11434
LLM_MODEL=gemma4:26b
# 1min.ai (si LLM_PROVIDER=onemin)
ONEMIN_API_KEY=
ONEMIN_MODEL=gpt-4o-mini

View File

@@ -0,0 +1,52 @@
name: Build & Push Images
on:
push:
tags:
- 'v*'
env:
REGISTRY: git.igmlcreation.fr
REGISTRY_USER: ietm64
jobs:
build:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
component: [brain, core, web]
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Debug token length
run: |
echo "Token length: ${#TOKEN}"
echo "First char code: $(printf '%d' "'${TOKEN:0:1}")"
echo "Last char code: $(printf '%d' "'${TOKEN: -1}")"
env:
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
- name: Login to Gitea Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ env.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Extract version
id: meta
run: echo "version=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT
- name: Build & push ${{ matrix.component }}
uses: docker/build-push-action@v5
with:
context: ./${{ matrix.component }}
push: true
tags: |
${{ env.REGISTRY }}/loremindmj/${{ matrix.component }}:latest
${{ env.REGISTRY }}/loremindmj/${{ matrix.component }}:${{ steps.meta.outputs.version }}

9
.gitignore vendored
View File

@@ -41,3 +41,12 @@ Thumbs.db
# Documentation temporaire # Documentation temporaire
docs/edraw/ docs/edraw/
docs/academy/
brain/.env.example
# Variables d'environnement runtime (prod)
.env
# Override compose local (optionnel - un dev peut avoir le sien)
# Retire cette ligne si tu veux committer l'override par defaut du repo.
# docker-compose.override.yml

64
INSTALL.md Normal file
View File

@@ -0,0 +1,64 @@
# Installation de LoreMindMJ
## Prerequis
- **Docker Desktop** ([Windows](https://www.docker.com/products/docker-desktop/) / [Mac](https://www.docker.com/products/docker-desktop/))
ou **Docker Engine + Compose v2** (Linux).
- (Optionnel) **[Ollama](https://ollama.com/)** si tu veux un LLM local.
Sinon, une cle API [1min.ai](https://1min.ai) suffit.
## Installation (5 minutes)
1. Telecharge `docker-compose.yml` et `.env.example` depuis la [derniere release](https://git.igmlcreation.fr/ietm64/LoreMindMJ/releases) dans un dossier a toi.
2. Renomme `.env.example` en `.env` et ouvre-le dans un editeur texte. Change **au minimum** `POSTGRES_PASSWORD`.
3. Dans un terminal, place-toi dans le dossier et lance :
```
docker compose up -d
```
Le premier demarrage telecharge les images (~500 Mo) et initialise la base. Compte 1-2 minutes.
4. Ouvre http://localhost:8081 dans ton navigateur. Bon jeu !
## Mise a jour
```
docker compose pull
docker compose up -d
```
Les donnees (base Postgres, images MinIO, settings Brain) sont dans des volumes Docker et survivent aux mises a jour.
## LLM : Ollama ou 1min.ai ?
**Ollama (local, gratuit)** — Edite `.env` :
```
LLM_PROVIDER=ollama
LLM_MODEL=gemma4:26b
```
Telecharge le modele au prealable : `ollama pull gemma4:26b`.
**1min.ai (cloud, paye)** — Edite `.env` :
```
LLM_PROVIDER=onemin
ONEMIN_API_KEY=sk-...
ONEMIN_MODEL=open-mistral-nemo
```
Tu peux aussi changer tout ca a chaud depuis l'ecran Parametres de l'appli.
## Problemes frequents
- **Port 8081 deja pris** : change `WEB_PORT=8082` (ou autre) dans `.env`.
- **Ollama injoignable** : verifie qu'Ollama tourne (`ollama serve`) et que le modele est bien telecharge.
- **Tout casser et repartir de zero** : `docker compose down -v` supprime les volumes (attention, perte de donnees).
## Sauvegarde
Les donnees sont dans les volumes Docker : `loremindmj_postgres-data`, `loremindmj_minio-data`, `loremindmj_brain-data`.
Sauvegarde rapide de la base :
```
docker compose exec postgres pg_dump -U loremind loremind > backup.sql
```

76
README.md Normal file
View File

@@ -0,0 +1,76 @@
# LoreMind
Application web d'aide aux Maîtres de Jeu (JDR) pour centraliser la gestion de l'univers (Lore) et le suivi des campagnes, avec un moteur IA intégré pour générer du contenu structuré.
## Fonctionnalités
- Gestion centralisée du Lore : Lieux, Factions, PNJ, et tous les éléments de votre univers
- Suivi de campagnes : Sessions, actions des joueurs, chronologie
- Moteur IA intégré : Génération automatique de contenu (PNJ, Villes, Quêtes) à partir de templates
- Export vers FoundryVTT : Transfert structuré des données vers votre VTT préféré (en développement)
## Captures d'écran
### Page d'accueil
![Accueil](docs/maquettes/général/Accueil.png)
### Recherche
![Recherche](docs/maquettes/général/Ecran de recherche.png)
## Stack Technologique
LoreMind utilise une architecture distribuée pour séparer les responsabilités :
- **Frontend** : Angular (Interface utilisateur, affichage du lore, formulaires de templates)
- **Backend Core** : Java (Spring Boot) - Orchestration, persistance, export VTT
- **Backend IA** : Python - Traitement des LLM et génération de contenu
- **Base de données** : PostgreSQL avec JSONB pour les templates flexibles
## Architecture
### Backend Java (Domain-Driven Design & Hexagonal)
Le Backend Core respecte strictement :
- **Domain-Driven Design (DDD)** : Séparation en Bounded Contexts autonomes
- **Architecture Hexagonale (Ports et Adaptateurs)** : Domaine pur sans dépendances techniques
#### Bounded Contexts
- **LoreContext** : Gestion de l'encyclopédie de l'univers
- **CampaignContext** : Suivi des sessions et chronologie
- **GenerationContext** : Gestion des requêtes IA et templates
#### Couches
- **Domaine (Core)** : Entités métier pures et interfaces (Ports)
- **Application** : Orchestration des flux (Use Cases)
- **Infrastructure** : Implémentation technique (Adapters)
## Installation rapide avec Docker
### Prérequis
- Docker et Docker Compose installés
### Démarrage
```bash
# Clone du repository
git clone https://ton-gitea.com/ton-user/LoreMind.git
cd LoreMind
# Lancement de tous les services
docker-compose up -d
# L'application est accessible sur :
# - Frontend : http://localhost:4200
# - Backend Core : http://localhost:8080
# - Backend IA : http://localhost:8000
```
### Arrêt
```bash
docker-compose down
```
## License
[À définir]

6
brain/.dockerignore Normal file
View File

@@ -0,0 +1,6 @@
data/
__pycache__/
*.pyc
.env
.venv/
venv/

4
brain/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
.venv/
__pycache__/
*.pyc
.env

16
brain/Dockerfile Normal file
View File

@@ -0,0 +1,16 @@
FROM python:3.12-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app ./app
RUN mkdir -p /app/data
VOLUME ["/app/data"]
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

0
brain/app/__init__.py Normal file
View File

View File

View File

@@ -0,0 +1,258 @@
"""Use case : chat conversationnel LoreMind avec Structural Context.
Construit un system prompt riche à partir de 4 contextes possibles
(Lore, Page focalisée, Campagne, entité narrative focalisée) puis délègue
au port `LLMChatProvider` pour le streaming token par token.
Ne charge PAS le contenu détaillé des pages — l'IA doit savoir ce qui
existe, pas être noyée sous le texte. Pattern "Structural Context", plus
simple que le RAG sémantique tant que les univers restent de taille humaine.
Combinaisons supportées :
- lore seul → chat Lore (page-edit / page-create)
- lore + page_context → chat Lore focalisé page
- campaign (+lore si liée) + optional narrative_entity → chat Campagne
"""
from typing import AsyncIterator
from app.domain.models import (
ArcSummary,
CampaignStructuralContext,
ChatMessage,
ChapterSummary,
LoreStructuralContext,
NarrativeEntityContext,
PageContext,
PageSummary,
)
from app.domain.ports import LLMChatProvider
# Température moyenne : chat conversationnel créatif mais cohérent.
# Plus élevée que le one-shot (0.4) car on veut de la variété d'idées,
# mais sans partir en délire halluciné (1.0+).
_DEFAULT_TEMPERATURE = 0.7
_BASE_SYSTEM = """Tu es un assistant d'écriture pour un Maître de Jeu de JDR.
Tu dialogues avec le MJ pour l'aider à enrichir son univers et ses campagnes.
Règles de ton :
- Réponds en français, ton chaleureux et créatif.
- Sois concis : listes à puces courtes plutôt que longs paragraphes.
- Propose des idées qui s'intègrent dans le contexte existant ci-dessous.
Règles de cohérence (IMPORTANT) :
- Tu PEUX et DOIS inventer des éléments originaux (personnages, lieux, objets, intrigues, créatures, scènes) — c'est ton rôle d'assistant créatif.
- Tu ne peux PAS faire référence à un élément du MJ (du Lore, des arcs, chapitres ou scènes) comme s'il existait déjà, SAUF s'il apparaît EXACTEMENT (même orthographe) dans l'une des sections de contexte ci-dessous.
- Si l'utilisateur mentionne un nom que tu ne vois pas dans le contexte, ne fais surtout pas semblant de le connaître : dis clairement "Je ne vois pas [nom] dans le contexte actuel, veux-tu qu'on le crée ?" plutôt que d'inventer des détails à son sujet.
- Évite les précisions inventées qu'on ne peut pas vérifier : dates exactes, chiffres de population, hiérarchies politiques complexes, généalogies détaillées. Préfère des formulations ouvertes que le MJ validera ("il y a longtemps", "de nombreux", "la haute noblesse")."""
class ChatUseCase:
"""Orchestre un tour de conversation avec le LLM + contextes structurels."""
def __init__(self, llm: LLMChatProvider) -> None:
self._llm = llm
async def stream(
self,
messages: list[ChatMessage],
*,
lore_context: LoreStructuralContext | None = None,
page_context: PageContext | None = None,
campaign_context: CampaignStructuralContext | None = None,
narrative_entity: NarrativeEntityContext | None = None,
) -> AsyncIterator[str]:
"""Streame les tokens de la réponse assistant pour le dernier message user.
Les 4 contextes sont tous optionnels, mais au moins l'un des deux
"niveaux haut" (lore_context ou campaign_context) doit être fourni
pour que le prompt ait du sens. Le controller (main.py) applique
cette règle à la frontière HTTP.
"""
system_prompt = self._build_system_prompt(
lore_context, page_context, campaign_context, narrative_entity
)
async for token in self._llm.stream_chat(
messages,
system_prompt=system_prompt,
temperature=_DEFAULT_TEMPERATURE,
):
yield token
# --- Construction du system prompt --------------------------------------
def _build_system_prompt(
self,
lore: LoreStructuralContext | None,
page: PageContext | None,
campaign: CampaignStructuralContext | None,
narrative: NarrativeEntityContext | None,
) -> str:
sections = [_BASE_SYSTEM]
if lore is not None:
sections.append(self._format_lore(lore))
if campaign is not None:
sections.append(self._format_campaign(campaign, lore_present=lore is not None))
if page is not None:
sections.append(self._format_page(page))
if narrative is not None:
sections.append(self._format_narrative_entity(narrative))
return "\n\n".join(sections)
# --- Blocs Lore ---------------------------------------------------------
@staticmethod
def _format_lore(ctx: LoreStructuralContext) -> str:
desc = f"\nDescription : {ctx.lore_description}" if ctx.lore_description else ""
folders_block = ChatUseCase._format_folders(ctx.folders)
tags_line = ", ".join(ctx.tags) if ctx.tags else "(aucun)"
return (
"--- UNIVERS (Lore) ---\n"
f"Nom : {ctx.lore_name}{desc}\n\n"
f"Organisation :\n{folders_block}\n\n"
f"Tags déjà utilisés : {tags_line}"
)
@staticmethod
def _format_folders(folders: dict[str, list[PageSummary]]) -> str:
"""Rend chaque page avec son contenu exploitable par le LLM.
Depuis b9 : affiche en plus des champs values/tags/pages liées sous
forme d'une fiche indentée par page, et seulement si l'info existe
(prompt compact quand une page est vierge).
"""
if not folders:
return "(Lore vide pour l'instant)"
lines: list[str] = []
for folder_name, pages in folders.items():
lines.append(f"- {folder_name} (dossier)")
if not pages:
lines.append(" (vide)")
continue
for ps in pages:
lines.append(f" - {ps.title} [template: {ps.template_name}]")
for field_name, value in ps.values.items():
lines.append(f" · {field_name} : {value}")
if ps.tags:
lines.append(f" · tags : {', '.join(ps.tags)}")
if ps.related_page_titles:
lines.append(
" · liée à : " + ", ".join(ps.related_page_titles)
)
return "\n".join(lines)
@staticmethod
def _format_page(pc: PageContext) -> str:
"""Bloc "PAGE EN COURS" — oriente l'IA vers la page précise éditée."""
if pc.template_fields:
fields_block = "\n".join(
f'- "{f}" : {pc.values.get(f) or "(vide)"}'
for f in pc.template_fields
)
else:
fields_block = "(aucun champ défini dans ce template)"
return (
"--- PAGE EN COURS D'ÉDITION ---\n"
f"Titre : {pc.title}\n"
f"Template : {pc.template_name}\n"
f"Champs et valeurs actuelles :\n{fields_block}\n\n"
"IMPORTANT : concentre-toi EXCLUSIVEMENT sur cette page. "
"Si l'utilisateur te demande de proposer des idées, elles doivent "
"concerner UNIQUEMENT les champs listés ci-dessus. Ne déborde pas "
"vers d'autres pages ou d'autres templates du Lore, même si ça te "
"semblerait pertinent."
)
# --- Blocs Campagne -----------------------------------------------------
@staticmethod
def _format_campaign(ctx: CampaignStructuralContext, *, lore_present: bool) -> str:
desc = f"\nDescription : {ctx.campaign_description}" if ctx.campaign_description else ""
arcs_block = ChatUseCase._format_arcs(ctx.arcs)
lore_note = (
"\n(Cette campagne est liée à l'univers ci-dessus : tu peux t'appuyer dessus.)"
if lore_present
else "\n(Cette campagne n'est associée à aucun univers — tu peux proposer des éléments d'ambiance libres.)"
)
return (
"--- CAMPAGNE COURANTE ---\n"
f"Nom : {ctx.campaign_name}{desc}{lore_note}\n\n"
"Structure narrative (les flèches → indiquent des transitions de scène "
"déclenchées par un choix des joueurs) :\n"
f"{arcs_block}"
)
@staticmethod
def _format_arcs(arcs: list[ArcSummary]) -> str:
if not arcs:
return "(Aucun arc créé pour l'instant.)"
lines: list[str] = []
for arc in arcs:
lines.append(f"- {arc.name} (arc){ChatUseCase._illustration_hint(arc.illustration_count)}")
if arc.description:
lines.append(f" Synopsis : {arc.description}")
if not arc.chapters:
lines.append(" (aucun chapitre)")
continue
for chapter in arc.chapters:
lines.extend(ChatUseCase._format_chapter_block(chapter))
return "\n".join(lines)
@staticmethod
def _format_chapter_block(chapter: ChapterSummary) -> list[str]:
hint = ChatUseCase._illustration_hint(chapter.illustration_count)
block = [f" - {chapter.name} (chapitre){hint}"]
if chapter.description:
block.append(f" Synopsis : {chapter.description}")
if not chapter.scenes:
block.append(" (aucune scène)")
else:
for scene in chapter.scenes:
sc_hint = ChatUseCase._illustration_hint(scene.illustration_count)
block.append(f" - {scene.name} (scène){sc_hint}")
if scene.description:
block.append(f" Description : {scene.description}")
for br in scene.branches:
cond = f" (si : {br.condition})" if br.condition else ""
block.append(
f'"{br.label}" vers {br.target_scene_name}{cond}'
)
return block
@staticmethod
def _illustration_hint(count: int) -> str:
"""Rend " [N illustrations]" si count > 0, sinon chaine vide.
Informe l'IA que l'entite a deja un support visuel. Permet de prioriser
les suggestions ecrites qui collent a l'existant visuel plutot que de
diverger.
"""
if count <= 0:
return ""
noun = "illustration" if count == 1 else "illustrations"
return f" [{count} {noun}]"
@staticmethod
def _format_narrative_entity(ne: NarrativeEntityContext) -> str:
"""Bloc équivalent à _format_page mais pour Arc/Chapter/Scene."""
type_label = {"arc": "ARC", "chapter": "CHAPITRE", "scene": "SCÈNE"}.get(
ne.entity_type.lower(), ne.entity_type.upper()
)
if ne.fields:
fields_block = "\n".join(
f'- "{key}" : {value or "(vide)"}'
for key, value in ne.fields.items()
)
else:
fields_block = "(aucun champ renseigné)"
return (
f"--- {type_label} EN COURS D'ÉDITION ---\n"
f"Titre : {ne.title}\n"
f"Champs et valeurs actuelles :\n{fields_block}\n\n"
"IMPORTANT : concentre-toi EXCLUSIVEMENT sur cette entité narrative. "
"Tes suggestions doivent enrichir UNIQUEMENT les champs listés ci-dessus. "
"Ne déborde pas vers d'autres arcs, chapitres ou scènes de la campagne, "
"même si ça te semblerait pertinent."
)

View File

@@ -0,0 +1,98 @@
"""Use case : génération d'une page LoreMind à partir d'un contexte métier.
Couche APPLICATION — au-dessus du domaine, en-dessous de l'infra web.
Orchestre le flux : contexte → prompt → appel LLM → parsing JSON → résultat.
Ne dépend que des abstractions du domaine (port `LLMProvider`). C'est ce qui
permet de tester ce use case avec un FakeLLMProvider, sans Ollama qui tourne.
"""
import json
from app.domain.models import PageGenerationContext, PageGenerationResult
from app.domain.ports import LLMProvider, LLMProviderError
# Température basse : remplissage de champs = tâche factuelle, peu créative.
# Une valeur trop haute (par défaut Ollama = 0.8) encourage l'IA à broder
# et à inventer des références à des PNJ/lieux/événements inexistants.
_DEFAULT_TEMPERATURE = 0.4
_SYSTEM_INSTRUCTIONS = """Tu es un assistant d'écriture pour un Maître de Jeu de JDR.
Tu vas générer le contenu d'une page appartenant à un univers fictionnel.
Règles impératives de ta réponse :
- Tu réponds UNIQUEMENT par un objet JSON valide.
- Les clés du JSON correspondent EXACTEMENT aux noms de champs demandés.
- Les valeurs sont des chaînes de texte en français, riches et évocatrices.
- Aucun markdown, aucune explication, aucun commentaire autour du JSON.
Règles de cohérence (IMPORTANT) :
- Tu PEUX inventer des détails originaux pour CETTE page : apparence, traits de caractère, anecdotes, histoire personnelle.
- Tu ne dois PAS faire référence à d'autres personnages, lieux, organisations ou événements comme s'ils existaient déjà dans l'univers, sauf si le contexte ci-dessous les mentionne explicitement.
- Si un champ appelle une précision externe (date, nom d'un roi, ville voisine, guerre passée), reste volontairement vague : "il y a de nombreuses années", "un bourg voisin", "une époque troublée". Le MJ préfère combler lui-même les blancs plutôt que trouver des faits inventés contradictoires avec son univers."""
class GeneratePageUseCase:
"""Orchestre la génération d'une page LoreMind via un LLM."""
def __init__(self, llm: LLMProvider) -> None:
self._llm = llm
async def execute(
self,
context: PageGenerationContext,
) -> PageGenerationResult:
prompt = self._build_prompt(context)
raw = await self._llm.generate(
prompt,
output_format="json",
temperature=_DEFAULT_TEMPERATURE,
)
values = self._parse_values(raw, context.template_fields)
return PageGenerationResult(values=values)
def _build_prompt(self, context: PageGenerationContext) -> str:
fields_block = "\n".join(f'- "{field}"' for field in context.template_fields)
lore_desc_line = (
f"\nDescription de l'univers : {context.lore_description}"
if context.lore_description
else ""
)
return (
f"{_SYSTEM_INSTRUCTIONS}\n\n"
f"Univers : {context.lore_name}"
f"{lore_desc_line}\n"
f"Catégorie (dossier) : {context.folder_name}\n"
f"Gabarit : {context.template_name}\n"
f"Titre de la page à créer : {context.page_title}\n\n"
f"Champs à remplir (clés JSON attendues) :\n"
f"{fields_block}\n\n"
f"Génère maintenant le JSON."
)
def _parse_values(
self,
raw: str,
expected_fields: list[str],
) -> dict[str, str]:
try:
parsed = json.loads(raw)
except json.JSONDecodeError as exc:
raise LLMProviderError(
f"Réponse du LLM non parseable en JSON : {exc}"
) from exc
if not isinstance(parsed, dict):
raise LLMProviderError(
f"Le LLM a renvoyé un {type(parsed).__name__}, pas un objet JSON."
)
# Filtrage défensif : on ne garde que les champs demandés, cast en str,
# jamais None. Les champs absents de la réponse deviennent des chaînes vides
# (l'utilisateur les complètera manuellement dans page-edit).
return {
field: str(parsed.get(field, "")).strip()
for field in expected_fields
}

View File

54
brain/app/core/config.py Normal file
View File

@@ -0,0 +1,54 @@
"""Configuration applicative centralisée (principe 12-factor : config via env).
Équivalent Python du `application.properties` Spring Boot, avec validation
Pydantic : une variable manquante/invalide = crash au démarrage, pas une
NullPointerException surprise à la 3ème requête.
Depuis l'ecran Parametres (UI) : certains champs sont surchargeables a chaud
via `settings_store` (fichier JSON). A chaque Depends(get_settings), on relit
.env + overrides fusionnes. Pas de cache : le cout d'un read JSON local est
negligeable face a un appel LLM.
"""
from typing import Literal
from pydantic_settings import BaseSettings, SettingsConfigDict
from app.core.settings_store import load_overrides
class Settings(BaseSettings):
"""Settings chargés depuis .env ou variables d'environnement."""
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
extra="ignore",
)
# Provider LLM actif. "ollama" = local ; "onemin" = 1min.ai (etage 2).
llm_provider: Literal["ollama", "onemin"] = "ollama"
ollama_base_url: str = "http://localhost:11434"
llm_model: str = "gemma4:26b"
llm_timeout_seconds: int = 120
# Fenêtre de contexte (num_ctx Ollama). Défaut Ollama = 2048, trop étroit
# dès que le Structural Context du Lore dépasse ~10 pages (b9). On monte
# à 16384 pour tenir ~100 pages enrichies. Coût VRAM : ~600 MB de KV cache
# supplémentaire (vs 2048) pour le modèle gemma 2B. Surchargeable via
# LLM_NUM_CTX dans .env si besoin (ex: VRAM limitée → 8192).
llm_num_ctx: int = 16384
# 1min.ai (etage 2) — la cle et le modele sont stockes via settings_store
# (modifiables depuis l'UI). Les defauts ici sont juste des placeholders.
onemin_api_key: str = ""
onemin_model: str = "gpt-4o-mini"
def get_settings() -> Settings:
"""Fabrique des Settings merges (.env -> overrides runtime).
Relu a chaque requete HTTP (via Depends). Permet a l'UI de changer
le modele / provider sans redemarrer le Brain.
"""
return Settings(**load_overrides())

View File

@@ -0,0 +1,41 @@
"""Overrides runtime persistés sur disque pour les Settings.
Les Settings par defaut viennent de .env (12-factor). L'utilisateur peut
surcharger certains champs depuis l'UI (ex: modele Ollama choisi) — ces
overrides sont stockes dans un fichier JSON local, relus a chaque requete.
Thread-safe via un lock simple : suffisant pour un deploiement mono-process
(usage local). Si un jour on passe en multi-worker, migrer vers SQLite.
"""
from __future__ import annotations
import json
import threading
from pathlib import Path
from typing import Any
_LOCK = threading.Lock()
_OVERRIDES_PATH = Path("data/settings.json")
def load_overrides() -> dict[str, Any]:
"""Retourne le dict d'overrides, ou {} si le fichier n'existe pas / est corrompu."""
if not _OVERRIDES_PATH.exists():
return {}
try:
return json.loads(_OVERRIDES_PATH.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return {}
def save_overrides(patch: dict[str, Any]) -> dict[str, Any]:
"""Fusionne `patch` dans les overrides existants et persiste. Retourne l'etat final."""
with _LOCK:
current = load_overrides()
current.update(patch)
_OVERRIDES_PATH.parent.mkdir(parents=True, exist_ok=True)
_OVERRIDES_PATH.write_text(
json.dumps(current, indent=2, ensure_ascii=False),
encoding="utf-8",
)
return current

View File

186
brain/app/domain/models.py Normal file
View File

@@ -0,0 +1,186 @@
"""Modèles de domaine pour le cas d'usage de génération de page LoreMind.
On utilise @dataclass (pas Pydantic) pour garder le domaine exempt de toute
dépendance framework. Pydantic apparaît uniquement aux frontières : DTOs HTTP
dans `main.py`, Settings dans `core/config.py`.
"""
from dataclasses import dataclass, field
@dataclass(frozen=True)
class PageGenerationContext:
"""Contexte métier à fournir au LLM pour générer une page LoreMind.
Les champs correspondent aux entités du Lore Context côté Core Java :
- lore_* : l'univers (Lore)
- folder_name : le dossier (LoreNode) qui catégorise la page
- template_* : le gabarit qui liste les champs à remplir
- page_title : le titre de la page à créer
"""
lore_name: str
folder_name: str
template_name: str
template_fields: list[str]
page_title: str
lore_description: str | None = None
@dataclass(frozen=True)
class PageGenerationResult:
"""Résultat métier : une valeur textuelle générée par champ du template.
La clé du dict est le nom du champ (ex: "apparence"), la valeur est
le contenu généré par le LLM. Cohérent avec la structure
`Page.values: Map<String,String>` côté Core Java.
"""
values: dict[str, str]
@dataclass(frozen=True)
class ChatMessage:
"""Message d'une conversation — rôle + contenu textuel.
Rôles possibles (OpenAI/Ollama compatibles) :
- "system" : prompt système (contexte, instructions)
- "user" : message de l'utilisateur
- "assistant" : réponse précédente du LLM
"""
role: str
content: str
@dataclass(frozen=True)
class PageSummary:
"""Résumé enrichi d'une page du Lore, projeté pour alimenter le prompt.
Depuis b9 : on ne se contente plus du nom + template, on embarque aussi
les valeurs des champs dynamiques (tronquées côté Core Java à 500 car.),
les tags, et les titres des pages liées (les IDs techniques sont déjà
résolus en titres lisibles côté Java — voir LoreStructuralContextBuilder).
Les notes privées du MJ restent volontairement absentes ici (confinées
à leur page d'édition via PageContext quand l'utilisateur y travaille).
"""
title: str
template_name: str
values: dict[str, str]
tags: list[str]
related_page_titles: list[str]
@dataclass(frozen=True)
class LoreStructuralContext:
"""Carte structurelle enrichie d'un Lore pour nourrir l'IA.
Depuis b9 : chaque page expose son contenu (values, tags, liens) via
PageSummary. Le prompt n'est plus qu'une table des matières — c'est
une encyclopédie condensée que le LLM peut directement citer.
Le dict `folders` est indexé par nom de dossier et mappe vers la liste
des pages qu'il contient (PageSummary).
"""
lore_name: str
lore_description: str | None
folders: dict[str, list[PageSummary]]
tags: list[str]
@dataclass(frozen=True)
class PageContext:
"""Contexte d'une page spécifique en cours d'édition.
Injecté dans le system prompt pour focaliser le chat sur CETTE page
précise : son template, ses champs, ses valeurs actuelles. Permet à
l'IA d'éviter de parler d'autres pages du Lore par mégarde.
Complémentaire de `LoreStructuralContext` : l'un donne la carte
générale (toutes les pages existantes), l'autre zoome sur la page
en cours de discussion.
"""
title: str
template_name: str
template_fields: list[str]
values: dict[str, str]
@dataclass(frozen=True)
class SceneBranchHint:
"""Indice d'une branche narrative vers une autre scène du même chapitre.
Le Core Java résout déjà `targetSceneId` en nom humain avant l'envoi :
l'IA ne voit donc jamais d'UUID, seulement des noms qu'elle peut citer.
"""
label: str
target_scene_name: str
condition: str | None = None
@dataclass(frozen=True)
class SceneSummary:
"""Résumé d'une scène : nom + description courte + illustrations + branches."""
name: str
description: str | None
# Depuis l'etape 6 : permet a l'IA de savoir qu'une scene a des illustrations
# attachees. 0 par defaut pour retrocompat si le Core n'envoie rien.
illustration_count: int = 0
# Connexions narratives sortantes (livre dont vous etes le heros).
branches: list[SceneBranchHint] = field(default_factory=list)
@dataclass(frozen=True)
class ChapterSummary:
"""Résumé d'un chapitre : nom + description courte + ses scènes."""
name: str
description: str | None
scenes: list[SceneSummary]
illustration_count: int = 0
@dataclass(frozen=True)
class ArcSummary:
"""Résumé d'un arc narratif : nom + description courte + ses chapitres."""
name: str
description: str | None
chapters: list[ChapterSummary]
illustration_count: int = 0
@dataclass(frozen=True)
class CampaignStructuralContext:
"""Carte narrative enrichie d'une Campagne pour nourrir l'IA.
Jumeau de LoreStructuralContext côté Campaign. On décrit l'arbre
arcs → chapitres → scènes en donnant le NOM + une DESCRIPTION courte
(synopsis) à chaque niveau. Les champs longs (notes MJ, narration
joueur, combat) restent réservés à l'entité focus via
NarrativeEntityContext. Ordre narratif préservé dans la liste `arcs`.
"""
campaign_name: str
campaign_description: str | None
arcs: list[ArcSummary]
@dataclass(frozen=True)
class NarrativeEntityContext:
"""Contexte d'une entité narrative précise en cours d'édition.
Équivalent de PageContext côté Campaign. Focalise l'IA sur un Arc,
Chapter ou Scene en particulier. `entity_type` ∈ {"arc","chapter","scene"}.
Les `fields` sont une map ordonnée nomChamp → valeurActuelle (chaîne
vide si non renseigné).
"""
entity_type: str
title: str
fields: dict[str, str]

86
brain/app/domain/ports.py Normal file
View File

@@ -0,0 +1,86 @@
"""Ports (contrats) du domaine du Brain LoreMind.
Un Port est une INTERFACE abstraite exposée par le domaine vers le monde
extérieur. Le domaine définit CE QU'IL ATTEND, pas COMMENT c'est implémenté.
En Python moderne on privilégie Protocol (PEP 544) sur ABC pour bénéficier
du duck typing structurel : toute classe qui possède les bonnes méthodes
satisfait le contrat, sans héritage explicite.
"""
from typing import AsyncIterator, Protocol
class LLMProvider(Protocol):
"""Port sortant — contrat pour un fournisseur de modèle de langage.
Toute implémentation (Ollama, OpenAI, Claude, faux-mock de test) doit
exposer au minimum cette méthode `generate`.
"""
async def generate(
self,
prompt: str,
*,
output_format: str | None = None,
temperature: float | None = None,
) -> str:
"""Génère une réponse textuelle à partir d'un prompt donné.
Args:
prompt: le texte envoyé au modèle.
output_format: contrainte de format optionnelle. Exemple : "json"
pour forcer le modèle à renvoyer du JSON valide. Les
fournisseurs qui ne supportent pas une valeur donnée doivent
l'ignorer silencieusement ou la traduire au mieux.
temperature: créativité du modèle, 0.0 (déterministe/factuel) à
1.0+ (très créatif, hallucine plus facilement). None =
valeur par défaut de l'adapter. Recommandation LoreMind :
~0.4 pour du remplissage factuel, ~0.7 pour du chat créatif.
Raises:
LLMProviderError: si le fournisseur sous-jacent a échoué.
"""
...
class LLMChatProvider(Protocol):
"""Port sortant — fournisseur de chat streamé (conversation multi-tours).
Distinct de LLMProvider par Interface Segregation Principle : le chat
streamé est une capacité séparée (messages structurés, flux de tokens)
qui mérite son propre contrat. Un même adapter concret (ex: Ollama)
peut satisfaire les deux protocoles simultanément grâce au duck typing.
"""
async def stream_chat(
self,
messages: list["ChatMessage"], # forward ref, évite import circulaire
*,
system_prompt: str | None = None,
temperature: float | None = None,
) -> AsyncIterator[str]:
"""Streame la réponse du LLM token par token.
Args:
messages: historique de la conversation (chronologique, le dernier
message étant typiquement celui de l'utilisateur en attente
de réponse).
system_prompt: instructions système optionnelles (contexte global,
règles de comportement). Prefixe la conversation si fourni.
temperature: créativité du modèle (voir `LLMProvider.generate`).
Yields:
Fragments de texte (tokens) au fur et à mesure de la génération.
Raises:
LLMProviderError: si le fournisseur sous-jacent a échoué.
"""
...
class LLMProviderError(Exception):
"""Erreur du domaine signalant qu'un LLMProvider n'a pas pu générer.
Définie dans le domaine (pas dans l'infra) pour que les couches
supérieures puissent l'attraper sans connaître l'adapter concret.
"""

View File

View File

@@ -0,0 +1,121 @@
"""Adapter Ollama — implémentation concrète des ports LLMProvider et LLMChatProvider.
Isole le reste de l'application des spécificités du protocole Ollama
(URL /api/generate, /api/chat, payload, parsing). Pour swap vers OpenAI
demain, on écrit un nouvel adapter sans toucher au reste du code.
"""
import json
from typing import AsyncIterator
import httpx
from app.core.config import Settings
from app.domain.models import ChatMessage
from app.domain.ports import LLMProviderError
class OllamaLLMProvider:
"""Implémentation des ports LLM — appelle un serveur Ollama via HTTP.
Satisfait implicitement (duck typing) à la fois `LLMProvider` (endpoint
/api/generate, appel unique) et `LLMChatProvider` (endpoint /api/chat,
streaming token par token).
"""
def __init__(self, settings: Settings) -> None:
self._base_url = settings.ollama_base_url
self._model = settings.llm_model
self._timeout = settings.llm_timeout_seconds
self._num_ctx = settings.llm_num_ctx
def _build_options(self, temperature: float | None) -> dict[str, object]:
"""Construit le dict `options` attendu par Ollama (hyperparamètres).
`num_ctx` est TOUJOURS envoyé — sinon Ollama retombe sur son défaut
2048 et tronque silencieusement les gros prompts (Structural Context
du Lore enrichi depuis b9). `temperature` n'est ajoutée que si
fournie par le use case (sinon Ollama utilise son défaut).
"""
options: dict[str, object] = {"num_ctx": self._num_ctx}
if temperature is not None:
options["temperature"] = temperature
return options
async def generate(
self,
prompt: str,
*,
output_format: str | None = None,
temperature: float | None = None,
) -> str:
url = f"{self._base_url}/api/generate"
payload: dict[str, object] = {
"model": self._model,
"prompt": prompt,
"stream": False,
"options": self._build_options(temperature),
}
if output_format is not None:
payload["format"] = output_format
async with httpx.AsyncClient(timeout=self._timeout) as client:
try:
response = await client.post(url, json=payload)
response.raise_for_status()
except httpx.HTTPError as exc:
raise LLMProviderError(
f"Erreur lors de l'appel à Ollama : {exc}"
) from exc
return response.json()["response"]
async def stream_chat(
self,
messages: list[ChatMessage],
*,
system_prompt: str | None = None,
temperature: float | None = None,
) -> AsyncIterator[str]:
"""Streame depuis Ollama /api/chat. Parse le NDJSON ligne par ligne.
Ollama renvoie un JSON par ligne au fil de la génération :
- étapes intermédiaires : `{"message": {"content": "token"}, "done": false}`
- étape finale : `{"done": true, ...}`
On yield chaque token non-vide au consommateur, qui se charge du
formatage SSE (c'est la responsabilité du controller HTTP, pas
de l'adapter LLM).
"""
url = f"{self._base_url}/api/chat"
payload_messages: list[dict[str, str]] = []
if system_prompt:
payload_messages.append({"role": "system", "content": system_prompt})
payload_messages.extend(
{"role": m.role, "content": m.content} for m in messages
)
payload: dict[str, object] = {
"model": self._model,
"messages": payload_messages,
"stream": True,
"options": self._build_options(temperature),
}
async with httpx.AsyncClient(timeout=self._timeout) as client:
try:
async with client.stream("POST", url, json=payload) as response:
response.raise_for_status()
async for line in response.aiter_lines():
if not line.strip():
continue
chunk = json.loads(line)
if chunk.get("done"):
break
token = chunk.get("message", {}).get("content", "")
if token:
yield token
except httpx.HTTPError as exc:
raise LLMProviderError(
f"Erreur lors du streaming Ollama : {exc}"
) from exc

View File

@@ -0,0 +1,174 @@
"""Adapter 1min.ai — implementation alternative des ports LLMProvider / LLMChatProvider.
API 1min.ai (cf. https://docs.1min.ai/docs/api/chat-with-ai-api) :
- POST https://api.1min.ai/api/chat-with-ai (one-shot)
- POST https://api.1min.ai/api/chat-with-ai?isStreaming=true (SSE)
- Auth : header "API-KEY: <cle>"
- Body : {"type": "UNIFY_CHAT_WITH_AI", "model": "...",
"promptObject": {"prompt": "..."}}
Le port LoreMind expose une API "messages[]", mais 1min.ai attend un prompt
unique. On aplatit donc l'historique + system prompt en un seul bloc texte,
avec des marqueurs de role lisibles pour le modele.
"""
from __future__ import annotations
import json
from typing import AsyncIterator
import httpx
from app.core.config import Settings
from app.domain.models import ChatMessage
from app.domain.ports import LLMProviderError
_API_BASE = "https://api.1min.ai/api/chat-with-ai"
_PAYLOAD_TYPE = "UNIFY_CHAT_WITH_AI"
class OneMinAiLLMProvider:
"""Adapter 1min.ai — satisfait LLMProvider et LLMChatProvider par duck typing."""
def __init__(self, settings: Settings) -> None:
if not settings.onemin_api_key:
raise LLMProviderError(
"Cle API 1min.ai manquante. Configure-la depuis l'ecran Parametres."
)
self._api_key = settings.onemin_api_key
self._model = settings.onemin_model
self._timeout = settings.llm_timeout_seconds
def _headers(self) -> dict[str, str]:
return {"API-KEY": self._api_key, "Content-Type": "application/json"}
def _payload(self, prompt: str) -> dict[str, object]:
return {
"type": _PAYLOAD_TYPE,
"model": self._model,
"promptObject": {"prompt": prompt},
}
async def generate(
self,
prompt: str,
*,
output_format: str | None = None, # 1min.ai ne supporte pas format=json
temperature: float | None = None, # idem, pas d'hyperparam expose ici
) -> str:
"""Appel one-shot : retourne la reponse complete sous forme de string."""
async with httpx.AsyncClient(timeout=self._timeout) as client:
try:
response = await client.post(
_API_BASE, headers=self._headers(), json=self._payload(prompt)
)
response.raise_for_status()
data = response.json()
except httpx.HTTPError as exc:
raise LLMProviderError(f"Erreur 1min.ai : {exc}") from exc
return self._extract_result(data)
async def stream_chat(
self,
messages: list[ChatMessage],
*,
system_prompt: str | None = None,
temperature: float | None = None,
) -> AsyncIterator[str]:
"""Streame via SSE.
1min.ai expose deux evenements utiles :
- `event: content` → `data: {"content": "..."}`
- `event: done` → fin du stream
- `event: error` → erreur serveur
On yield le champ `content` au fil de l'arrivee.
"""
prompt = self._flatten_messages(messages, system_prompt)
url = f"{_API_BASE}?isStreaming=true"
async with httpx.AsyncClient(timeout=self._timeout) as client:
try:
async with client.stream(
"POST", url, headers=self._headers(), json=self._payload(prompt)
) as response:
response.raise_for_status()
async for token in self._parse_sse(response):
yield token
except httpx.HTTPError as exc:
raise LLMProviderError(
f"Erreur lors du streaming 1min.ai : {exc}"
) from exc
# --- Helpers ------------------------------------------------------------
@staticmethod
async def _parse_sse(response: httpx.Response) -> AsyncIterator[str]:
"""Decoupe le flux SSE ligne par ligne et yield les chunks 'content'."""
current_event: str | None = None
current_data = ""
async for line in response.aiter_lines():
if line == "":
# Fin d'un evenement SSE : dispatch
if current_event == "done":
return
if current_event == "error":
raise LLMProviderError(f"1min.ai a signale une erreur : {current_data}")
if current_data and current_event in (None, "content", "message"):
token = OneMinAiLLMProvider._extract_content_chunk(current_data)
if token:
yield token
current_event = None
current_data = ""
continue
if line.startswith("event:"):
current_event = line[6:].strip()
elif line.startswith("data:"):
chunk = line[5:].lstrip()
current_data = f"{current_data}\n{chunk}" if current_data else chunk
@staticmethod
def _extract_content_chunk(data: str) -> str:
"""Extrait le champ `content` d'un data JSON, avec tolerance si format brut."""
try:
obj = json.loads(data)
except json.JSONDecodeError:
return data # filet de securite si le serveur envoie du texte brut
if isinstance(obj, dict):
return obj.get("content") or obj.get("token") or ""
return ""
@staticmethod
def _extract_result(payload: dict) -> str:
"""Extrait le texte final d'une reponse non-streamee.
Schema attendu : `aiRecord.aiRecordDetail.resultObject` (list[str]).
On concatene par securite (le serveur renvoie habituellement un seul element).
"""
record = payload.get("aiRecord") or {}
detail = record.get("aiRecordDetail") or {}
result = detail.get("resultObject") or []
if isinstance(result, list):
return "".join(str(x) for x in result)
if isinstance(result, str):
return result
raise LLMProviderError("Reponse 1min.ai inattendue : resultObject absent.")
@staticmethod
def _flatten_messages(
messages: list[ChatMessage], system_prompt: str | None
) -> str:
"""Transforme [system_prompt, history] en un unique prompt textuel.
1min.ai n'accepte qu'un champ `prompt` : on serialise la conversation
avec des marqueurs explicites pour que le modele comprenne les tours.
"""
parts: list[str] = []
if system_prompt:
parts.append(f"[SYSTEM]\n{system_prompt}")
if messages:
history = "\n\n".join(
f"[{m.role.upper()}]\n{m.content}" for m in messages
)
parts.append(history)
parts.append("[ASSISTANT]") # invite le modele a continuer
return "\n\n".join(parts)

567
brain/app/main.py Normal file
View File

@@ -0,0 +1,567 @@
"""Point d'entrée FastAPI du Brain LoreMind.
Controller volontairement FIN : il valide l'entrée (DTOs Pydantic), délègue
au domaine via injection de dépendance (ports + use cases), et transforme les
erreurs du domaine en réponses HTTP. Aucune connaissance d'Ollama ici.
"""
import json
from typing import Annotated, AsyncIterator, Literal
import httpx
from fastapi import Depends, FastAPI, HTTPException
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from app.application.chat import ChatUseCase
from app.application.generate_page import GeneratePageUseCase
from app.core.config import Settings, get_settings
from app.core.settings_store import save_overrides
from app.domain.models import (
ArcSummary,
CampaignStructuralContext,
ChapterSummary,
ChatMessage,
LoreStructuralContext,
NarrativeEntityContext,
PageContext,
PageGenerationContext,
PageSummary,
SceneBranchHint,
SceneSummary,
)
from app.domain.ports import LLMProvider, LLMProviderError
from app.infrastructure.ollama_adapter import OllamaLLMProvider
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.1.0",
)
# --- DTOs HTTP (frontière, c'est ici et seulement ici qu'on utilise Pydantic) ---
class GenerateRequest(BaseModel):
prompt: str
class GenerateResponse(BaseModel):
model: str
response: str
class GeneratePageRequestDTO(BaseModel):
"""Contexte envoyé par le Core Java pour remplir une page via le LLM."""
lore_name: str
folder_name: str
template_name: str
template_fields: list[str] = Field(min_length=1)
page_title: str
lore_description: str | None = None
class GeneratePageResponseDTO(BaseModel):
"""Retour : une valeur textuelle par champ du template (clé = field name)."""
values: dict[str, str]
class ChatMessageDTO(BaseModel):
"""Un message de la conversation. Rôles acceptés : user, assistant, system."""
role: str = Field(pattern="^(user|assistant|system)$")
content: str
class PageSummaryDTO(BaseModel):
"""Résumé enrichi d'une page : identité + contenu + interconnexions.
Depuis b9 : values/tags/related_page_titles sont optionnels côté JSON —
le Core Java ne les sérialise que s'ils sont non-vides (payload léger
pour un Lore avec beaucoup de pages vierges).
"""
title: str
template_name: str
values: dict[str, str] = Field(default_factory=dict)
tags: list[str] = Field(default_factory=list)
related_page_titles: list[str] = Field(default_factory=list)
class LoreContextDTO(BaseModel):
"""Carte structurelle du Lore avec contenu des pages (b9+)."""
lore_name: str
lore_description: str | None = None
folders: dict[str, list[PageSummaryDTO]] = Field(default_factory=dict)
tags: list[str] = Field(default_factory=list)
class PageContextDTO(BaseModel):
"""Contexte d'une page spécifique pour focaliser le chat (optionnel)."""
title: str
template_name: str
template_fields: list[str] = Field(default_factory=list)
values: dict[str, str] = Field(default_factory=dict)
class SceneBranchHintDTO(BaseModel):
"""Indice d'une branche narrative (le Core a deja resolu le nom cible)."""
label: str
target_scene_name: str
condition: str | None = None
class SceneSummaryDTO(BaseModel):
"""Résumé d'une scène : nom + description courte (synopsis)."""
name: str
description: str | None = None
# Optionnel : le Core Java ne serialise illustration_count QUE si > 0
# (payload plus leger). Defaut 0 = pas d'illustrations ou champ absent.
illustration_count: int = 0
# Branches narratives sortantes, omises cote Core si vides.
branches: list[SceneBranchHintDTO] = Field(default_factory=list)
class ChapterSummaryDTO(BaseModel):
"""Résumé d'un chapitre : nom + description courte + ses scènes."""
name: str
description: str | None = None
scenes: list[SceneSummaryDTO] = Field(default_factory=list)
illustration_count: int = 0
class ArcSummaryDTO(BaseModel):
"""Résumé d'un arc narratif : nom + description courte + ses chapitres."""
name: str
description: str | None = None
chapters: list[ChapterSummaryDTO] = Field(default_factory=list)
illustration_count: int = 0
class CampaignContextDTO(BaseModel):
"""Carte narrative enrichie : arcs → chapitres → scènes avec synopsis."""
campaign_name: str
campaign_description: str | None = None
arcs: list[ArcSummaryDTO] = Field(default_factory=list)
class NarrativeEntityDTO(BaseModel):
"""Entité narrative (arc/chapter/scene) en cours d'édition — focus optionnel."""
entity_type: str = Field(pattern="^(arc|chapter|scene)$")
title: str
fields: dict[str, str] = Field(default_factory=dict)
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.
"""
messages: list[ChatMessageDTO] = Field(min_length=1)
lore_context: LoreContextDTO | None = None
page_context: PageContextDTO | None = None
campaign_context: CampaignContextDTO | None = None
narrative_entity: NarrativeEntityDTO | 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
# --- Factories d'injection de dépendance ---
def get_llm_provider(
settings: Annotated[Settings, Depends(get_settings)],
) -> LLMProvider:
"""Factory d'adapter — point d'inversion de dépendance.
C'est ici (et uniquement ici) qu'on choisit QUEL adapter concret
incarne le port, en fonction du champ `llm_provider` des Settings
(modifiable a chaud depuis l'ecran Parametres de l'UI).
"""
try:
if settings.llm_provider == "onemin":
return OneMinAiLLMProvider(settings)
return OllamaLLMProvider(settings)
except LLMProviderError as exc:
# Ex : cle 1min.ai manquante. On renvoie du 400 plutot que du 500
# pour que le frontend puisse afficher un message actionnable.
raise HTTPException(status_code=400, detail=str(exc)) from exc
def get_generate_page_use_case(
llm: Annotated[LLMProvider, Depends(get_llm_provider)],
) -> GeneratePageUseCase:
"""Factory du use case — injecte le port LLMProvider sans connaître l'adapter."""
return GeneratePageUseCase(llm=llm)
def get_chat_use_case(
llm: Annotated[LLMProvider, Depends(get_llm_provider)],
) -> ChatUseCase:
"""Factory du use case chat.
L'adapter OllamaLLMProvider satisfait les deux protocoles (LLMProvider
et LLMChatProvider) par duck typing ; on lui passe la même instance.
"""
return ChatUseCase(llm=llm) # type: ignore[arg-type]
# --- Endpoints ---
@app.get("/health")
def health() -> dict[str, str]:
"""Sonde de santé — permet au Core Java de vérifier que le Brain répond."""
return {"status": "ok", "service": "brain"}
@app.post("/generate", response_model=GenerateResponse)
async def generate(
body: GenerateRequest,
settings: Annotated[Settings, Depends(get_settings)],
llm: Annotated[LLMProvider, Depends(get_llm_provider)],
) -> GenerateResponse:
"""Endpoint libre : prompt → texte brut. Utile pour debug et exploration."""
try:
text = await llm.generate(body.prompt)
except LLMProviderError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
return GenerateResponse(model=settings.llm_model, response=text)
@app.post("/generate-page", response_model=GeneratePageResponseDTO)
async def generate_page(
body: GeneratePageRequestDTO,
use_case: Annotated[
GeneratePageUseCase, Depends(get_generate_page_use_case)
],
) -> GeneratePageResponseDTO:
"""Endpoint métier : contexte LoreMind → valeurs structurées par champ.
Branche tout le use case `GeneratePageUseCase`. Ce controller ne fait
que le mapping DTO ↔ dataclass et la traduction d'erreur domaine → HTTP.
"""
context = PageGenerationContext(
lore_name=body.lore_name,
lore_description=body.lore_description,
folder_name=body.folder_name,
template_name=body.template_name,
template_fields=body.template_fields,
page_title=body.page_title,
)
try:
result = await use_case.execute(context)
except LLMProviderError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
return GeneratePageResponseDTO(values=result.values)
@app.post("/chat/stream")
async def chat_stream(
body: ChatStreamRequestDTO,
use_case: Annotated[ChatUseCase, Depends(get_chat_use_case)],
) -> StreamingResponse:
"""Chat streamé (Server-Sent Events) avec Structural Context.
Accepte jusqu'à 4 contextes optionnels (Lore, Page focalisée, Campagne,
entité narrative focalisée). Au moins un contexte racine (Lore ou
Campagne) est requis pour que la requête ait du sens.
Format de flux :
- Chaque token : `data: {"token": "..."}\\n\\n`
- Fin normale : `event: done\\ndata: {}\\n\\n`
- Erreur LLM : `event: error\\ndata: {"message": "..."}\\n\\n`
"""
if not body.has_scope():
raise HTTPException(
status_code=422,
detail="Au moins un des deux contextes racines (lore_context ou campaign_context) est requis.",
)
messages = [ChatMessage(role=m.role, content=m.content) for m in body.messages]
lore_context = _to_lore_context(body.lore_context)
page_context = _to_page_context(body.page_context)
campaign_context = _to_campaign_context(body.campaign_context)
narrative_entity = _to_narrative_entity(body.narrative_entity)
async def event_stream() -> AsyncIterator[str]:
try:
async for token in use_case.stream(
messages,
lore_context=lore_context,
page_context=page_context,
campaign_context=campaign_context,
narrative_entity=narrative_entity,
):
# json.dumps avec ensure_ascii=False pour préserver les accents
yield f"data: {json.dumps({'token': token}, ensure_ascii=False)}\n\n"
yield "event: done\ndata: {}\n\n"
except LLMProviderError as exc:
yield f"event: error\ndata: {json.dumps({'message': str(exc)})}\n\n"
return StreamingResponse(event_stream(), media_type="text/event-stream")
# --- Mapping DTO → domaine (frontière HTTP) ---------------------------------
def _to_lore_context(dto: LoreContextDTO | None) -> LoreStructuralContext | None:
if dto is None:
return None
return LoreStructuralContext(
lore_name=dto.lore_name,
lore_description=dto.lore_description,
folders={
folder: [_to_page_summary(p) for p in pages]
for folder, pages in dto.folders.items()
},
tags=dto.tags,
)
def _to_page_summary(dto: PageSummaryDTO) -> PageSummary:
return PageSummary(
title=dto.title,
template_name=dto.template_name,
values=dict(dto.values),
tags=list(dto.tags),
related_page_titles=list(dto.related_page_titles),
)
def _to_page_context(dto: PageContextDTO | None) -> PageContext | None:
if dto is None:
return None
return PageContext(
title=dto.title,
template_name=dto.template_name,
template_fields=dto.template_fields,
values=dto.values,
)
def _to_campaign_context(dto: CampaignContextDTO | None) -> CampaignStructuralContext | None:
if dto is None:
return None
arcs = [
ArcSummary(
name=arc.name,
description=arc.description,
illustration_count=arc.illustration_count,
chapters=[
ChapterSummary(
name=ch.name,
description=ch.description,
illustration_count=ch.illustration_count,
scenes=[
SceneSummary(
name=sc.name,
description=sc.description,
illustration_count=sc.illustration_count,
branches=[
SceneBranchHint(
label=br.label,
target_scene_name=br.target_scene_name,
condition=br.condition,
)
for br in sc.branches
],
)
for sc in ch.scenes
],
)
for ch in arc.chapters
],
)
for arc in dto.arcs
]
return CampaignStructuralContext(
campaign_name=dto.campaign_name,
campaign_description=dto.campaign_description,
arcs=arcs,
)
# --- Settings (parametrage runtime depuis l'UI) ------------------------------
class SettingsDTO(BaseModel):
"""Vue serialisable des settings modifiables depuis l'UI.
Expose uniquement les champs que l'utilisateur peut changer a chaud.
Les secrets (onemin_api_key) sont masques en lecture.
"""
llm_provider: Literal["ollama", "onemin"]
ollama_base_url: str
llm_model: str
onemin_model: str
# True si une cle 1min.ai est deja configuree — pas de leak de la cle elle-meme.
onemin_api_key_set: bool
class SettingsUpdateDTO(BaseModel):
"""Patch partiel des settings. Tous les champs sont optionnels."""
llm_provider: Literal["ollama", "onemin"] | None = None
ollama_base_url: str | None = None
llm_model: str | None = None
onemin_model: str | None = None
# Chaine vide => on efface la cle. None => pas de changement.
onemin_api_key: str | None = None
def _to_settings_dto(s: Settings) -> SettingsDTO:
return SettingsDTO(
llm_provider=s.llm_provider,
ollama_base_url=s.ollama_base_url,
llm_model=s.llm_model,
onemin_model=s.onemin_model,
onemin_api_key_set=bool(s.onemin_api_key),
)
@app.get("/settings", response_model=SettingsDTO)
def read_settings(settings: Annotated[Settings, Depends(get_settings)]) -> SettingsDTO:
"""Retourne la config courante (secrets masques)."""
return _to_settings_dto(settings)
@app.put("/settings", response_model=SettingsDTO)
def update_settings(patch: SettingsUpdateDTO) -> SettingsDTO:
"""Applique un patch partiel aux settings et persiste les overrides.
Toute requete HTTP suivante verra les nouvelles valeurs (pas de cache).
"""
overrides = {k: v for k, v in patch.model_dump().items() if v is not None}
if overrides:
save_overrides(overrides)
# Relit .env + overrides fusionnes pour confirmation.
return _to_settings_dto(get_settings())
@app.get("/models/ollama")
async def list_ollama_models(
settings: Annotated[Settings, Depends(get_settings)],
) -> dict[str, list[str]]:
"""Liste les modeles disponibles sur le serveur Ollama configure.
Retourne une liste vide si Ollama est injoignable — l'UI affichera un
message plutot qu'une 500.
"""
url = f"{settings.ollama_base_url}/api/tags"
try:
async with httpx.AsyncClient(timeout=5) as client:
response = await client.get(url)
response.raise_for_status()
data = response.json()
except httpx.HTTPError:
return {"models": []}
models = [m.get("name", "") for m in data.get("models", []) if m.get("name")]
return {"models": sorted(models)}
@app.get("/models/onemin")
def list_onemin_models() -> dict[str, list[dict[str, object]]]:
"""Catalogue statique des modeles 1min.ai, groupes par fournisseur.
Liste construite par probing direct de l'endpoint chat-with-ai avec
une vraie cle API (avril 2026) : chaque ID renvoie 200, les IDs
absents renvoient 400 UNSUPPORTED_MODEL.
Nota : les IDs Anthropic utilisent la nomenclature propre a 1min.ai
(`claude-<family>-<version>`), pas la convention officielle Anthropic.
"""
return {
"groups": [
{
"provider": "Anthropic",
"models": ["claude-opus-4-6", "claude-sonnet-4-6"],
},
{
"provider": "OpenAI",
"models": [
"gpt-5",
"gpt-5-mini",
"gpt-5-nano",
"gpt-4.1",
"gpt-4.1-mini",
"gpt-4.1-nano",
"gpt-4o",
"gpt-4o-mini",
"gpt-4-turbo",
"gpt-3.5-turbo",
"o3",
"o3-pro",
"o3-mini",
"o4-mini",
],
},
{
"provider": "Google",
"models": ["gemini-2.5-pro", "gemini-2.5-flash"],
},
{
"provider": "Mistral",
"models": [
"mistral-large-latest",
"mistral-medium-latest",
"mistral-small-latest",
"open-mistral-nemo",
],
},
{
"provider": "DeepSeek",
"models": ["deepseek-chat", "deepseek-reasoner"],
},
{
"provider": "xAI",
"models": ["grok-3", "grok-3-mini"],
},
{
"provider": "Meta",
"models": [
"meta/meta-llama-3.1-405b-instruct",
"meta/meta-llama-3-70b-instruct",
],
},
{
"provider": "Alibaba",
"models": ["qwen-plus", "qwen3-max"],
},
{
"provider": "Perplexity",
"models": ["sonar", "sonar-pro"],
},
]
}
def _to_narrative_entity(dto: NarrativeEntityDTO | None) -> NarrativeEntityContext | None:
if dto is None:
return None
return NarrativeEntityContext(
entity_type=dto.entity_type,
title=dto.title,
fields=dict(dto.fields),
)

7
brain/data/settings.json Normal file
View File

@@ -0,0 +1,7 @@
{
"llm_provider": "onemin",
"ollama_base_url": "http://localhost:11434",
"llm_model": "gemma4:26b",
"onemin_model": "mistral-large-latest",
"onemin_api_key": "9f8eb3da313eef5e95887889b7d10b42bbc1c42b2d157bc3589a8962e5d9dd9e"
}

4
brain/requirements.txt Normal file
View File

@@ -0,0 +1,4 @@
fastapi==0.115.*
uvicorn[standard]==0.32.*
httpx==0.27.*
pydantic-settings==2.6.*

4
core/.dockerignore Normal file
View File

@@ -0,0 +1,4 @@
target/
.idea/
*.iml
.mvn/

12
core/Dockerfile Normal file
View File

@@ -0,0 +1,12 @@
FROM maven:3.9-eclipse-temurin-17 AS build
WORKDIR /build
COPY pom.xml .
RUN mvn dependency:go-offline -B
COPY src ./src
RUN mvn clean package -DskipTests -B
FROM eclipse-temurin:17-jre
WORKDIR /app
COPY --from=build /build/target/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]

97
core/pom.xml Normal file
View File

@@ -0,0 +1,97 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.0</version>
<relativePath/>
</parent>
<groupId>com.loremind</groupId>
<artifactId>loremind-core</artifactId>
<version>1.0.0</version>
<name>LoreMind Core</name>
<description>Backend Core - Architecture Hexagonale</description>
<properties>
<java.version>17</java.version>
</properties>
<dependencies>
<!-- Spring Boot Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Spring WebFlux : requis pour WebClient (streaming SSE vers le Brain).
RestTemplate (Web MVC) reste pour les appels synchrones one-shot. -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
<!-- Spring Boot Data JPA -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- PostgreSQL Driver -->
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<!-- H2 Database pour les tests -->
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>test</scope>
</dependency>
<!-- Lombok (réduit le code boilerplate) -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- Spring Boot Test -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!-- MinIO — client S3-compatible pour le stockage d'images (Shared Kernel images). -->
<dependency>
<groupId>io.minio</groupId>
<artifactId>minio</artifactId>
<version>8.5.11</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</project>

View File

@@ -0,0 +1,16 @@
package com.loremind;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* Classe principale de l'application LoreMind.
* Point d'entrée Spring Boot qui démarre l'application.
*/
@SpringBootApplication
public class LoreMindApplication {
public static void main(String[] args) {
SpringApplication.run(LoreMindApplication.class, args);
}
}

View File

@@ -0,0 +1,77 @@
package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Arc;
import com.loremind.domain.campaigncontext.ports.ArcRepository;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Optional;
/**
* Service d'application pour le contexte Arc.
* Orchestre la logique métier en utilisant le Port ArcRepository.
* Fait partie de la couche Application de l'Architecture Hexagonale.
*/
@Service
public class ArcService {
private final ArcRepository arcRepository;
public ArcService(ArcRepository arcRepository) {
this.arcRepository = arcRepository;
}
public Arc createArc(String name, String description, String campaignId, int order) {
Arc arc = Arc.builder()
.name(name)
.description(description)
.campaignId(campaignId)
.order(order)
.build();
return arcRepository.save(arc);
}
public Optional<Arc> getArcById(String id) {
return arcRepository.findById(id);
}
public List<Arc> getAllArcs() {
return arcRepository.findAll();
}
public List<Arc> getArcsByCampaignId(String campaignId) {
return arcRepository.findByCampaignId(campaignId);
}
/**
* Met à jour un Arc avec tous ses champs narratifs.
* Accepte un objet Arc pour éviter l'explosion de paramètres (Parameter Object pattern).
*/
public Arc updateArc(String id, Arc updated) {
Optional<Arc> existingArc = arcRepository.findById(id);
if (existingArc.isEmpty()) {
throw new IllegalArgumentException("Arc non trouvé avec l'ID: " + id);
}
Arc arc = existingArc.get();
arc.setName(updated.getName());
arc.setDescription(updated.getDescription());
arc.setOrder(updated.getOrder());
arc.setThemes(updated.getThemes());
arc.setStakes(updated.getStakes());
arc.setGmNotes(updated.getGmNotes());
arc.setRewards(updated.getRewards());
arc.setResolution(updated.getResolution());
arc.setRelatedPageIds(updated.getRelatedPageIds());
arc.setIllustrationImageIds(updated.getIllustrationImageIds());
return arcRepository.save(arc);
}
public void deleteArc(String id) {
arcRepository.deleteById(id);
}
public boolean arcExists(String id) {
return arcRepository.existsById(id);
}
}

View File

@@ -0,0 +1,84 @@
package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Campaign;
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Optional;
/**
* Service d'application pour le contexte Campaign.
* Orchestre la logique métier en utilisant le Port CampaignRepository.
* Fait partie de la couche Application de l'Architecture Hexagonale.
*/
@Service
public class CampaignService {
private final CampaignRepository campaignRepository;
public CampaignService(CampaignRepository campaignRepository) {
this.campaignRepository = campaignRepository;
}
/**
* Parameter Object pour la création / mise à jour d'une Campaign.
* Évite une signature à rallonge et rend les évolutions futures (theme,
* coverImageUrl, etc.) sans casser les appelants.
*
* <p>{@code loreId} est nullable : une campagne peut exister sans univers associé.</p>
*/
public record CampaignData(String name, String description, String loreId) {}
public Campaign createCampaign(CampaignData data) {
Campaign campaign = Campaign.builder()
.name(data.name())
.description(data.description())
.loreId(normalizeLoreId(data.loreId()))
.arcsCount(0)
.build();
return campaignRepository.save(campaign);
}
public Optional<Campaign> getCampaignById(String id) {
return campaignRepository.findById(id);
}
public List<Campaign> getAllCampaigns() {
return campaignRepository.findAll();
}
public Campaign updateCampaign(String id, CampaignData data) {
Optional<Campaign> existingCampaign = campaignRepository.findById(id);
if (existingCampaign.isEmpty()) {
throw new IllegalArgumentException("Campaign non trouvé avec l'ID: " + id);
}
Campaign campaign = existingCampaign.get();
campaign.setName(data.name());
campaign.setDescription(data.description());
campaign.setLoreId(normalizeLoreId(data.loreId()));
return campaignRepository.save(campaign);
}
/**
* Normalise un loreId entrant : une chaîne vide/blanche est traitée comme "pas de lien".
* Utile car les payloads JSON peuvent envoyer "" au lieu de null.
*/
private String normalizeLoreId(String loreId) {
return (loreId == null || loreId.isBlank()) ? null : loreId;
}
public void deleteCampaign(String id) {
campaignRepository.deleteById(id);
}
public boolean campaignExists(String id) {
return campaignRepository.existsById(id);
}
public List<Campaign> searchCampaigns(String query) {
if (query == null || query.isBlank()) return List.of();
return campaignRepository.searchByName(query.trim());
}
}

View File

@@ -0,0 +1,74 @@
package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Chapter;
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Optional;
/**
* Service d'application pour le contexte Chapter.
* Orchestre la logique métier en utilisant le Port ChapterRepository.
* Fait partie de la couche Application de l'Architecture Hexagonale.
*/
@Service
public class ChapterService {
private final ChapterRepository chapterRepository;
public ChapterService(ChapterRepository chapterRepository) {
this.chapterRepository = chapterRepository;
}
public Chapter createChapter(String name, String description, String arcId, int order) {
Chapter chapter = Chapter.builder()
.name(name)
.description(description)
.arcId(arcId)
.order(order)
.build();
return chapterRepository.save(chapter);
}
public Optional<Chapter> getChapterById(String id) {
return chapterRepository.findById(id);
}
public List<Chapter> getAllChapters() {
return chapterRepository.findAll();
}
public List<Chapter> getChaptersByArcId(String arcId) {
return chapterRepository.findByArcId(arcId);
}
/**
* Met à jour un Chapter avec tous ses champs narratifs (Parameter Object pattern).
*/
public Chapter updateChapter(String id, Chapter updated) {
Optional<Chapter> existingChapter = chapterRepository.findById(id);
if (existingChapter.isEmpty()) {
throw new IllegalArgumentException("Chapter non trouvé avec l'ID: " + id);
}
Chapter chapter = existingChapter.get();
chapter.setName(updated.getName());
chapter.setDescription(updated.getDescription());
chapter.setOrder(updated.getOrder());
chapter.setGmNotes(updated.getGmNotes());
chapter.setPlayerObjectives(updated.getPlayerObjectives());
chapter.setNarrativeStakes(updated.getNarrativeStakes());
chapter.setRelatedPageIds(updated.getRelatedPageIds());
chapter.setIllustrationImageIds(updated.getIllustrationImageIds());
return chapterRepository.save(chapter);
}
public void deleteChapter(String id) {
chapterRepository.deleteById(id);
}
public boolean chapterExists(String id) {
return chapterRepository.existsById(id);
}
}

View File

@@ -0,0 +1,121 @@
package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Scene;
import com.loremind.domain.campaigncontext.SceneBranch;
import com.loremind.domain.campaigncontext.ports.SceneRepository;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
/**
* Service d'application pour le contexte Scene.
* Orchestre la logique métier en utilisant le Port SceneRepository.
* Fait partie de la couche Application de l'Architecture Hexagonale.
*/
@Service
public class SceneService {
private final SceneRepository sceneRepository;
public SceneService(SceneRepository sceneRepository) {
this.sceneRepository = sceneRepository;
}
public Scene createScene(String name, String description, String chapterId, int order) {
Scene scene = Scene.builder()
.name(name)
.description(description)
.chapterId(chapterId)
.order(order)
.build();
return sceneRepository.save(scene);
}
public Optional<Scene> getSceneById(String id) {
return sceneRepository.findById(id);
}
public List<Scene> getAllScenes() {
return sceneRepository.findAll();
}
public List<Scene> getScenesByChapterId(String chapterId) {
return sceneRepository.findByChapterId(chapterId);
}
/**
* Met à jour une Scene avec tous ses champs narratifs (Parameter Object pattern).
*/
public Scene updateScene(String id, Scene updated) {
Optional<Scene> existingScene = sceneRepository.findById(id);
if (existingScene.isEmpty()) {
throw new IllegalArgumentException("Scene non trouvée avec l'ID: " + id);
}
Scene scene = existingScene.get();
scene.setName(updated.getName());
scene.setDescription(updated.getDescription());
scene.setOrder(updated.getOrder());
scene.setLocation(updated.getLocation());
scene.setTiming(updated.getTiming());
scene.setAtmosphere(updated.getAtmosphere());
scene.setPlayerNarration(updated.getPlayerNarration());
scene.setGmSecretNotes(updated.getGmSecretNotes());
scene.setChoicesConsequences(updated.getChoicesConsequences());
scene.setCombatDifficulty(updated.getCombatDifficulty());
scene.setEnemies(updated.getEnemies());
scene.setRelatedPageIds(updated.getRelatedPageIds());
scene.setIllustrationImageIds(updated.getIllustrationImageIds());
scene.setBranches(updated.getBranches());
// Validation métier : le graphe narratif doit rester cohérent.
validateBranches(scene);
return sceneRepository.save(scene);
}
public void deleteScene(String id) {
sceneRepository.deleteById(id);
}
public boolean sceneExists(String id) {
return sceneRepository.existsById(id);
}
/**
* Vérifie les invariants du graphe narratif :
* 1. Pas d'auto-référence (scène qui pointe sur elle-même).
* 2. Toutes les branches pointent vers des scènes du MÊME chapitre.
* 3. Pas de targetSceneId null/vide.
*
* Note : on ne vérifie PAS l'existence réelle de chaque scène cible
* individuellement (ça serait un N+1). On charge une seule fois les
* IDs du chapitre et on compare.
*/
private void validateBranches(Scene scene) {
List<SceneBranch> branches = scene.getBranches();
if (branches == null || branches.isEmpty()) return;
// IDs des scènes du chapitre courant (référentiel de validation)
Set<String> chapterSceneIds = sceneRepository.findByChapterId(scene.getChapterId()).stream()
.map(Scene::getId)
.collect(Collectors.toSet());
for (SceneBranch b : branches) {
String target = b.getTargetSceneId();
if (target == null || target.isBlank()) {
throw new IllegalArgumentException("Une branche doit avoir une scène de destination");
}
if (target.equals(scene.getId())) {
throw new IllegalArgumentException("Une scène ne peut pas se brancher sur elle-même");
}
if (!chapterSceneIds.contains(target)) {
throw new IllegalArgumentException(
"La branche pointe vers la scène " + target + " qui n'appartient pas au même chapitre");
}
}
}
}

View File

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

View File

@@ -0,0 +1,127 @@
package com.loremind.application.generationcontext;
import com.loremind.domain.generationcontext.GenerationContext;
import com.loremind.domain.generationcontext.GenerationResult;
import com.loremind.domain.generationcontext.ports.AiProvider;
import com.loremind.domain.lorecontext.Lore;
import com.loremind.domain.lorecontext.LoreNode;
import com.loremind.domain.lorecontext.Page;
import com.loremind.domain.lorecontext.Template;
import com.loremind.domain.lorecontext.ports.LoreNodeRepository;
import com.loremind.domain.lorecontext.ports.LoreRepository;
import com.loremind.domain.lorecontext.ports.PageRepository;
import com.loremind.domain.lorecontext.ports.TemplateRepository;
import org.springframework.stereotype.Service;
import java.util.Map;
/**
* Use case applicatif : génère des suggestions de valeurs pour les champs
* d'une Page via l'IA.
*
* Orchestrateur (couche Application de l'hexagonal). C'est le seul endroit
* qui touche simultanément au LoreContext (chargement) et au GenerationContext
* (appel IA). Le domaine reste isolé.
*
* Décision produit : ce use case NE PERSISTE PAS les valeurs générées.
* Il renvoie des suggestions que l'utilisateur validera manuellement via
* le endpoint PUT /api/pages/{id} existant.
*/
@Service
public class GeneratePageValuesUseCase {
private final PageRepository pageRepository;
private final TemplateRepository templateRepository;
private final LoreRepository loreRepository;
private final LoreNodeRepository loreNodeRepository;
private final AiProvider aiProvider;
public GeneratePageValuesUseCase(
PageRepository pageRepository,
TemplateRepository templateRepository,
LoreRepository loreRepository,
LoreNodeRepository loreNodeRepository,
AiProvider aiProvider) {
this.pageRepository = pageRepository;
this.templateRepository = templateRepository;
this.loreRepository = loreRepository;
this.loreNodeRepository = loreNodeRepository;
this.aiProvider = aiProvider;
}
/**
* Génère les valeurs suggérées pour les champs dynamiques d'une Page.
*
* @param pageId identifiant de la Page à enrichir
* @return map fieldName -> valeur suggérée (jamais null, peut contenir des chaînes vides)
* @throws IllegalArgumentException si la Page est introuvable
* @throws IllegalStateException si le Template, le Lore ou le dossier parent sont
* incohérents (intégrité BDD cassée) ou si le Template
* n'a aucun champ à générer
*/
public Map<String, String> execute(String pageId) {
Page page = loadPage(pageId);
Template template = loadTemplate(page.getTemplateId(), pageId);
Lore lore = loadLore(page.getLoreId(), pageId);
LoreNode folder = loadFolder(page.getNodeId(), pageId);
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();
GenerationResult result = aiProvider.generatePage(context);
return result.getValues();
}
// --- Helpers de chargement (un lookup = un message d'erreur clair) ------
private Page loadPage(String pageId) {
return pageRepository.findById(pageId)
.orElseThrow(() -> new IllegalArgumentException(
"Page non trouvée avec l'ID: " + pageId));
}
private Template loadTemplate(String templateId, String pageId) {
if (templateId == null || templateId.isBlank()) {
throw new IllegalStateException(
"La page " + pageId + " n'a pas de template associé.");
}
return templateRepository.findById(templateId)
.orElseThrow(() -> new IllegalStateException(
"Template introuvable (id=" + templateId
+ ") pour la page " + pageId));
}
private Lore loadLore(String loreId, String pageId) {
return loreRepository.findById(loreId)
.orElseThrow(() -> new IllegalStateException(
"Lore introuvable (id=" + loreId
+ ") pour la page " + pageId));
}
private LoreNode loadFolder(String nodeId, String pageId) {
return loreNodeRepository.findById(nodeId)
.orElseThrow(() -> new IllegalStateException(
"Dossier parent introuvable (id=" + nodeId
+ ") pour la page " + pageId));
}
private void requireNonEmptyFields(Template template) {
// On exige au moins un champ TEXT : les champs IMAGE ne sont pas genereables
// par l'IA (pas de text-to-image pour l'instant).
if (template.textFieldNames().isEmpty()) {
throw new IllegalStateException(
"Le template '" + template.getName()
+ "' n'a aucun champ texte à générer.");
}
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,115 @@
package com.loremind.application.generationcontext;
import com.loremind.domain.generationcontext.ChatMessage;
import com.loremind.domain.generationcontext.ChatRequest;
import com.loremind.domain.generationcontext.LoreStructuralContext;
import com.loremind.domain.generationcontext.PageContext;
import com.loremind.domain.generationcontext.ports.AiChatProvider;
import com.loremind.domain.lorecontext.Page;
import com.loremind.domain.lorecontext.Template;
import com.loremind.domain.lorecontext.ports.PageRepository;
import com.loremind.domain.lorecontext.ports.TemplateRepository;
import org.springframework.stereotype.Service;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.function.Consumer;
/**
* Use case applicatif : chat conversationnel avec Structural Context d'un Lore.
*
* Orchestrateur fin — délègue la construction du LoreStructuralContext au
* {@link LoreStructuralContextBuilder} (service partagé avec
* {@link StreamChatForCampaignUseCase}), charge le PageContext si demandé,
* puis délègue au port AiChatProvider pour le streaming.
*
* Zéro persistance : la conversation est éphémère (responsabilité du frontend).
*/
@Service
public class StreamChatForLoreUseCase {
private final LoreStructuralContextBuilder loreContextBuilder;
private final PageRepository pageRepository;
private final TemplateRepository templateRepository;
private final AiChatProvider aiChatProvider;
public StreamChatForLoreUseCase(
LoreStructuralContextBuilder loreContextBuilder,
PageRepository pageRepository,
TemplateRepository templateRepository,
AiChatProvider aiChatProvider) {
this.loreContextBuilder = loreContextBuilder;
this.pageRepository = pageRepository;
this.templateRepository = templateRepository;
this.aiChatProvider = aiChatProvider;
}
/**
* Streame la réponse du LLM pour le Lore donné avec la conversation fournie.
*
* Méthode bloquante : retourne une fois le stream terminé (onComplete ou onError).
* L'appelant (controller SSE) doit l'exécuter dans un thread dédié.
*
* @param loreId obligatoire — l'univers concerné
* @param pageId optionnel (nullable) — si fourni, focalise l'IA sur cette page
* précise (template, champs, valeurs actuelles).
* @throws IllegalArgumentException si le Lore (ou la Page si pageId fourni) est introuvable
*/
public void execute(
String loreId,
String pageId,
List<ChatMessage> messages,
Consumer<String> onToken,
Runnable onComplete,
Consumer<Throwable> onError) {
LoreStructuralContext loreContext = loreContextBuilder.build(loreId);
PageContext pageContext = (pageId == null || pageId.isBlank())
? null
: buildPageContext(pageId);
ChatRequest request = ChatRequest.builder()
.messages(messages)
.loreContext(loreContext)
.pageContext(pageContext)
.build();
aiChatProvider.streamChat(request, onToken, onComplete, onError);
}
/**
* Charge la Page + son Template et construit un PageContext prêt à injecter.
* Si le template est absent (page orpheline), on renvoie un PageContext
* minimal (titre + template "?", champs vides) — l'IA reste contextualisée
* sur la page sans pouvoir proposer de champs précis.
*/
private PageContext buildPageContext(String pageId) {
Page page = pageRepository.findById(pageId)
.orElseThrow(() -> new IllegalArgumentException(
"Page non trouvée avec l'ID: " + pageId));
String templateName = "?";
List<String> templateFields = Collections.emptyList();
if (page.hasTemplate()) {
Template template = templateRepository.findById(page.getTemplateId()).orElse(null);
if (template != null) {
templateName = template.getName();
// On expose uniquement les noms des champs TEXT a l'IA pour le chat.
// Les champs IMAGE ne sont pas pertinents pour une generation textuelle.
templateFields = template.textFieldNames();
}
}
Map<String, String> values = page.getValues() != null
? page.getValues()
: Collections.emptyMap();
return PageContext.builder()
.title(page.getTitle())
.templateName(templateName)
.templateFields(templateFields)
.values(values)
.build();
}
}

View File

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

View File

@@ -0,0 +1,78 @@
package com.loremind.application.lorecontext;
import com.loremind.domain.lorecontext.LoreNode;
import com.loremind.domain.lorecontext.ports.LoreNodeRepository;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Optional;
/**
* Service d'application pour le contexte LoreNode.
* Orchestre la logique métier en utilisant le Port LoreNodeRepository.
* Fait partie de la couche Application de l'Architecture Hexagonale.
*/
@Service
public class LoreNodeService {
private final LoreNodeRepository loreNodeRepository;
public LoreNodeService(LoreNodeRepository loreNodeRepository) {
this.loreNodeRepository = loreNodeRepository;
}
/**
* Crée un LoreNode (dossier) à partir d'un "objet changes" porteur des valeurs
* souhaitées (pattern Parameter Object) : évite les signatures qui gonflent
* à chaque ajout de champ.
*/
public LoreNode createLoreNode(LoreNode changes) {
LoreNode loreNode = LoreNode.builder()
.name(changes.getName())
.icon(changes.getIcon())
.parentId(changes.getParentId())
.loreId(changes.getLoreId())
.build();
return loreNodeRepository.save(loreNode);
}
public Optional<LoreNode> getLoreNodeById(String id) {
return loreNodeRepository.findById(id);
}
public List<LoreNode> getAllLoreNodes() {
return loreNodeRepository.findAll();
}
public List<LoreNode> getLoreNodesByLoreId(String loreId) {
return loreNodeRepository.findByLoreId(loreId);
}
public List<LoreNode> getLoreNodesByParentId(String parentId) {
return loreNodeRepository.findByParentId(parentId);
}
public List<LoreNode> searchLoreNodes(String query) {
if (query == null || query.isBlank()) return List.of();
return loreNodeRepository.searchByName(query.trim());
}
public LoreNode updateLoreNode(String id, LoreNode changes) {
LoreNode existing = loreNodeRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("LoreNode non trouvé avec l'ID: " + id));
existing.setName(changes.getName());
existing.setIcon(changes.getIcon());
existing.setParentId(changes.getParentId());
// loreId volontairement immuable (un dossier ne migre pas d'un Lore à l'autre).
return loreNodeRepository.save(existing);
}
public void deleteLoreNode(String id) {
loreNodeRepository.deleteById(id);
}
public boolean loreNodeExists(String id) {
return loreNodeRepository.existsById(id);
}
}

View File

@@ -0,0 +1,93 @@
package com.loremind.application.lorecontext;
import com.loremind.domain.lorecontext.Lore;
import com.loremind.domain.lorecontext.ports.LoreNodeRepository;
import com.loremind.domain.lorecontext.ports.LoreRepository;
import com.loremind.domain.lorecontext.ports.PageRepository;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
/**
* Service d'application pour le contexte Lore.
* Orchestre la logique métier en utilisant le Port LoreRepository.
* Fait partie de la couche Application de l'Architecture Hexagonale.
*
* Note: les compteurs nodeCount/pageCount sont calculés à la volée via
* countByLoreId sur les ports LoreNode et Page, plutôt que stockés en BDD
* (la colonne existe encore mais n'est plus fiable). Source of truth = les
* tables nodes/pages elles-mêmes, jamais de désync possible.
*/
@Service
public class LoreService {
private final LoreRepository loreRepository;
private final LoreNodeRepository loreNodeRepository;
private final PageRepository pageRepository;
public LoreService(LoreRepository loreRepository,
LoreNodeRepository loreNodeRepository,
PageRepository pageRepository) {
this.loreRepository = loreRepository;
this.loreNodeRepository = loreNodeRepository;
this.pageRepository = pageRepository;
}
public Lore createLore(String name, String description) {
Lore lore = Lore.builder()
.name(name)
.description(description)
.nodeCount(0)
.pageCount(0)
.build();
return loreRepository.save(lore);
}
public Optional<Lore> getLoreById(String id) {
return loreRepository.findById(id).map(this::withCounts);
}
public List<Lore> getAllLores() {
return loreRepository.findAll().stream()
.map(this::withCounts)
.collect(Collectors.toList());
}
/**
* Enrichit un Lore avec les compteurs calculés à la volée.
* N+1 acceptable à l'échelle actuelle (quelques dizaines de Lores max).
*/
private Lore withCounts(Lore lore) {
lore.setNodeCount((int) loreNodeRepository.countByLoreId(lore.getId()));
lore.setPageCount((int) pageRepository.countByLoreId(lore.getId()));
return lore;
}
/** Recherche par nom (ILIKE). Résultats sans compteurs — pas utile pour la command palette. */
public List<Lore> searchLores(String query) {
if (query == null || query.isBlank()) return List.of();
return loreRepository.searchByName(query.trim());
}
public Lore updateLore(String id, String name, String description) {
Optional<Lore> existingLore = loreRepository.findById(id);
if (existingLore.isEmpty()) {
throw new IllegalArgumentException("Lore non trouvé avec l'ID: " + id);
}
Lore lore = existingLore.get();
lore.setName(name);
lore.setDescription(description);
return loreRepository.save(lore);
}
public void deleteLore(String id) {
loreRepository.deleteById(id);
}
public boolean loreExists(String id) {
return loreRepository.existsById(id);
}
}

View File

@@ -0,0 +1,98 @@
package com.loremind.application.lorecontext;
import com.loremind.domain.lorecontext.Page;
import com.loremind.domain.lorecontext.ports.PageRepository;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Optional;
/**
* Service d'application pour le contexte Page.
* Orchestre la logique métier via le Port PageRepository.
* Couche Application de l'Architecture Hexagonale.
*/
@Service
public class PageService {
private final PageRepository pageRepository;
public PageService(PageRepository pageRepository) {
this.pageRepository = pageRepository;
}
/** Création MVP : seuls les champs structurels sont requis. Le contenu est
* enrichi plus tard depuis l'écran d'édition. */
public Page createPage(String loreId, String nodeId, String templateId, String title) {
Page page = Page.builder()
.loreId(loreId)
.nodeId(nodeId)
.templateId(templateId)
.title(title)
.values(new HashMap<>())
.tags(new ArrayList<>())
.relatedPageIds(new ArrayList<>())
.build();
return pageRepository.save(page);
}
public Optional<Page> getPageById(String id) {
return pageRepository.findById(id);
}
public List<Page> getAllPages() {
return pageRepository.findAll();
}
public List<Page> getPagesByLoreId(String loreId) {
return pageRepository.findByLoreId(loreId);
}
public List<Page> getPagesByNodeId(String nodeId) {
return pageRepository.findByNodeId(nodeId);
}
public List<Page> searchPages(String query) {
if (query == null || query.isBlank()) return List.of();
return pageRepository.searchByTitle(query.trim());
}
/**
* Met à jour une page existante.
* Parameter Object pattern : le controller construit une Page "changes"
* avec les nouvelles valeurs, le service recharge et applique les champs modifiables.
* Les champs structurels immuables sont : id, loreId, templateId.
* Le nodeId est mutable (déplacement d'une page d'un noeud à l'autre).
*/
public Page updatePage(String id, Page changes) {
Page existing = pageRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Page non trouvée avec l'ID: " + id));
existing.setTitle(changes.getTitle());
existing.setNodeId(changes.getNodeId());
existing.setValues(changes.getValues() != null
? new HashMap<>(changes.getValues())
: new HashMap<>());
existing.setImageValues(changes.getImageValues() != null
? new HashMap<>(changes.getImageValues())
: new HashMap<>());
existing.setNotes(changes.getNotes());
existing.setTags(changes.getTags() != null
? new ArrayList<>(changes.getTags())
: new ArrayList<>());
existing.setRelatedPageIds(changes.getRelatedPageIds() != null
? new ArrayList<>(changes.getRelatedPageIds())
: new ArrayList<>());
return pageRepository.save(existing);
}
public void deletePage(String id) {
pageRepository.deleteById(id);
}
public boolean pageExists(String id) {
return pageRepository.existsById(id);
}
}

View File

@@ -0,0 +1,85 @@
package com.loremind.application.lorecontext;
import com.loremind.domain.lorecontext.Template;
import com.loremind.domain.lorecontext.TemplateField;
import com.loremind.domain.lorecontext.ports.TemplateRepository;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
/**
* Service d'application pour le contexte Template.
* Orchestre la logique métier via le Port TemplateRepository.
* Couche Application de l'Architecture Hexagonale.
*/
@Service
public class TemplateService {
private final TemplateRepository templateRepository;
public TemplateService(TemplateRepository templateRepository) {
this.templateRepository = templateRepository;
}
public Template createTemplate(String loreId,
String name,
String description,
String defaultNodeId,
List<TemplateField> fields) {
Template template = Template.builder()
.loreId(loreId)
.name(name)
.description(description)
.defaultNodeId(defaultNodeId)
.fields(fields != null ? new ArrayList<>(fields) : new ArrayList<>())
.build();
return templateRepository.save(template);
}
public Optional<Template> getTemplateById(String id) {
return templateRepository.findById(id);
}
public List<Template> getAllTemplates() {
return templateRepository.findAll();
}
public List<Template> getTemplatesByLoreId(String loreId) {
return templateRepository.findByLoreId(loreId);
}
public List<Template> searchTemplates(String query) {
if (query == null || query.isBlank()) return List.of();
return templateRepository.searchByName(query.trim());
}
/**
* Met à jour un Template existant.
* Pattern Parameter Object via l'entité Template elle-même :
* le controller construit un Template avec les champs à modifier, le service
* recharge l'existant et applique les changements.
*/
public Template updateTemplate(String id, Template changes) {
Template existing = templateRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Template non trouvé avec l'ID: " + id));
existing.setName(changes.getName());
existing.setDescription(changes.getDescription());
existing.setDefaultNodeId(changes.getDefaultNodeId());
existing.setFields(changes.getFields() != null
? new ArrayList<TemplateField>(changes.getFields())
: new ArrayList<TemplateField>());
// loreId volontairement immuable : un template ne migre pas d'un Lore à l'autre.
return templateRepository.save(existing);
}
public void deleteTemplate(String id) {
templateRepository.deleteById(id);
}
public boolean templateExists(String id) {
return templateRepository.existsById(id);
}
}

View File

@@ -0,0 +1,67 @@
package com.loremind.domain.campaigncontext;
import lombok.Builder;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
/**
* Entité de domaine représentant un Arc narratif.
* Division majeure d'une Campaign (ex: "L'arc sombre").
* Entité pure du domaine, sans dépendance technique.
*/
@Data
@Builder
public class Arc {
private String id;
private String name;
private String description; // = Synopsis dans l'UI
private String campaignId; // Référence vers la Campaign parente
private int order; // Ordre de l'arc dans la campagne
// Champs narratifs enrichis (voir docs/maquettes/campagne/détail/)
private String themes; // Thèmes principaux explorés dans cet arc
private String stakes; // Enjeux globaux pour les personnages
private String gmNotes; // Notes privées du MJ (non exportées vers FoundryVTT)
private String rewards; // Récompenses et progression
private String resolution; // Dénouement prévu
/**
* IDs des pages du Lore associées à cet arc (weak cross-context references).
* Permet au MJ de lier des PNJ, lieux ou lore items qui jouent un rôle dans cet arc.
* Ne contient que des IDs ; pas d'import du Lore Context (respect des Bounded Contexts).
* Initialisé en {@link ArrayList} vide dans le builder pour éviter les NPE.
*/
@Builder.Default
private List<String> relatedPageIds = new ArrayList<>();
/**
* IDs des images (Shared Kernel) servant d'illustrations a cet arc.
* Galerie ordonnee : la 1ere image est l'illustration principale.
*/
@Builder.Default
private List<String> illustrationImageIds = new ArrayList<>();
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
// ─────────────── Méthodes métier ───────────────
/** Ajoute un lien vers une page du Lore (idempotent : pas de doublon). */
public void linkPage(String pageId) {
if (relatedPageIds == null) relatedPageIds = new ArrayList<>();
if (!relatedPageIds.contains(pageId)) {
relatedPageIds.add(pageId);
this.updatedAt = LocalDateTime.now();
}
}
/** Retire un lien vers une page du Lore (sans erreur si absent). */
public void unlinkPage(String pageId) {
if (relatedPageIds != null && relatedPageIds.remove(pageId)) {
this.updatedAt = LocalDateTime.now();
}
}
}

View File

@@ -0,0 +1,59 @@
package com.loremind.domain.campaigncontext;
import lombok.Builder;
import lombok.Data;
import java.time.LocalDateTime;
/**
* Entité de domaine représentant une Campaign.
* Conteneur global pour organiser la narration d'une campagne.
* Entité pure du domaine, sans dépendance technique.
*/
@Data
@Builder
public class Campaign {
private String id;
private String name;
private String description;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
private int arcsCount;
/**
* Référence faible (weak reference) vers un Lore.
* Nullable : une campagne peut exister sans univers associé (one-shot, test, pitch libre).
* Ce n'est qu'un ID : le Campaign Context ne dépend PAS du Lore Context
* (respect des Bounded Contexts en DDD).
*/
private String loreId;
/** Associe cette campagne à un Lore existant. */
public void linkToLore(String loreId) {
this.loreId = loreId;
this.updatedAt = LocalDateTime.now();
}
/** Retire l'association à un Lore (la campagne redevient "universe-agnostic"). */
public void unlinkFromLore() {
this.loreId = null;
this.updatedAt = LocalDateTime.now();
}
public boolean isLinkedToLore() {
return this.loreId != null && !this.loreId.isBlank();
}
// Méthode métier pour gérer le nombre d'arcs
public void incrementArcsCount() {
this.arcsCount++;
this.updatedAt = LocalDateTime.now();
}
public void decrementArcsCount() {
if (this.arcsCount > 0) {
this.arcsCount--;
this.updatedAt = LocalDateTime.now();
}
}
}

View File

@@ -0,0 +1,60 @@
package com.loremind.domain.campaigncontext;
import lombok.Builder;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
/**
* Entité de domaine représentant un Chapter.
* Subdivision d'un Arc (ex: "Chapitre 1: Le début").
* Entité pure du domaine, sans dépendance technique.
*/
@Data
@Builder
public class Chapter {
private String id;
private String name;
private String description; // = Synopsis du chapitre dans l'UI
private String arcId; // Référence vers l'Arc parent
private int order; // Ordre du chapitre dans l'arc
// Champs narratifs enrichis (voir docs/maquettes/campagne/détail/)
private String gmNotes; // Notes privées du MJ (non exportées vers FoundryVTT)
private String playerObjectives; // Objectifs des joueurs dans ce chapitre
private String narrativeStakes; // Enjeux narratifs dramatiques
/**
* IDs des pages du Lore associées à ce chapitre (weak cross-context references).
* Permet au MJ de lier des PNJ / lieux / éléments du Lore qui apparaissent dans ce chapitre.
*/
@Builder.Default
private List<String> relatedPageIds = new ArrayList<>();
/**
* IDs des images (Shared Kernel) illustrant ce chapitre.
*/
@Builder.Default
private List<String> illustrationImageIds = new ArrayList<>();
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
// ─────────────── Méthodes métier ───────────────
public void linkPage(String pageId) {
if (relatedPageIds == null) relatedPageIds = new ArrayList<>();
if (!relatedPageIds.contains(pageId)) {
relatedPageIds.add(pageId);
this.updatedAt = LocalDateTime.now();
}
}
public void unlinkPage(String pageId) {
if (relatedPageIds != null && relatedPageIds.remove(pageId)) {
this.updatedAt = LocalDateTime.now();
}
}
}

View File

@@ -0,0 +1,99 @@
package com.loremind.domain.campaigncontext;
import lombok.Builder;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
/**
* Entité de domaine représentant une Scene.
* Unité de jeu la plus fine, subdivision d'un Chapter (ex: "Scène 1: L'auberge").
* Entité pure du domaine, sans dépendance technique.
*/
@Data
@Builder
public class Scene {
private String id;
private String name;
private String description; // = Description courte dans l'UI
private String chapterId; // Référence vers le Chapter parent
private int order; // Ordre de la scène dans le chapitre
// === Contexte et ambiance ===
private String location; // Lieu de la scène (ex: Taverne du Dragon d'Or)
private String timing; // Moment (ex: Soir, à la tombée de la nuit)
private String atmosphere; // Ambiance générale (sons, odeurs, émotions...)
// === Narration pour les joueurs ===
private String playerNarration; // Texte lu directement aux joueurs
// === Notes et secrets du MJ (privé) ===
private String gmSecretNotes; // Informations cachées, non visibles par les joueurs
// === Choix et conséquences ===
private String choicesConsequences; // Options offertes aux joueurs et leurs conséquences
// === Combat ou rencontre ===
private String combatDifficulty; // Difficulté estimée
private String enemies; // Liste des ennemis et créatures
/**
* IDs des pages du Lore associées à cette scène (weak cross-context references).
* Très utile pour la préparation : épingler un lieu, un PNJ, une créature à une scène.
*/
@Builder.Default
private List<String> relatedPageIds = new ArrayList<>();
/**
* IDs des images (Shared Kernel) illustrant cette scene.
* Utile pour carte du lieu, portraits des PNJ principaux, ambiance.
*/
@Builder.Default
private List<String> illustrationImageIds = new ArrayList<>();
/**
* Sorties narratives possibles depuis cette scène (graphe intra-chapitre).
* Chaque branche décrit un choix des joueurs et la scène de destination.
* Liste vide = scène "feuille" (fin de chapitre ou scène linéaire).
*/
@Builder.Default
private List<SceneBranch> branches = new ArrayList<>();
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
// ─────────────── Méthodes métier ───────────────
public void linkPage(String pageId) {
if (relatedPageIds == null) relatedPageIds = new ArrayList<>();
if (!relatedPageIds.contains(pageId)) {
relatedPageIds.add(pageId);
this.updatedAt = LocalDateTime.now();
}
}
public void unlinkPage(String pageId) {
if (relatedPageIds != null && relatedPageIds.remove(pageId)) {
this.updatedAt = LocalDateTime.now();
}
}
public void addBranch(SceneBranch branch) {
if (branch == null) return;
if (branches == null) branches = new ArrayList<>();
// Interdit l'auto-référence (scène qui pointe sur elle-même)
if (this.id != null && this.id.equals(branch.getTargetSceneId())) {
throw new IllegalArgumentException("Une scène ne peut pas se brancher sur elle-même");
}
branches.add(branch);
this.updatedAt = LocalDateTime.now();
}
public void removeBranchTo(String targetSceneId) {
if (branches == null || targetSceneId == null) return;
boolean removed = branches.removeIf(b -> targetSceneId.equals(b.getTargetSceneId()));
if (removed) this.updatedAt = LocalDateTime.now();
}
}

View File

@@ -0,0 +1,31 @@
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.
*
* Règle métier : targetSceneId DOIT pointer vers une Scene du MÊME Chapter
* (validation portée par SceneService).
*/
@Value
@Builder
@Jacksonized
public class SceneBranch {
/** 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;
}

View File

@@ -0,0 +1,24 @@
package com.loremind.domain.campaigncontext.ports;
import com.loremind.domain.campaigncontext.Arc;
import java.util.List;
import java.util.Optional;
/**
* Port de sortie pour la persistance des Arcs.
* Interface définie dans le domaine, implémentée par l'infrastructure.
*/
public interface ArcRepository {
Arc save(Arc arc);
Optional<Arc> findById(String id);
List<Arc> findByCampaignId(String campaignId);
List<Arc> findAll();
void deleteById(String id);
boolean existsById(String id);
}

View File

@@ -0,0 +1,24 @@
package com.loremind.domain.campaigncontext.ports;
import com.loremind.domain.campaigncontext.Campaign;
import java.util.List;
import java.util.Optional;
/**
* Port de sortie pour la persistance des Campaigns.
* Interface définie dans le domaine, implémentée par l'infrastructure.
*/
public interface CampaignRepository {
Campaign save(Campaign campaign);
Optional<Campaign> findById(String id);
List<Campaign> findAll();
void deleteById(String id);
boolean existsById(String id);
List<Campaign> searchByName(String query);
}

View File

@@ -0,0 +1,24 @@
package com.loremind.domain.campaigncontext.ports;
import com.loremind.domain.campaigncontext.Chapter;
import java.util.List;
import java.util.Optional;
/**
* Port de sortie pour la persistance des Chapters.
* Interface définie dans le domaine, implémentée par l'infrastructure.
*/
public interface ChapterRepository {
Chapter save(Chapter chapter);
Optional<Chapter> findById(String id);
List<Chapter> findByArcId(String arcId);
List<Chapter> findAll();
void deleteById(String id);
boolean existsById(String id);
}

View File

@@ -0,0 +1,24 @@
package com.loremind.domain.campaigncontext.ports;
import com.loremind.domain.campaigncontext.Scene;
import java.util.List;
import java.util.Optional;
/**
* Port de sortie pour la persistance des Scenes.
* Interface définie dans le domaine, implémentée par l'infrastructure.
*/
public interface SceneRepository {
Scene save(Scene scene);
Optional<Scene> findById(String id);
List<Scene> findByChapterId(String chapterId);
List<Scene> findAll();
void deleteById(String id);
boolean existsById(String id);
}

View File

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

View File

@@ -0,0 +1,16 @@
package com.loremind.domain.generationcontext;
import lombok.Value;
/**
* Un message d'une conversation avec le LLM.
*
* Rôles acceptés : "user", "assistant", "system".
* Object de valeur immuable — cohérent avec le reste du domaine.
*/
@Value
public class ChatMessage {
String role;
String content;
}

View File

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

View File

@@ -0,0 +1,28 @@
package com.loremind.domain.generationcontext;
import lombok.Builder;
import lombok.Value;
import java.util.List;
/**
* Object de valeur (immuable) représentant une demande de génération IA
* 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.
*/
@Value
@Builder
public class GenerationContext {
String loreName;
String loreDescription;
String folderName; // Nom du LoreNode parent (ex: "PNJ", "Lieux")
String templateName;
List<String> templateFields; // Champs à générer (clés attendues dans la réponse)
String pageTitle;
}

View File

@@ -0,0 +1,18 @@
package com.loremind.domain.generationcontext;
import lombok.Value;
import java.util.Map;
/**
* Résultat d'une génération IA : une map fieldName -> valeur générée.
*
* Équivalent Java du PageGenerationResult Python.
* Immuable : une fois reçu, pas de modification (l'UI pourra faire du merge,
* mais pas en mutant cet objet).
*/
@Value
public class GenerationResult {
Map<String, String> values;
}

View File

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

View File

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

View File

@@ -0,0 +1,27 @@
package com.loremind.domain.generationcontext;
import lombok.Builder;
import lombok.Value;
import java.util.List;
import java.util.Map;
/**
* Contexte d'une page spécifique en cours d'édition.
*
* Complément du LoreStructuralContext : l'un donne la carte générale du
* Lore, l'autre zoome sur la page précise en cours de discussion. Permet
* à 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.
*/
@Value
@Builder
public class PageContext {
String title;
String templateName;
List<String> templateFields;
Map<String, String> values;
}

View File

@@ -0,0 +1,41 @@
package com.loremind.domain.generationcontext.ports;
import com.loremind.domain.generationcontext.ChatRequest;
import java.util.function.Consumer;
/**
* Port de sortie pour le chat streamé avec un LLM.
*
* Distinct de AiProvider (one-shot) par Interface Segregation Principle :
* le streaming est une capacité séparée avec un contrat propre. Un même
* adapter concret peut satisfaire les deux ports s'il le souhaite.
*
* API par callbacks (plutôt que Flux/Stream) pour garder le domaine libre
* de toute dépendance à Reactor. Les couches supérieures (controller SSE)
* s'adaptent naturellement à ce style.
*/
public interface AiChatProvider {
/**
* Streame la réponse du LLM en invoquant les callbacks au fil de l'eau.
*
* Cette méthode est bloquante : elle ne rend la main qu'après la fin
* du stream (appel à onComplete ou onError). L'appelant est responsable
* de l'exécuter dans un thread adapté (ex: thread dédié à la requête
* HTTP côté controller SSE).
*
* @param request messages + contexte Lore
* @param onToken invoqué à chaque token reçu du LLM (peut être appelé
* de nombreuses fois)
* @param onComplete invoqué une fois le stream terminé avec succès
* @param onError invoqué en cas d'erreur (Brain injoignable, timeout,
* réponse invalide). Exclusif avec onComplete.
*/
void streamChat(
ChatRequest request,
Consumer<String> onToken,
Runnable onComplete,
Consumer<Throwable> onError
);
}

View File

@@ -0,0 +1,25 @@
package com.loremind.domain.generationcontext.ports;
import com.loremind.domain.generationcontext.GenerationContext;
import com.loremind.domain.generationcontext.GenerationResult;
/**
* Port de sortie pour la génération IA.
*
* Le domaine ne connaît pas l'implémentation (HTTP vers Brain Python,
* appel direct à OpenAI, mock en test, etc.). Il manipule uniquement
* cette interface.
*
* C'est l'équivalent Java du Protocol LLMProvider côté Python —
* même pattern hexagonal des deux côtés de la frontière réseau.
*/
public interface AiProvider {
/**
* Génère les valeurs des champs d'une Page à partir du contexte fourni.
*
* @throws AiProviderException si le fournisseur IA est indisponible,
* renvoie une réponse invalide ou dépasse le timeout.
*/
GenerationResult generatePage(GenerationContext context) throws AiProviderException;
}

View File

@@ -0,0 +1,22 @@
package com.loremind.domain.generationcontext.ports;
/**
* Exception de domaine signalant un échec du fournisseur IA.
*
* Équivalent Java de LLMProviderError (Python). Hérite de RuntimeException
* pour rester cohérent avec le reste du code (pas d'exceptions checked
* qui polluent les signatures de méthodes).
*
* L'Adapter (BrainAiClient) traduira toute erreur technique (timeout,
* 5xx, JSON invalide) en AiProviderException avant de la propager.
*/
public class AiProviderException extends RuntimeException {
public AiProviderException(String message) {
super(message);
}
public AiProviderException(String message, Throwable cause) {
super(message, cause);
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,48 @@
package com.loremind.domain.lorecontext;
import lombok.Builder;
import lombok.Data;
import java.time.LocalDateTime;
/**
* Entité de domaine représentant un Univers de jeu (Lore).
* Conteneur global pour organiser la connaissance d'un monde.
* C'est une entité pure du domaine, sans dépendance technique (pas de JPA).
*/
@Data
@Builder
public class Lore {
private String id;
private String name;
private String description;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
private int nodeCount;
private int pageCount;
// Méthodes métier pour gérer les métriques
public void incrementNodeCount() {
this.nodeCount++;
this.updatedAt = LocalDateTime.now();
}
public void decrementNodeCount() {
if (this.nodeCount > 0) {
this.nodeCount--;
this.updatedAt = LocalDateTime.now();
}
}
public void incrementPageCount() {
this.pageCount++;
this.updatedAt = LocalDateTime.now();
}
public void decrementPageCount() {
if (this.pageCount > 0) {
this.pageCount--;
this.updatedAt = LocalDateTime.now();
}
}
}

View File

@@ -0,0 +1,42 @@
package com.loremind.domain.lorecontext;
import lombok.Builder;
import lombok.Data;
import java.time.LocalDateTime;
/**
* Entité de domaine représentant un dossier dans l'arborescence d'un Lore.
* Un dossier organise des Pages et peut contenir des sous-dossiers.
* Structure hiérarchique via parentId (auto-référence).
* Entité pure du domaine, sans dépendance technique.
*
* Note : le nom interne reste "LoreNode" pour des raisons historiques et
* techniques (compatibilité BDD, couplage avec le code existant). L'UI expose
* le concept sous le terme "dossier".
*/
@Data
@Builder
public class LoreNode {
private String id;
private String name;
/** Clé de l'icône lucide choisie par l'utilisateur (ex: "users", "map-pin"). */
private String icon;
private String parentId; // Auto-référence pour l'arborescence
private String loreId; // Référence vers le Lore parent
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
// Méthode métier pour vérifier si c'est un nœud racine
public boolean isRoot() {
return parentId == null || parentId.isEmpty();
}
// Méthode métier pour vérifier si c'est un nœud feuille (sans enfants)
// Note: La vérification réelle nécessite d'accéder aux enfants,
// donc cette méthode est indicative et doit être complétée par le repository
public boolean isLeaf() {
// Cette logique sera implémentée au niveau du service/repository
return false;
}
}

View File

@@ -0,0 +1,105 @@
package com.loremind.domain.lorecontext;
import lombok.Builder;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* Entité de domaine représentant une page de contenu rattachée à un LoreNode.
*
* Une page :
* - appartient à un Lore (loreId — dénormalisé pour queries rapides)
* - est rangée sous un LoreNode (nodeId)
* - est générée depuis un Template (templateId) dont elle suit les `fields`
* - stocke les valeurs de ses champs dynamiques dans `values` (fieldName → value)
* - porte des métadonnées éditoriales : notes privées MJ, tags, liens inter-pages.
*
* Entité pure du domaine : aucune dépendance technique.
*/
@Data
@Builder
public class Page {
private String id;
private String loreId;
private String nodeId;
private String templateId;
private String title;
/** Valeurs des champs dynamiques TEXT définis par le Template. */
private Map<String, String> values;
/**
* Valeurs des champs dynamiques IMAGE : pour chaque nom de champ IMAGE du
* template, la liste ordonnee des IDs d'images uploadees (Shared Kernel images).
* Structure separee de `values` pour garder des types homogenes par map.
*/
private Map<String, List<String>> imageValues;
/** Notes privées du MJ (non exportées vers FoundryVTT). */
private String notes;
/** Étiquettes libres pour regroupement/recherche. */
private List<String> tags;
/** IDs d'autres Pages liées (mêmes Lore ou cross-lore). */
private List<String> relatedPageIds;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
// --- Méthodes métier --------------------------------------------------
/** Met à jour la valeur d'un champ dynamique (création de la map si absente). */
public void setFieldValue(String fieldName, String value) {
if (values == null) {
values = new HashMap<>();
}
values.put(fieldName, value);
this.updatedAt = LocalDateTime.now();
}
public String getFieldValue(String fieldName) {
return values == null ? null : values.get(fieldName);
}
/** Remplace la liste d'IDs d'images pour un champ IMAGE donne. */
public void setImageFieldValue(String fieldName, List<String> imageIds) {
if (imageValues == null) {
imageValues = new HashMap<>();
}
imageValues.put(fieldName, imageIds != null ? new ArrayList<>(imageIds) : new ArrayList<>());
this.updatedAt = LocalDateTime.now();
}
/** Liste d'IDs d'images pour un champ IMAGE (ou liste vide si absent). */
public List<String> getImageFieldValue(String fieldName) {
if (imageValues == null) return new ArrayList<>();
List<String> ids = imageValues.get(fieldName);
return ids != null ? ids : new ArrayList<>();
}
public void addTag(String tag) {
if (tag == null || tag.isBlank()) return;
if (tags == null) tags = new ArrayList<>();
if (!tags.contains(tag)) {
tags.add(tag);
this.updatedAt = LocalDateTime.now();
}
}
public void removeTag(String tag) {
if (tags != null && tags.remove(tag)) {
this.updatedAt = LocalDateTime.now();
}
}
public boolean hasTemplate() {
return templateId != null && !templateId.isBlank();
}
}

View File

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

View File

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

View File

@@ -0,0 +1,30 @@
package com.loremind.domain.lorecontext.ports;
import com.loremind.domain.lorecontext.LoreNode;
import java.util.List;
import java.util.Optional;
/**
* Port de sortie pour la persistance des LoreNodes.
* Interface définie dans le domaine, implémentée par l'infrastructure.
*/
public interface LoreNodeRepository {
LoreNode save(LoreNode loreNode);
Optional<LoreNode> findById(String id);
List<LoreNode> findByLoreId(String loreId);
List<LoreNode> findByParentId(String parentId);
List<LoreNode> findAll();
void deleteById(String id);
boolean existsById(String id);
long countByLoreId(String loreId);
List<LoreNode> searchByName(String query);
}

View File

@@ -0,0 +1,25 @@
package com.loremind.domain.lorecontext.ports;
import com.loremind.domain.lorecontext.Lore;
import java.util.List;
import java.util.Optional;
/**
* Port de sortie pour la persistance des Lores.
* Interface définie dans le domaine, implémentée par l'infrastructure.
* C'est un Port de sortie (Output Port) selon l'Architecture Hexagonale.
*/
public interface LoreRepository {
Lore save(Lore lore);
Optional<Lore> findById(String id);
List<Lore> findAll();
void deleteById(String id);
boolean existsById(String id);
List<Lore> searchByName(String query);
}

View File

@@ -0,0 +1,32 @@
package com.loremind.domain.lorecontext.ports;
import com.loremind.domain.lorecontext.Page;
import java.util.List;
import java.util.Optional;
/**
* Port de sortie pour la persistance des Pages.
* Interface définie dans le domaine, implémentée par l'infrastructure.
*/
public interface PageRepository {
Page save(Page page);
Optional<Page> findById(String id);
List<Page> findByLoreId(String loreId);
List<Page> findByNodeId(String nodeId);
List<Page> findByTemplateId(String templateId);
List<Page> findAll();
void deleteById(String id);
boolean existsById(String id);
long countByLoreId(String loreId);
List<Page> searchByTitle(String query);
}

View File

@@ -0,0 +1,27 @@
package com.loremind.domain.lorecontext.ports;
import com.loremind.domain.lorecontext.Template;
import java.util.List;
import java.util.Optional;
/**
* Port de sortie pour la persistance des Templates.
* Interface définie dans le domaine, implémentée par l'infrastructure.
*/
public interface TemplateRepository {
Template save(Template template);
Optional<Template> findById(String id);
List<Template> findAll();
/** Tous les templates rattachés à un Lore donné (pour le panneau sidebar). */
List<Template> findByLoreId(String loreId);
void deleteById(String id);
boolean existsById(String id);
List<Template> searchByName(String query);
}

View File

@@ -0,0 +1,284 @@
package com.loremind.infrastructure.ai;
import com.loremind.domain.generationcontext.CampaignStructuralContext;
import com.loremind.domain.generationcontext.CampaignStructuralContext.ArcSummary;
import com.loremind.domain.generationcontext.CampaignStructuralContext.BranchHint;
import com.loremind.domain.generationcontext.CampaignStructuralContext.ChapterSummary;
import com.loremind.domain.generationcontext.CampaignStructuralContext.SceneSummary;
import com.loremind.domain.generationcontext.ChatMessage;
import com.loremind.domain.generationcontext.ChatRequest;
import com.loremind.domain.generationcontext.LoreStructuralContext;
import com.loremind.domain.generationcontext.LoreStructuralContext.PageSummary;
import com.loremind.domain.generationcontext.NarrativeEntityContext;
import com.loremind.domain.generationcontext.PageContext;
import com.loremind.domain.generationcontext.ports.AiChatProvider;
import com.loremind.domain.generationcontext.ports.AiProviderException;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.MediaType;
import org.springframework.http.codec.ServerSentEvent;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Flux;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Consumer;
import java.util.stream.Collectors;
/**
* Adapter de sortie (Architecture Hexagonale) : implémente AiChatProvider
* en appelant le Brain Python via WebClient + SSE (Server-Sent Events).
*
* Responsabilités :
* 1. Traduire ChatRequest (domaine) -> JSON attendu par /chat/stream.
* Sérialise lore_context, page_context, campaign_context et
* narrative_entity de façon conditionnelle selon le scénario d'appel
* (chat Lore / chat Lore focalisé page / chat Campagne / chat Campagne
* focalisé arc-chapter-scene).
* 2. Consommer le flux SSE token par token.
* 3. Invoquer onToken / onComplete / onError au bon moment.
* 4. Traduire toute erreur technique en AiProviderException.
*
* Le domaine ne voit JAMAIS WebClient, Flux, ni la moindre URL.
*/
@Component
public class BrainAiChatClient implements AiChatProvider {
private static final String CHAT_STREAM_PATH = "/chat/stream";
private static final ParameterizedTypeReference<ServerSentEvent<String>> SSE_STRING_TYPE =
new ParameterizedTypeReference<>() {};
private final WebClient webClient;
public BrainAiChatClient(
WebClient.Builder builder,
@Value("${brain.base-url}") String baseUrl) {
this.webClient = builder.baseUrl(baseUrl).build();
}
@Override
public void streamChat(
ChatRequest request,
Consumer<String> onToken,
Runnable onComplete,
Consumer<Throwable> onError) {
Map<String, Object> payload = toPayload(request);
Flux<ServerSentEvent<String>> flux = webClient.post()
.uri(CHAT_STREAM_PATH)
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.TEXT_EVENT_STREAM)
.bodyValue(payload)
.retrieve()
.bodyToFlux(SSE_STRING_TYPE);
try {
// blockLast() : transforme le flux réactif en appel bloquant conforme
// au contrat synchrone du port. L'appelant choisit le thread.
flux
.timeout(Duration.ofSeconds(120))
.doOnNext(sse -> handleEvent(sse, onToken, onError))
.blockLast();
onComplete.run();
} catch (Exception e) {
onError.accept(new AiProviderException(
"Erreur lors du streaming chat depuis le Brain.", e));
}
}
/** Dispatch selon le type d'événement SSE (data par défaut, done, error). */
private void handleEvent(
ServerSentEvent<String> sse,
Consumer<String> onToken,
Consumer<Throwable> onError) {
String event = sse.event(); // null si pas d'event: xxx -> c'est un data par défaut
String data = sse.data();
if ("error".equals(event)) {
onError.accept(new AiProviderException(
"Le Brain a signalé une erreur : " + data));
return;
}
if ("done".equals(event)) {
return; // la fin est gérée par blockLast + onComplete
}
// Défaut : événement data avec JSON {"token":"..."}.
String token = extractToken(data);
if (token != null && !token.isEmpty()) {
onToken.accept(token);
}
}
/**
* Parse minimaliste du JSON {"token":"..."} sans pull Jackson ici.
* Si le format se complexifie, on remplacera par un DTO Jackson.
*/
private String extractToken(String json) {
if (json == null) return null;
int idx = json.indexOf("\"token\"");
if (idx < 0) return null;
int colon = json.indexOf(':', idx);
int firstQuote = json.indexOf('"', colon + 1);
int lastQuote = json.lastIndexOf('"');
if (firstQuote < 0 || lastQuote <= firstQuote) return null;
return json.substring(firstQuote + 1, lastQuote)
.replace("\\n", "\n")
.replace("\\\"", "\"")
.replace("\\\\", "\\");
}
// --- Construction du payload JSON vers le Brain -------------------------
/**
* Construit le payload JSON. Chaque contexte optionnel est omis s'il est
* null, pour s'aligner sur le schéma Pydantic côté Brain (champs
* Optional qui restent absents du dict transmis au LLM).
*/
private Map<String, Object> toPayload(ChatRequest request) {
Map<String, Object> root = new LinkedHashMap<>();
root.put("messages", request.getMessages().stream()
.map(this::messageToMap)
.collect(Collectors.toList()));
if (request.getLoreContext() != null) {
root.put("lore_context", loreContextToMap(request.getLoreContext()));
}
if (request.getPageContext() != null) {
root.put("page_context", pageContextToMap(request.getPageContext()));
}
if (request.getCampaignContext() != null) {
root.put("campaign_context", campaignContextToMap(request.getCampaignContext()));
}
if (request.getNarrativeEntity() != null) {
root.put("narrative_entity", narrativeEntityToMap(request.getNarrativeEntity()));
}
return root;
}
private Map<String, Object> messageToMap(ChatMessage m) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("role", m.getRole());
map.put("content", m.getContent());
return map;
}
private Map<String, Object> loreContextToMap(LoreStructuralContext ctx) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("lore_name", ctx.getLoreName());
map.put("lore_description", ctx.getLoreDescription());
Map<String, Object> foldersMap = new LinkedHashMap<>();
for (Map.Entry<String, List<PageSummary>> e : ctx.getFolders().entrySet()) {
foldersMap.put(e.getKey(), e.getValue().stream()
.map(this::pageSummaryToMap)
.collect(Collectors.toList()));
}
map.put("folders", foldersMap);
map.put("tags", ctx.getTags());
return map;
}
private Map<String, Object> pageSummaryToMap(PageSummary ps) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("title", ps.getTitle());
map.put("template_name", ps.getTemplateName());
// values/tags/related_page_titles ne sont sérialisés que s'ils contiennent
// de l'info — payload réseau plus léger quand la page est vierge.
if (ps.getValues() != null && !ps.getValues().isEmpty()) {
map.put("values", ps.getValues());
}
if (ps.getTags() != null && !ps.getTags().isEmpty()) {
map.put("tags", ps.getTags());
}
if (ps.getRelatedPageTitles() != null && !ps.getRelatedPageTitles().isEmpty()) {
map.put("related_page_titles", ps.getRelatedPageTitles());
}
return map;
}
private Map<String, Object> pageContextToMap(PageContext pc) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("title", pc.getTitle());
map.put("template_name", pc.getTemplateName());
map.put("template_fields", pc.getTemplateFields());
map.put("values", pc.getValues());
return map;
}
private Map<String, Object> campaignContextToMap(CampaignStructuralContext ctx) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("campaign_name", ctx.getCampaignName());
map.put("campaign_description", ctx.getCampaignDescription());
map.put("arcs", ctx.getArcs().stream()
.map(this::arcSummaryToMap)
.collect(Collectors.toList()));
return map;
}
private Map<String, Object> arcSummaryToMap(ArcSummary a) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("name", a.getName());
map.put("description", a.getDescription());
// Envoye au Python pour enrichir le prompt ("N illustrations attachees").
// Serialise uniquement si > 0 pour economiser le payload sur les entites sans images.
if (a.getIllustrationCount() > 0) {
map.put("illustration_count", a.getIllustrationCount());
}
map.put("chapters", a.getChapters().stream()
.map(this::chapterSummaryToMap)
.collect(Collectors.toList()));
return map;
}
private Map<String, Object> chapterSummaryToMap(ChapterSummary c) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("name", c.getName());
map.put("description", c.getDescription());
if (c.getIllustrationCount() > 0) {
map.put("illustration_count", c.getIllustrationCount());
}
map.put("scenes", c.getScenes().stream()
.map(this::sceneSummaryToMap)
.collect(Collectors.toList()));
return map;
}
private Map<String, Object> sceneSummaryToMap(SceneSummary s) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("name", s.getName());
map.put("description", s.getDescription());
if (s.getIllustrationCount() > 0) {
map.put("illustration_count", s.getIllustrationCount());
}
// Branches narratives : serialise uniquement si presentes, pour garder
// un payload leger sur les scenes lineaires classiques.
if (s.getBranches() != null && !s.getBranches().isEmpty()) {
map.put("branches", s.getBranches().stream()
.map(this::branchHintToMap)
.collect(Collectors.toList()));
}
return map;
}
private Map<String, Object> branchHintToMap(BranchHint b) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("label", b.getLabel());
map.put("target_scene_name", b.getTargetSceneName());
if (b.getCondition() != null && !b.getCondition().isBlank()) {
map.put("condition", b.getCondition());
}
return map;
}
private Map<String, Object> narrativeEntityToMap(NarrativeEntityContext ne) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("entity_type", ne.getEntityType());
map.put("title", ne.getTitle());
map.put("fields", ne.getFields());
return map;
}
}

View File

@@ -0,0 +1,104 @@
package com.loremind.infrastructure.ai;
import com.loremind.domain.generationcontext.GenerationContext;
import com.loremind.domain.generationcontext.GenerationResult;
import com.loremind.domain.generationcontext.ports.AiProvider;
import com.loremind.domain.generationcontext.ports.AiProviderException;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.client.ResourceAccessException;
import org.springframework.web.client.RestClientResponseException;
import org.springframework.web.client.RestTemplate;
import java.util.Map;
/**
* Adapter de sortie : implémente le port AiProvider en appelant
* le Brain Python via HTTP (RestTemplate).
*
* Responsabilités exclusives de cette classe :
* 1. Traduire GenerationContext (domaine) -> BrainGeneratePageRequest (wire).
* 2. Exécuter l'appel HTTP POST /generate-page.
* 3. Traduire BrainGeneratePageResponse (wire) -> GenerationResult (domaine).
* 4. Traduire toute erreur technique en AiProviderException (exception de domaine).
*
* Le domaine ne voit JAMAIS RestTemplate, Jackson, ni la moindre URL.
*/
@Component
public class BrainAiClient implements AiProvider {
private static final String GENERATE_PAGE_PATH = "/generate-page";
private final RestTemplate restTemplate;
private final String baseUrl;
public BrainAiClient(
RestTemplate restTemplate,
@Value("${brain.base-url}") String baseUrl) {
this.restTemplate = restTemplate;
this.baseUrl = baseUrl;
}
@Override
public GenerationResult generatePage(GenerationContext context) {
BrainGeneratePageRequest request = toBrainRequest(context);
BrainGeneratePageResponse response = callBrain(request);
return toDomainResult(response);
}
// --- Traduction domaine -> wire -----------------------------------------
private BrainGeneratePageRequest toBrainRequest(GenerationContext context) {
return new BrainGeneratePageRequest(
context.getLoreName(),
context.getLoreDescription(),
context.getFolderName(),
context.getTemplateName(),
context.getTemplateFields(),
context.getPageTitle()
);
}
// --- Appel HTTP + traduction d'erreurs ----------------------------------
private BrainGeneratePageResponse callBrain(BrainGeneratePageRequest request) {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<BrainGeneratePageRequest> entity = new HttpEntity<>(request, headers);
try {
BrainGeneratePageResponse response = restTemplate.postForObject(
baseUrl + GENERATE_PAGE_PATH,
entity,
BrainGeneratePageResponse.class
);
if (response == null || response.getValues() == null) {
throw new AiProviderException("Le Brain a renvoyé une réponse vide.");
}
return response;
} catch (ResourceAccessException e) {
// Timeout ou connexion impossible (Brain down)
throw new AiProviderException(
"Le Brain est injoignable (timeout ou service arrêté).", e);
} catch (RestClientResponseException e) {
// Code HTTP 4xx/5xx renvoyé par le Brain
throw new AiProviderException(
"Le Brain a répondu avec une erreur HTTP " + e.getStatusCode().value(), e);
} catch (AiProviderException e) {
throw e; // déjà traduite, ne pas ré-envelopper
} catch (Exception e) {
// Filet de sécurité (JSON invalide, etc.)
throw new AiProviderException(
"Erreur inattendue lors de l'appel au Brain.", e);
}
}
// --- Traduction wire -> domaine -----------------------------------------
private GenerationResult toDomainResult(BrainGeneratePageResponse response) {
return new GenerationResult(Map.copyOf(response.getValues()));
}
}

View File

@@ -0,0 +1,37 @@
package com.loremind.infrastructure.ai;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor;
import lombok.Value;
import java.util.List;
/**
* DTO interne de l'Adapter : format JSON envoyé au Brain Python.
* Package-private : n'existe que pour la couche infrastructure.
*
* Le contrat HTTP côté Python utilise snake_case — on le matche ici
* pour éviter de configurer Jackson globalement (impact sur le reste du projet).
*/
@Value
@AllArgsConstructor
class BrainGeneratePageRequest {
@JsonProperty("lore_name")
String loreName;
@JsonProperty("lore_description")
String loreDescription;
@JsonProperty("folder_name")
String folderName;
@JsonProperty("template_name")
String templateName;
@JsonProperty("template_fields")
List<String> templateFields;
@JsonProperty("page_title")
String pageTitle;
}

View File

@@ -0,0 +1,20 @@
package com.loremind.infrastructure.ai;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.Map;
/**
* DTO interne de l'Adapter : format JSON reçu du Brain Python.
*
* @Data + @NoArgsConstructor : nécessaire à Jackson pour la désérialisation.
*/
@Data
@NoArgsConstructor
class BrainGeneratePageResponse {
@JsonProperty("values")
private Map<String, String> values;
}

View File

@@ -0,0 +1,29 @@
package com.loremind.infrastructure.ai;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestTemplate;
import java.time.Duration;
/**
* Configuration Spring fournissant un RestTemplate avec timeout adapté
* aux appels vers le Brain (LLM local parfois lent).
*
* Ce bean est réutilisable par tout futur Adapter HTTP du projet.
*/
@Configuration
public class RestTemplateConfig {
@Bean
public RestTemplate brainRestTemplate(
RestTemplateBuilder builder,
@Value("${brain.timeout-seconds}") long timeoutSeconds) {
return builder
.setConnectTimeout(Duration.ofSeconds(10))
.setReadTimeout(Duration.ofSeconds(timeoutSeconds))
.build();
}
}

View File

@@ -0,0 +1,44 @@
package com.loremind.infrastructure.persistence.converter;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.persistence.AttributeConverter;
import jakarta.persistence.Converter;
import java.util.Map;
/**
* Converter JPA pour convertir Map<String, Object> en String (JSON).
* Compatible avec PostgreSQL (JSONB peut stocker du JSON dans TEXT).
* Utilisé pour le champ structure de Template, mais peut servir pour tout champ JSON.
* C'est un converter générique réutilisable.
*/
@Converter(autoApply = false)
public class MapJsonConverter implements AttributeConverter<Map<String, Object>, String> {
private static final ObjectMapper objectMapper = new ObjectMapper();
@Override
public String convertToDatabaseColumn(Map<String, Object> attribute) {
if (attribute == null) {
return null;
}
try {
return objectMapper.writeValueAsString(attribute);
} catch (JsonProcessingException e) {
throw new IllegalArgumentException("Erreur lors de la conversion Map vers JSON String", e);
}
}
@Override
@SuppressWarnings("unchecked")
public Map<String, Object> convertToEntityAttribute(String dbData) {
if (dbData == null) {
return null;
}
try {
return objectMapper.readValue(dbData, Map.class);
} catch (JsonProcessingException e) {
throw new IllegalArgumentException("Erreur lors de la conversion JSON String vers Map", e);
}
}
}

View File

@@ -0,0 +1,48 @@
package com.loremind.infrastructure.persistence.converter;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.loremind.domain.campaigncontext.SceneBranch;
import jakarta.persistence.AttributeConverter;
import jakarta.persistence.Converter;
import java.util.Collections;
import java.util.List;
/**
* Convertit une List<SceneBranch> du domaine en chaîne JSON stockée en base,
* et inversement. Même pattern que StringListJsonConverter mais typé sur
* le Value Object SceneBranch.
*
* Adaptateur d'infrastructure : le domaine reste pur (List<SceneBranch>)
* pendant que PostgreSQL reçoit un TEXT JSON.
*/
@Converter
public class SceneBranchListJsonConverter implements AttributeConverter<List<SceneBranch>, String> {
private static final ObjectMapper MAPPER = new ObjectMapper();
@Override
public String convertToDatabaseColumn(List<SceneBranch> attribute) {
if (attribute == null || attribute.isEmpty()) {
return "[]";
}
try {
return MAPPER.writeValueAsString(attribute);
} catch (Exception e) {
throw new IllegalStateException("Erreur sérialisation List<SceneBranch> → JSON", e);
}
}
@Override
public List<SceneBranch> convertToEntityAttribute(String dbData) {
if (dbData == null || dbData.isBlank()) {
return Collections.emptyList();
}
try {
return MAPPER.readValue(dbData, new TypeReference<List<SceneBranch>>() {});
} catch (Exception e) {
throw new IllegalStateException("Erreur désérialisation JSON → List<SceneBranch>", e);
}
}
}

View File

@@ -0,0 +1,46 @@
package com.loremind.infrastructure.persistence.converter;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.persistence.AttributeConverter;
import jakarta.persistence.Converter;
import java.util.Collections;
import java.util.List;
/**
* Convertit une List<String> du domaine en chaîne JSON stockée en base, et
* inversement. Utilisé pour les listes simples (ex: Template.fields).
*
* Ceci est un adaptateur technique d'infrastructure : il permet au domaine de
* rester pur (juste une List<String>) pendant que JPA parle JSON à PostgreSQL.
*/
@Converter
public class StringListJsonConverter implements AttributeConverter<List<String>, String> {
private static final ObjectMapper MAPPER = new ObjectMapper();
@Override
public String convertToDatabaseColumn(List<String> attribute) {
if (attribute == null || attribute.isEmpty()) {
return "[]";
}
try {
return MAPPER.writeValueAsString(attribute);
} catch (Exception e) {
throw new IllegalStateException("Erreur sérialisation List<String> → JSON", e);
}
}
@Override
public List<String> convertToEntityAttribute(String dbData) {
if (dbData == null || dbData.isBlank()) {
return Collections.emptyList();
}
try {
return MAPPER.readValue(dbData, new TypeReference<List<String>>() {});
} catch (Exception e) {
throw new IllegalStateException("Erreur désérialisation JSON → List<String>", e);
}
}
}

View File

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

View File

@@ -0,0 +1,44 @@
package com.loremind.infrastructure.persistence.converter;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.persistence.AttributeConverter;
import jakarta.persistence.Converter;
import java.util.Collections;
import java.util.Map;
/**
* Convertit une Map<String,String> du domaine en chaîne JSON et inversement.
* Utilisé pour les maps clé-valeur simples (ex: Page.values — stocke les valeurs
* des champs dynamiques définis par le Template).
*/
@Converter
public class StringMapJsonConverter implements AttributeConverter<Map<String, String>, String> {
private static final ObjectMapper MAPPER = new ObjectMapper();
@Override
public String convertToDatabaseColumn(Map<String, String> attribute) {
if (attribute == null || attribute.isEmpty()) {
return "{}";
}
try {
return MAPPER.writeValueAsString(attribute);
} catch (Exception e) {
throw new IllegalStateException("Erreur sérialisation Map<String,String> → JSON", e);
}
}
@Override
public Map<String, String> convertToEntityAttribute(String dbData) {
if (dbData == null || dbData.isBlank()) {
return Collections.emptyMap();
}
try {
return MAPPER.readValue(dbData, new TypeReference<Map<String, String>>() {});
} catch (Exception e) {
throw new IllegalStateException("Erreur désérialisation JSON → Map<String,String>", e);
}
}
}

View File

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

View File

@@ -0,0 +1,87 @@
package com.loremind.infrastructure.persistence.entity;
import com.loremind.infrastructure.persistence.converter.StringListJsonConverter;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
/**
* Entité JPA pour la persistance des Arcs en base de données PostgreSQL.
*/
@Entity
@Table(name = "arcs")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ArcJpaEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
@Column(columnDefinition = "TEXT")
private String description;
@Column(name = "campaign_id", nullable = false)
private Long campaignId;
@Column(name = "\"order\"", nullable = false)
private int order;
// Champs narratifs enrichis — ajoutés automatiquement par Hibernate DDL (ddl-auto=update)
@Column(columnDefinition = "TEXT")
private String themes;
@Column(columnDefinition = "TEXT")
private String stakes;
@Column(name = "gm_notes", columnDefinition = "TEXT")
private String gmNotes;
@Column(columnDefinition = "TEXT")
private String rewards;
@Column(columnDefinition = "TEXT")
private String resolution;
/**
* IDs des pages du Lore liées à cet arc, stockés en JSON dans une colonne TEXT.
* Pas de FK cross-context : respect des Bounded Contexts.
*/
@Column(name = "related_page_ids", columnDefinition = "TEXT")
@Convert(converter = StringListJsonConverter.class)
@Builder.Default
private List<String> relatedPageIds = new ArrayList<>();
/** IDs des images (Shared Kernel) illustrant cet arc. JSON dans colonne TEXT. */
@Column(name = "illustration_image_ids", columnDefinition = "TEXT")
@Convert(converter = StringListJsonConverter.class)
@Builder.Default
private List<String> illustrationImageIds = new ArrayList<>();
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
updatedAt = LocalDateTime.now();
}
@PreUpdate
protected void onUpdate() {
updatedAt = LocalDateTime.now();
}
}

View File

@@ -0,0 +1,58 @@
package com.loremind.infrastructure.persistence.entity;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* Entité JPA pour la persistance des Campaigns en base de données PostgreSQL.
*/
@Entity
@Table(name = "campaigns")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class CampaignJpaEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
@Column(columnDefinition = "TEXT")
private String description;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
@Column(name = "arcs_count", nullable = false)
private int arcsCount;
/**
* ID du Lore associé (nullable).
* Pas de @ManyToOne / pas de FK : c'est une weak reference inter-contexte.
* Le Campaign Context et le Lore Context doivent pouvoir évoluer indépendamment.
*/
@Column(name = "lore_id")
private String loreId;
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
updatedAt = LocalDateTime.now();
}
@PreUpdate
protected void onUpdate() {
updatedAt = LocalDateTime.now();
}
}

View File

@@ -0,0 +1,76 @@
package com.loremind.infrastructure.persistence.entity;
import com.loremind.infrastructure.persistence.converter.StringListJsonConverter;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
/**
* Entité JPA pour la persistance des Chapters en base de données PostgreSQL.
*/
@Entity
@Table(name = "chapters")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ChapterJpaEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
@Column(columnDefinition = "TEXT")
private String description;
@Column(name = "arc_id", nullable = false)
private Long arcId;
@Column(name = "\"order\"", nullable = false)
private int order;
// Champs narratifs enrichis — ajoutés automatiquement par Hibernate DDL (ddl-auto=update)
@Column(name = "gm_notes", columnDefinition = "TEXT")
private String gmNotes;
@Column(name = "player_objectives", columnDefinition = "TEXT")
private String playerObjectives;
@Column(name = "narrative_stakes", columnDefinition = "TEXT")
private String narrativeStakes;
@Column(name = "related_page_ids", columnDefinition = "TEXT")
@Convert(converter = StringListJsonConverter.class)
@Builder.Default
private List<String> relatedPageIds = new ArrayList<>();
@Column(name = "illustration_image_ids", columnDefinition = "TEXT")
@Convert(converter = StringListJsonConverter.class)
@Builder.Default
private List<String> illustrationImageIds = new ArrayList<>();
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
updatedAt = LocalDateTime.now();
}
@PreUpdate
protected void onUpdate() {
updatedAt = LocalDateTime.now();
}
}

View File

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

View File

@@ -0,0 +1,54 @@
package com.loremind.infrastructure.persistence.entity;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* Entité JPA pour la persistance des Lores en base de données PostgreSQL.
* Cette classe contient des annotations JPA, donc elle est dans infrastructure, pas dans le domaine.
*/
@Entity
@Table(name = "lores")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class LoreJpaEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
@Column(columnDefinition = "TEXT")
private String description;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
@Column(name = "node_count", nullable = false)
private int nodeCount;
@Column(name = "page_count", nullable = false)
private int pageCount;
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
updatedAt = LocalDateTime.now();
}
@PreUpdate
protected void onUpdate() {
updatedAt = LocalDateTime.now();
}
}

View File

@@ -0,0 +1,55 @@
package com.loremind.infrastructure.persistence.entity;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* Entité JPA pour la persistance des LoreNodes en base de données PostgreSQL.
* Structure hiérarchique via parentId (auto-référence).
*/
@Entity
@Table(name = "lore_nodes")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class LoreNodeJpaEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
/** Clé de l'icône lucide-angular (ex: "users", "map-pin"). Nullable : un dossier peut ne pas avoir d'icône. */
@Column(length = 64)
private String icon;
@Column(name = "parent_id")
private Long parentId; // Auto-référence pour l'arborescence
@Column(name = "lore_id", nullable = false)
private Long loreId;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
updatedAt = LocalDateTime.now();
}
@PreUpdate
protected void onUpdate() {
updatedAt = LocalDateTime.now();
}
}

View File

@@ -0,0 +1,82 @@
package com.loremind.infrastructure.persistence.entity;
import com.loremind.infrastructure.persistence.converter.StringListJsonConverter;
import com.loremind.infrastructure.persistence.converter.StringListMapJsonConverter;
import com.loremind.infrastructure.persistence.converter.StringMapJsonConverter;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
/**
* Entité JPA pour la persistance des Pages en PostgreSQL.
* - lore_id dénormalisé (accès rapide aux pages d'un lore sans passer par nodes).
* - values / tags / related_page_ids stockés en JSON (TEXT via converters).
* - Note : colonne `values_json` car `values` est un mot-clé SQL réservé.
*/
@Entity
@Table(name = "pages")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class PageJpaEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "lore_id", nullable = false)
private Long loreId;
@Column(name = "node_id", nullable = false)
private Long nodeId;
@Column(name = "template_id")
private Long templateId;
@Column(nullable = false)
private String title;
@Column(name = "values_json", columnDefinition = "TEXT")
@Convert(converter = StringMapJsonConverter.class)
private Map<String, String> values;
/** Stocke les IDs d'images par champ IMAGE du template. JSON dans colonne TEXT. */
@Column(name = "image_values_json", columnDefinition = "TEXT")
@Convert(converter = StringListMapJsonConverter.class)
private Map<String, List<String>> imageValues;
@Column(columnDefinition = "TEXT")
private String notes;
@Column(name = "tags", columnDefinition = "TEXT")
@Convert(converter = StringListJsonConverter.class)
private List<String> tags;
@Column(name = "related_page_ids", columnDefinition = "TEXT")
@Convert(converter = StringListJsonConverter.class)
private List<String> relatedPageIds;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
updatedAt = LocalDateTime.now();
}
@PreUpdate
protected void onUpdate() {
updatedAt = LocalDateTime.now();
}
}

View File

@@ -0,0 +1,106 @@
package com.loremind.infrastructure.persistence.entity;
import com.loremind.domain.campaigncontext.SceneBranch;
import com.loremind.infrastructure.persistence.converter.SceneBranchListJsonConverter;
import com.loremind.infrastructure.persistence.converter.StringListJsonConverter;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
/**
* Entité JPA pour la persistance des Scenes en base de données PostgreSQL.
*/
@Entity
@Table(name = "scenes")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class SceneJpaEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
@Column(columnDefinition = "TEXT")
private String description;
@Column(name = "chapter_id", nullable = false)
private Long chapterId;
@Column(name = "\"order\"", nullable = false)
private int order;
// Champs narratifs enrichis — ajoutés automatiquement par Hibernate (ddl-auto=update)
// Contexte et ambiance
@Column(columnDefinition = "TEXT")
private String location;
@Column(columnDefinition = "TEXT")
private String timing;
@Column(columnDefinition = "TEXT")
private String atmosphere;
// Narration
@Column(name = "player_narration", columnDefinition = "TEXT")
private String playerNarration;
// Secrets MJ
@Column(name = "gm_secret_notes", columnDefinition = "TEXT")
private String gmSecretNotes;
// Choix et conséquences
@Column(name = "choices_consequences", columnDefinition = "TEXT")
private String choicesConsequences;
// Combat
@Column(name = "combat_difficulty", columnDefinition = "TEXT")
private String combatDifficulty;
@Column(columnDefinition = "TEXT")
private String enemies;
@Column(name = "related_page_ids", columnDefinition = "TEXT")
@Convert(converter = StringListJsonConverter.class)
@Builder.Default
private List<String> relatedPageIds = new ArrayList<>();
@Column(name = "illustration_image_ids", columnDefinition = "TEXT")
@Convert(converter = StringListJsonConverter.class)
@Builder.Default
private List<String> illustrationImageIds = new ArrayList<>();
// Graphe narratif intra-chapitre : sorties possibles vers d'autres scènes.
// Persisté en TEXT JSON via converter (pattern homogène avec les autres listes).
@Column(name = "branches", columnDefinition = "TEXT")
@Convert(converter = SceneBranchListJsonConverter.class)
@Builder.Default
private List<SceneBranch> branches = new ArrayList<>();
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
updatedAt = LocalDateTime.now();
}
@PreUpdate
protected void onUpdate() {
updatedAt = LocalDateTime.now();
}
}

View File

@@ -0,0 +1,65 @@
package com.loremind.infrastructure.persistence.entity;
import com.loremind.domain.lorecontext.TemplateField;
import com.loremind.infrastructure.persistence.converter.TemplateFieldListJsonConverter;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.List;
/**
* Entité JPA pour la persistance des Templates en PostgreSQL.
* - loreId et defaultNodeId : colonnes typées (FK logiques, pas de @ManyToOne
* pour respecter l'isolation des Bounded Contexts).
* - fields : stocké en JSON (TEXT) via TemplateFieldListJsonConverter.
* Les anciens templates (format legacy ["a","b"]) sont lus de maniere tolerante.
*/
@Entity
@Table(name = "templates")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class TemplateJpaEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "lore_id", nullable = false)
private Long loreId;
@Column(nullable = false)
private String name;
@Column(columnDefinition = "TEXT")
private String description;
@Column(name = "default_node_id")
private Long defaultNodeId;
@Column(name = "fields", columnDefinition = "TEXT")
@Convert(converter = TemplateFieldListJsonConverter.class)
private List<TemplateField> fields;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
updatedAt = LocalDateTime.now();
}
@PreUpdate
protected void onUpdate() {
updatedAt = LocalDateTime.now();
}
}

View File

@@ -0,0 +1,16 @@
package com.loremind.infrastructure.persistence.jpa;
import com.loremind.infrastructure.persistence.entity.ArcJpaEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
/**
* Repository Spring Data JPA pour ArcJpaEntity.
*/
@Repository
public interface ArcJpaRepository extends JpaRepository<ArcJpaEntity, Long> {
List<ArcJpaEntity> findByCampaignId(Long campaignId);
}

View File

@@ -0,0 +1,19 @@
package com.loremind.infrastructure.persistence.jpa;
import com.loremind.infrastructure.persistence.entity.CampaignJpaEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;
/**
* Repository Spring Data JPA pour CampaignJpaEntity.
*/
@Repository
public interface CampaignJpaRepository extends JpaRepository<CampaignJpaEntity, Long> {
@Query("SELECT c FROM CampaignJpaEntity c WHERE LOWER(c.name) LIKE LOWER(CONCAT('%', :query, '%'))")
List<CampaignJpaEntity> findByNameContainingIgnoreCase(@Param("query") String query);
}

View File

@@ -0,0 +1,16 @@
package com.loremind.infrastructure.persistence.jpa;
import com.loremind.infrastructure.persistence.entity.ChapterJpaEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
/**
* Repository Spring Data JPA pour ChapterJpaEntity.
*/
@Repository
public interface ChapterJpaRepository extends JpaRepository<ChapterJpaEntity, Long> {
List<ChapterJpaEntity> findByArcId(Long arcId);
}

View File

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

Some files were not shown because too many files have changed in this diff Show More