Compare commits
16 Commits
1e2598bcf9
...
v1.0.5
| Author | SHA1 | Date | |
|---|---|---|---|
| d16adb25b0 | |||
| 9189506e9b | |||
| 0d1c34d1f8 | |||
| ca1bc2b573 | |||
| 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
|
||||
44
.gitea/workflows/release.yml
Normal file
44
.gitea/workflows/release.yml
Normal file
@@ -0,0 +1,44 @@
|
||||
name: Build & Push Images
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
env:
|
||||
REGISTRY: git.igmlcreation.fr
|
||||
REGISTRY_USER: ietm64
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
component: [brain, core, web]
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to Gitea Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ env.REGISTRY_USER }}
|
||||
password: ${{ secrets.DOCKER_PAT }}
|
||||
|
||||
- name: Extract version
|
||||
id: meta
|
||||
run: echo "version=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build & push ${{ matrix.component }}
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ./${{ matrix.component }}
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/${{ env.REGISTRY_USER }}/${{ matrix.component }}:latest
|
||||
${{ env.REGISTRY }}/${{ env.REGISTRY_USER }}/${{ matrix.component }}:${{ steps.meta.outputs.version }}
|
||||
9
.gitignore
vendored
9
.gitignore
vendored
@@ -41,3 +41,12 @@ Thumbs.db
|
||||
|
||||
# Documentation temporaire
|
||||
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