Compare commits
12 Commits
1e2598bcf9
...
v1.0.1
| Author | SHA1 | Date | |
|---|---|---|---|
| 570f6819d8 | |||
| 6b35aa7ef2 | |||
| 319bc15980 | |||
| 344013fb5c | |||
| abb3081294 | |||
| 7a340285c5 | |||
| 67818f0d3d | |||
| 1a5b6f8d79 | |||
| 5b133aa2fe | |||
| 94bbf8beff | |||
| e1f37a8dbb | |||
| 094c759f2c |
30
.env.example
Normal file
30
.env.example
Normal 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
|
||||||
40
.gitea/workflows/release.yml
Normal file
40
.gitea/workflows/release.yml
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
name: Build & Push Images
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
|
||||||
|
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: Login to Gitea Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ vars.REGISTRY_URL }}
|
||||||
|
username: ${{ vars.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: |
|
||||||
|
${{ vars.REGISTRY_URL }}/loremindmj/${{ matrix.component }}:latest
|
||||||
|
${{ vars.REGISTRY_URL }}/loremindmj/${{ matrix.component }}:${{ steps.meta.outputs.version }}
|
||||||
9
.gitignore
vendored
9
.gitignore
vendored
@@ -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
64
INSTALL.md
Normal 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
76
README.md
Normal 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
|
||||||
|

|
||||||
|
|
||||||
|
### Recherche
|
||||||
|

|
||||||
|
|
||||||
|
## 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
6
brain/.dockerignore
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
data/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.env
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
4
brain/.gitignore
vendored
Normal file
4
brain/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.env
|
||||||
16
brain/Dockerfile
Normal file
16
brain/Dockerfile
Normal 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
0
brain/app/__init__.py
Normal file
0
brain/app/application/__init__.py
Normal file
0
brain/app/application/__init__.py
Normal file
258
brain/app/application/chat.py
Normal file
258
brain/app/application/chat.py
Normal 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."
|
||||||
|
)
|
||||||
98
brain/app/application/generate_page.py
Normal file
98
brain/app/application/generate_page.py
Normal 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
|
||||||
|
}
|
||||||
0
brain/app/core/__init__.py
Normal file
0
brain/app/core/__init__.py
Normal file
54
brain/app/core/config.py
Normal file
54
brain/app/core/config.py
Normal 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())
|
||||||
41
brain/app/core/settings_store.py
Normal file
41
brain/app/core/settings_store.py
Normal 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
|
||||||
0
brain/app/domain/__init__.py
Normal file
0
brain/app/domain/__init__.py
Normal file
186
brain/app/domain/models.py
Normal file
186
brain/app/domain/models.py
Normal 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
86
brain/app/domain/ports.py
Normal 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.
|
||||||
|
"""
|
||||||
0
brain/app/infrastructure/__init__.py
Normal file
0
brain/app/infrastructure/__init__.py
Normal file
121
brain/app/infrastructure/ollama_adapter.py
Normal file
121
brain/app/infrastructure/ollama_adapter.py
Normal 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
|
||||||
174
brain/app/infrastructure/onemin_adapter.py
Normal file
174
brain/app/infrastructure/onemin_adapter.py
Normal 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
567
brain/app/main.py
Normal 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
7
brain/data/settings.json
Normal 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
4
brain/requirements.txt
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
fastapi==0.115.*
|
||||||
|
uvicorn[standard]==0.32.*
|
||||||
|
httpx==0.27.*
|
||||||
|
pydantic-settings==2.6.*
|
||||||
4
core/.dockerignore
Normal file
4
core/.dockerignore
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
target/
|
||||||
|
.idea/
|
||||||
|
*.iml
|
||||||
|
.mvn/
|
||||||
12
core/Dockerfile
Normal file
12
core/Dockerfile
Normal 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
97
core/pom.xml
Normal 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>
|
||||||
16
core/src/main/java/com/loremind/LoreMindApplication.java
Normal file
16
core/src/main/java/com/loremind/LoreMindApplication.java
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
package com.loremind.application.generationcontext;
|
||||||
|
|
||||||
|
import com.loremind.domain.generationcontext.LoreStructuralContext;
|
||||||
|
import com.loremind.domain.generationcontext.LoreStructuralContext.PageSummary;
|
||||||
|
import com.loremind.domain.lorecontext.Lore;
|
||||||
|
import com.loremind.domain.lorecontext.LoreNode;
|
||||||
|
import com.loremind.domain.lorecontext.Page;
|
||||||
|
import com.loremind.domain.lorecontext.Template;
|
||||||
|
import com.loremind.domain.lorecontext.ports.LoreNodeRepository;
|
||||||
|
import com.loremind.domain.lorecontext.ports.LoreRepository;
|
||||||
|
import com.loremind.domain.lorecontext.ports.PageRepository;
|
||||||
|
import com.loremind.domain.lorecontext.ports.TemplateRepository;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service applicatif qui construit un {@link LoreStructuralContext}
|
||||||
|
* depuis le Lore Context (Single Responsibility : projection LoreContext → GenerationContext).
|
||||||
|
*
|
||||||
|
* Partagé entre {@link StreamChatForLoreUseCase} (Lore) et
|
||||||
|
* {@link StreamChatForCampaignUseCase} (Campagne liée à un Lore) pour
|
||||||
|
* respecter DRY — la carte structurelle d'un Lore se calcule de la même
|
||||||
|
* manière des deux côtés.
|
||||||
|
*
|
||||||
|
* Depuis b9 : chaque PageSummary embarque values/tags/relatedPageTitles
|
||||||
|
* (résolus en titres), avec troncature à {@value #MAX_VALUE_LENGTH} caractères
|
||||||
|
* par valeur pour garder le prompt sous contrôle.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class LoreStructuralContextBuilder {
|
||||||
|
|
||||||
|
/** Garde-fou : évite qu'un champ énorme (ex: "Histoire" de 5000 car.) ne sature le prompt. */
|
||||||
|
private static final int MAX_VALUE_LENGTH = 500;
|
||||||
|
|
||||||
|
private final LoreRepository loreRepository;
|
||||||
|
private final LoreNodeRepository loreNodeRepository;
|
||||||
|
private final PageRepository pageRepository;
|
||||||
|
private final TemplateRepository templateRepository;
|
||||||
|
|
||||||
|
public LoreStructuralContextBuilder(
|
||||||
|
LoreRepository loreRepository,
|
||||||
|
LoreNodeRepository loreNodeRepository,
|
||||||
|
PageRepository pageRepository,
|
||||||
|
TemplateRepository templateRepository) {
|
||||||
|
this.loreRepository = loreRepository;
|
||||||
|
this.loreNodeRepository = loreNodeRepository;
|
||||||
|
this.pageRepository = pageRepository;
|
||||||
|
this.templateRepository = templateRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construit la carte structurelle pour un Lore obligatoire.
|
||||||
|
* @throws IllegalArgumentException si le Lore est introuvable
|
||||||
|
*/
|
||||||
|
public LoreStructuralContext build(String loreId) {
|
||||||
|
return buildOptional(loreId).orElseThrow(() ->
|
||||||
|
new IllegalArgumentException("Lore non trouvé avec l'ID: " + loreId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Variante non-strict : renvoie Optional.empty() si le Lore a été supprimé
|
||||||
|
* (cas d'une Campagne dont le loreId pointe sur un Lore effacé entre-temps).
|
||||||
|
*/
|
||||||
|
public Optional<LoreStructuralContext> buildOptional(String loreId) {
|
||||||
|
return loreRepository.findById(loreId).map(this::buildFromLore);
|
||||||
|
}
|
||||||
|
|
||||||
|
private LoreStructuralContext buildFromLore(Lore lore) {
|
||||||
|
List<LoreNode> nodes = loreNodeRepository.findByLoreId(lore.getId());
|
||||||
|
List<Page> pages = pageRepository.findByLoreId(lore.getId());
|
||||||
|
List<Template> templates = templateRepository.findByLoreId(lore.getId());
|
||||||
|
|
||||||
|
// Maps de résolution construites une seule fois — évite les N² en aval.
|
||||||
|
Map<String, String> templateNameById = templates.stream()
|
||||||
|
.collect(Collectors.toMap(Template::getId, Template::getName, (a, b) -> a));
|
||||||
|
Map<String, String> pageTitleById = pages.stream()
|
||||||
|
.collect(Collectors.toMap(Page::getId, Page::getTitle, (a, b) -> a));
|
||||||
|
|
||||||
|
return LoreStructuralContext.builder()
|
||||||
|
.loreName(lore.getName())
|
||||||
|
.loreDescription(lore.getDescription())
|
||||||
|
.folders(buildFoldersMap(nodes, pages, templateNameById, pageTitleById))
|
||||||
|
.tags(extractUniqueTags(pages))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, List<PageSummary>> buildFoldersMap(
|
||||||
|
List<LoreNode> nodes,
|
||||||
|
List<Page> pages,
|
||||||
|
Map<String, String> templateNameById,
|
||||||
|
Map<String, String> pageTitleById) {
|
||||||
|
// LinkedHashMap : préserve l'ordre d'insertion pour un prompt lisible.
|
||||||
|
Map<String, List<PageSummary>> folders = new LinkedHashMap<>();
|
||||||
|
for (LoreNode node : nodes) {
|
||||||
|
folders.put(node.getName(), pagesInFolder(node.getId(), pages, templateNameById, pageTitleById));
|
||||||
|
}
|
||||||
|
return folders;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<PageSummary> pagesInFolder(
|
||||||
|
String nodeId,
|
||||||
|
List<Page> allPages,
|
||||||
|
Map<String, String> templateNameById,
|
||||||
|
Map<String, String> pageTitleById) {
|
||||||
|
return allPages.stream()
|
||||||
|
.filter(p -> nodeId.equals(p.getNodeId()))
|
||||||
|
.map(p -> toPageSummary(p, templateNameById, pageTitleById))
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
private PageSummary toPageSummary(
|
||||||
|
Page page,
|
||||||
|
Map<String, String> templateNameById,
|
||||||
|
Map<String, String> pageTitleById) {
|
||||||
|
return PageSummary.builder()
|
||||||
|
.title(page.getTitle())
|
||||||
|
.templateName(templateNameById.getOrDefault(page.getTemplateId(), "?"))
|
||||||
|
.values(truncatedValues(page.getValues()))
|
||||||
|
.tags(page.getTags() != null ? List.copyOf(page.getTags()) : Collections.emptyList())
|
||||||
|
.relatedPageTitles(resolveRelatedTitles(page.getRelatedPageIds(), pageTitleById))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copie défensive des values avec troncature par valeur.
|
||||||
|
* Les entrées vides/nulles sont filtrées pour alléger le prompt.
|
||||||
|
*/
|
||||||
|
private Map<String, String> truncatedValues(Map<String, String> source) {
|
||||||
|
if (source == null || source.isEmpty()) {
|
||||||
|
return Collections.emptyMap();
|
||||||
|
}
|
||||||
|
Map<String, String> out = new LinkedHashMap<>();
|
||||||
|
for (Map.Entry<String, String> e : source.entrySet()) {
|
||||||
|
String v = e.getValue();
|
||||||
|
if (v == null || v.isBlank()) continue;
|
||||||
|
out.put(e.getKey(), truncate(v));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String truncate(String value) {
|
||||||
|
if (value.length() <= MAX_VALUE_LENGTH) return value;
|
||||||
|
return value.substring(0, MAX_VALUE_LENGTH) + "…";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Résout les IDs de pages liées en titres. Un ID qui ne matche rien
|
||||||
|
* (page supprimée entre-temps) est silencieusement ignoré — pas de "?"
|
||||||
|
* qui polluerait le prompt.
|
||||||
|
*/
|
||||||
|
private List<String> resolveRelatedTitles(
|
||||||
|
List<String> relatedIds, Map<String, String> pageTitleById) {
|
||||||
|
if (relatedIds == null || relatedIds.isEmpty()) {
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
return relatedIds.stream()
|
||||||
|
.map(pageTitleById::get)
|
||||||
|
.filter(title -> title != null && !title.isBlank())
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<String> extractUniqueTags(List<Page> pages) {
|
||||||
|
return pages.stream()
|
||||||
|
.filter(p -> p.getTags() != null)
|
||||||
|
.flatMap(p -> p.getTags().stream())
|
||||||
|
.distinct()
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
package com.loremind.application.generationcontext;
|
||||||
|
|
||||||
|
import com.loremind.domain.campaigncontext.Arc;
|
||||||
|
import com.loremind.domain.campaigncontext.Chapter;
|
||||||
|
import com.loremind.domain.campaigncontext.Scene;
|
||||||
|
import com.loremind.domain.campaigncontext.ports.ArcRepository;
|
||||||
|
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
|
||||||
|
import com.loremind.domain.campaigncontext.ports.SceneRepository;
|
||||||
|
import com.loremind.domain.generationcontext.NarrativeEntityContext;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service applicatif qui construit un {@link NarrativeEntityContext}
|
||||||
|
* depuis une entité Arc / Chapter / Scene du Campaign Context.
|
||||||
|
*
|
||||||
|
* Responsabilité unique : mapper les champs textuels spécifiques de chaque
|
||||||
|
* type vers la map uniforme `fields` du VO. Utilise LinkedHashMap pour
|
||||||
|
* préserver l'ordre des champs dans le prompt (lisibilité).
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class NarrativeEntityContextBuilder {
|
||||||
|
|
||||||
|
private final ArcRepository arcRepository;
|
||||||
|
private final ChapterRepository chapterRepository;
|
||||||
|
private final SceneRepository sceneRepository;
|
||||||
|
|
||||||
|
public NarrativeEntityContextBuilder(
|
||||||
|
ArcRepository arcRepository,
|
||||||
|
ChapterRepository chapterRepository,
|
||||||
|
SceneRepository sceneRepository) {
|
||||||
|
this.arcRepository = arcRepository;
|
||||||
|
this.chapterRepository = chapterRepository;
|
||||||
|
this.sceneRepository = sceneRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Charge l'entité narrative ciblée et la projette vers un VO du GenerationContext.
|
||||||
|
*
|
||||||
|
* @param entityType "arc", "chapter" ou "scene" (insensible à la casse)
|
||||||
|
* @param entityId l'ID de l'entité
|
||||||
|
* @throws IllegalArgumentException si le type est inconnu ou l'entité introuvable
|
||||||
|
*/
|
||||||
|
public NarrativeEntityContext build(String entityType, String entityId) {
|
||||||
|
String normalized = entityType == null ? "" : entityType.trim().toLowerCase();
|
||||||
|
switch (normalized) {
|
||||||
|
case "arc": return fromArc(loadArc(entityId));
|
||||||
|
case "chapter": return fromChapter(loadChapter(entityId));
|
||||||
|
case "scene": return fromScene(loadScene(entityId));
|
||||||
|
default:
|
||||||
|
throw new IllegalArgumentException("Type d'entité narrative inconnu: " + entityType);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Chargement ---------------------------------------------------------
|
||||||
|
|
||||||
|
private Arc loadArc(String id) {
|
||||||
|
return arcRepository.findById(id)
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("Arc non trouvé: " + id));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Chapter loadChapter(String id) {
|
||||||
|
return chapterRepository.findById(id)
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("Chapitre non trouvé: " + id));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Scene loadScene(String id) {
|
||||||
|
return sceneRepository.findById(id)
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("Scène non trouvée: " + id));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Mapping entité → VO ------------------------------------------------
|
||||||
|
|
||||||
|
private NarrativeEntityContext fromArc(Arc a) {
|
||||||
|
Map<String, String> fields = new LinkedHashMap<>();
|
||||||
|
putField(fields, "description (synopsis)", a.getDescription());
|
||||||
|
putField(fields, "themes", a.getThemes());
|
||||||
|
putField(fields, "stakes", a.getStakes());
|
||||||
|
putField(fields, "rewards", a.getRewards());
|
||||||
|
putField(fields, "resolution", a.getResolution());
|
||||||
|
putField(fields, "gmNotes", a.getGmNotes());
|
||||||
|
return NarrativeEntityContext.builder()
|
||||||
|
.entityType("arc")
|
||||||
|
.title(a.getName())
|
||||||
|
.fields(fields)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private NarrativeEntityContext fromChapter(Chapter c) {
|
||||||
|
Map<String, String> fields = new LinkedHashMap<>();
|
||||||
|
putField(fields, "description (synopsis)", c.getDescription());
|
||||||
|
putField(fields, "playerObjectives", c.getPlayerObjectives());
|
||||||
|
putField(fields, "narrativeStakes", c.getNarrativeStakes());
|
||||||
|
putField(fields, "gmNotes", c.getGmNotes());
|
||||||
|
return NarrativeEntityContext.builder()
|
||||||
|
.entityType("chapter")
|
||||||
|
.title(c.getName())
|
||||||
|
.fields(fields)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private NarrativeEntityContext fromScene(Scene s) {
|
||||||
|
Map<String, String> fields = new LinkedHashMap<>();
|
||||||
|
putField(fields, "description", s.getDescription());
|
||||||
|
putField(fields, "location", s.getLocation());
|
||||||
|
putField(fields, "timing", s.getTiming());
|
||||||
|
putField(fields, "atmosphere", s.getAtmosphere());
|
||||||
|
putField(fields, "playerNarration", s.getPlayerNarration());
|
||||||
|
putField(fields, "choicesConsequences", s.getChoicesConsequences());
|
||||||
|
putField(fields, "combatDifficulty", s.getCombatDifficulty());
|
||||||
|
putField(fields, "enemies", s.getEnemies());
|
||||||
|
putField(fields, "gmSecretNotes", s.getGmSecretNotes());
|
||||||
|
return NarrativeEntityContext.builder()
|
||||||
|
.entityType("scene")
|
||||||
|
.title(s.getName())
|
||||||
|
.fields(fields)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Null/blank devient chaîne vide — uniforme côté prompt, pas de NPE côté LLM. */
|
||||||
|
private static void putField(Map<String, String> target, String key, String value) {
|
||||||
|
target.put(key, value == null ? "" : value);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package com.loremind.application.generationcontext;
|
||||||
|
|
||||||
|
import com.loremind.domain.campaigncontext.Campaign;
|
||||||
|
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
|
||||||
|
import com.loremind.domain.generationcontext.CampaignStructuralContext;
|
||||||
|
import com.loremind.domain.generationcontext.ChatMessage;
|
||||||
|
import com.loremind.domain.generationcontext.ChatRequest;
|
||||||
|
import com.loremind.domain.generationcontext.LoreStructuralContext;
|
||||||
|
import com.loremind.domain.generationcontext.NarrativeEntityContext;
|
||||||
|
import com.loremind.domain.generationcontext.ports.AiChatProvider;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use case applicatif : chat conversationnel pour une Campagne avec Structural Context.
|
||||||
|
*
|
||||||
|
* Orchestre :
|
||||||
|
* 1. Chargement de la carte narrative de la Campagne (arcs → chapitres → scènes).
|
||||||
|
* 2. Si la Campagne est liée à un Lore (`loreId`), chargement également de
|
||||||
|
* la carte du Lore associé (asymétrie métier : Campagne voit son Lore).
|
||||||
|
* 3. Si une entité narrative précise est ciblée (arc/chapter/scene en cours
|
||||||
|
* d'édition), focalisation via `NarrativeEntityContext`.
|
||||||
|
* 4. Délégation au port `AiChatProvider` pour le streaming token par token.
|
||||||
|
*
|
||||||
|
* Zéro persistance : la conversation est éphémère (responsabilité du frontend).
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class StreamChatForCampaignUseCase {
|
||||||
|
|
||||||
|
private final CampaignRepository campaignRepository;
|
||||||
|
private final CampaignStructuralContextBuilder campaignContextBuilder;
|
||||||
|
private final LoreStructuralContextBuilder loreContextBuilder;
|
||||||
|
private final NarrativeEntityContextBuilder narrativeEntityContextBuilder;
|
||||||
|
private final AiChatProvider aiChatProvider;
|
||||||
|
|
||||||
|
public StreamChatForCampaignUseCase(
|
||||||
|
CampaignRepository campaignRepository,
|
||||||
|
CampaignStructuralContextBuilder campaignContextBuilder,
|
||||||
|
LoreStructuralContextBuilder loreContextBuilder,
|
||||||
|
NarrativeEntityContextBuilder narrativeEntityContextBuilder,
|
||||||
|
AiChatProvider aiChatProvider) {
|
||||||
|
this.campaignRepository = campaignRepository;
|
||||||
|
this.campaignContextBuilder = campaignContextBuilder;
|
||||||
|
this.loreContextBuilder = loreContextBuilder;
|
||||||
|
this.narrativeEntityContextBuilder = narrativeEntityContextBuilder;
|
||||||
|
this.aiChatProvider = aiChatProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Streame la réponse du LLM pour la Campagne donnée.
|
||||||
|
*
|
||||||
|
* Méthode bloquante : retourne une fois le stream terminé (onComplete ou onError).
|
||||||
|
* L'appelant (controller SSE) doit l'exécuter dans un thread dédié.
|
||||||
|
*
|
||||||
|
* @param campaignId obligatoire — la campagne concernée
|
||||||
|
* @param entityType optionnel ("arc"|"chapter"|"scene") — si fourni avec entityId,
|
||||||
|
* focalise l'IA sur l'entité narrative en cours d'édition.
|
||||||
|
* @param entityId optionnel — ID de l'entité si `entityType` est fourni
|
||||||
|
* @throws IllegalArgumentException si la Campagne (ou l'entité ciblée) est introuvable
|
||||||
|
*/
|
||||||
|
public void execute(
|
||||||
|
String campaignId,
|
||||||
|
String entityType,
|
||||||
|
String entityId,
|
||||||
|
List<ChatMessage> messages,
|
||||||
|
Consumer<String> onToken,
|
||||||
|
Runnable onComplete,
|
||||||
|
Consumer<Throwable> onError) {
|
||||||
|
|
||||||
|
Campaign campaign = campaignRepository.findById(campaignId)
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException(
|
||||||
|
"Campagne non trouvée avec l'ID: " + campaignId));
|
||||||
|
|
||||||
|
CampaignStructuralContext campaignContext = campaignContextBuilder.build(campaignId);
|
||||||
|
LoreStructuralContext loreContext = loadLinkedLoreContextOrNull(campaign);
|
||||||
|
NarrativeEntityContext narrativeEntity = buildNarrativeEntityOrNull(entityType, entityId);
|
||||||
|
|
||||||
|
ChatRequest request = ChatRequest.builder()
|
||||||
|
.messages(messages)
|
||||||
|
.loreContext(loreContext)
|
||||||
|
.campaignContext(campaignContext)
|
||||||
|
.narrativeEntity(narrativeEntity)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
aiChatProvider.streamChat(request, onToken, onComplete, onError);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Charge le LoreStructuralContext si la campagne est liée ET que le Lore
|
||||||
|
* existe encore (cas dégradé : loreId pointant sur un Lore supprimé →
|
||||||
|
* on continue sans contexte Lore plutôt que d'échouer).
|
||||||
|
*/
|
||||||
|
private LoreStructuralContext loadLinkedLoreContextOrNull(Campaign campaign) {
|
||||||
|
if (!campaign.isLinkedToLore()) return null;
|
||||||
|
return loreContextBuilder.buildOptional(campaign.getLoreId()).orElse(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private NarrativeEntityContext buildNarrativeEntityOrNull(String entityType, String entityId) {
|
||||||
|
if (entityType == null || entityType.isBlank()) return null;
|
||||||
|
if (entityId == null || entityId.isBlank()) return null;
|
||||||
|
return narrativeEntityContextBuilder.build(entityType, entityId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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).");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
56
core/src/main/java/com/loremind/domain/images/Image.java
Normal file
56
core/src/main/java/com/loremind/domain/images/Image.java
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
package com.loremind.domain.images;
|
||||||
|
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entite de domaine representant une image uploadee par l'utilisateur.
|
||||||
|
*
|
||||||
|
* Shared Kernel : cette entite vit dans un package transverse (ni LoreContext
|
||||||
|
* ni CampaignContext) car une image peut etre referencee par n'importe quelle
|
||||||
|
* entite de ces deux contextes (Page, Scene, Chapter, Arc). Elle n'appartient
|
||||||
|
* a aucun context en particulier.
|
||||||
|
*
|
||||||
|
* Design :
|
||||||
|
* - Metadata en DB relationnelle (Postgres)
|
||||||
|
* - Binaire sur object storage (MinIO/S3) referencE par `storageKey`
|
||||||
|
* - Le domaine ne connait pas MinIO : il manipule juste une cle opaque.
|
||||||
|
*
|
||||||
|
* Architecture Hexagonale : entite pure, aucune dependance technique.
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
public class Image {
|
||||||
|
|
||||||
|
/** Identifiant stable (String pour rester agnostique vis-a-vis du stockage). */
|
||||||
|
private String id;
|
||||||
|
|
||||||
|
/** Nom original du fichier uploade (ex: "portrait-elfe.jpg"). */
|
||||||
|
private String filename;
|
||||||
|
|
||||||
|
/** Type MIME valide (ex: "image/jpeg", "image/png", "image/webp"). */
|
||||||
|
private String contentType;
|
||||||
|
|
||||||
|
/** Taille en octets, utile pour quotas et affichage UI. */
|
||||||
|
private long sizeBytes;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cle opaque dans l'object storage (ex: "images/abc123.jpg").
|
||||||
|
* Le domaine ne fait qu'acheminer cette cle ; seul l'adaptateur MinIO sait
|
||||||
|
* comment la transformer en bucket + path pour recuperer le binaire.
|
||||||
|
*/
|
||||||
|
private String storageKey;
|
||||||
|
|
||||||
|
/** Horodatage de l'upload initial (l'image est immuable apres creation). */
|
||||||
|
private LocalDateTime uploadedAt;
|
||||||
|
|
||||||
|
// --- Methodes metier ---------------------------------------------------
|
||||||
|
|
||||||
|
/** Une image est "sereement valide" si elle pointe bien vers un binaire. */
|
||||||
|
public boolean isValid() {
|
||||||
|
return storageKey != null && !storageKey.isBlank()
|
||||||
|
&& contentType != null && contentType.startsWith("image/");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package com.loremind.domain.images.ports;
|
||||||
|
|
||||||
|
import com.loremind.domain.images.Image;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Port de sortie pour la persistance des metadonnees d'images.
|
||||||
|
*
|
||||||
|
* Architecture Hexagonale : ce port est defini dans le domaine ; il est
|
||||||
|
* implemente par un adaptateur d'infrastructure (PostgresImageRepository).
|
||||||
|
*
|
||||||
|
* Ne manipule QUE les metadonnees (filename, mimeType, storageKey...).
|
||||||
|
* Le binaire est gere par un autre port : ImageStorage.
|
||||||
|
* Cette separation suit le Single Responsibility Principle (SRP).
|
||||||
|
*/
|
||||||
|
public interface ImageRepository {
|
||||||
|
|
||||||
|
Image save(Image image);
|
||||||
|
|
||||||
|
Optional<Image> findById(String id);
|
||||||
|
|
||||||
|
void deleteById(String id);
|
||||||
|
|
||||||
|
boolean existsById(String id);
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package com.loremind.domain.images.ports;
|
||||||
|
|
||||||
|
import java.io.InputStream;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Port de sortie pour le stockage du BINAIRE des images.
|
||||||
|
*
|
||||||
|
* Separe de ImageRepository (metadonnees) pour respecter le SRP :
|
||||||
|
* - ImageRepository --> Postgres (metadonnees)
|
||||||
|
* - ImageStorage --> MinIO/S3 (fichiers binaires)
|
||||||
|
*
|
||||||
|
* Le domaine raisonne en termes de "cle opaque" (storageKey).
|
||||||
|
* Chaque implementation (MinIO, filesystem, S3...) traduit cette cle selon
|
||||||
|
* sa propre logique physique.
|
||||||
|
*/
|
||||||
|
public interface ImageStorage {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Envoie un flux binaire et retourne la cle generee.
|
||||||
|
*
|
||||||
|
* @param filename nom d'origine (utilise pour extraire l'extension)
|
||||||
|
* @param contentType MIME type valide
|
||||||
|
* @param data flux binaire a stocker
|
||||||
|
* @param sizeBytes taille en octets (requis par certains backends comme S3)
|
||||||
|
* @return cle opaque utilisable ensuite pour retrouver le binaire
|
||||||
|
*/
|
||||||
|
String upload(String filename, String contentType, InputStream data, long sizeBytes);
|
||||||
|
|
||||||
|
/** Recupere le flux binaire associe a une cle, ou null si inexistante. */
|
||||||
|
InputStream download(String storageKey);
|
||||||
|
|
||||||
|
/** Supprime le binaire. No-op silencieux si la cle n'existe pas. */
|
||||||
|
void delete(String storageKey);
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package com.loremind.domain.lorecontext;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type d'un champ dynamique d'un Template.
|
||||||
|
*
|
||||||
|
* - TEXT : valeur textuelle libre (stockee dans Page.values : Map<String, String>)
|
||||||
|
* - IMAGE : galerie d'images, represente comme une liste d'IDs d'images
|
||||||
|
* (stockee dans Page.imageValues : Map<String, List<String>>)
|
||||||
|
*
|
||||||
|
* Extension future possible : RICH_TEXT, NUMBER, DATE, BOOLEAN, LORE_LINK...
|
||||||
|
*/
|
||||||
|
public enum FieldType {
|
||||||
|
TEXT,
|
||||||
|
IMAGE
|
||||||
|
}
|
||||||
48
core/src/main/java/com/loremind/domain/lorecontext/Lore.java
Normal file
48
core/src/main/java/com/loremind/domain/lorecontext/Lore.java
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
105
core/src/main/java/com/loremind/domain/lorecontext/Page.java
Normal file
105
core/src/main/java/com/loremind/domain/lorecontext/Page.java
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package com.loremind.infrastructure.persistence.converter;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.type.TypeReference;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import jakarta.persistence.AttributeConverter;
|
||||||
|
import jakarta.persistence.Converter;
|
||||||
|
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convertit une Map<String, List<String>> du domaine en chaine JSON et inversement.
|
||||||
|
*
|
||||||
|
* Utilise pour Page.imageValues : pour chaque champ IMAGE du template
|
||||||
|
* (ex: "Portrait"), la map stocke la liste ordonnee des IDs d'images uploadees.
|
||||||
|
*
|
||||||
|
* Exemple de JSON produit :
|
||||||
|
* {"Portrait": ["42","17"], "Carte": ["99"]}
|
||||||
|
*
|
||||||
|
* Adaptateur technique d'infrastructure : le domaine ne connait jamais ce converter.
|
||||||
|
*/
|
||||||
|
@Converter
|
||||||
|
public class StringListMapJsonConverter
|
||||||
|
implements AttributeConverter<Map<String, List<String>>, String> {
|
||||||
|
|
||||||
|
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String convertToDatabaseColumn(Map<String, List<String>> attribute) {
|
||||||
|
if (attribute == null || attribute.isEmpty()) {
|
||||||
|
return "{}";
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return MAPPER.writeValueAsString(attribute);
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"Erreur serialisation Map<String, List<String>> -> JSON", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Map<String, List<String>> convertToEntityAttribute(String dbData) {
|
||||||
|
if (dbData == null || dbData.isBlank()) {
|
||||||
|
return Collections.emptyMap();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return MAPPER.readValue(dbData, new TypeReference<Map<String, List<String>>>() {});
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"Erreur deserialisation JSON -> Map<String, List<String>>", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<>() {};
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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
Reference in New Issue
Block a user