Compare commits
37 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9ad7651c44 | |||
| 389392fd1d | |||
| aaebeaa547 | |||
| 03ee3855f5 | |||
| 94a39cf3b4 | |||
| efe6f6c2b0 | |||
| 73a9d15786 | |||
| dfe05cf2d2 | |||
| fcba907438 | |||
| 5739602702 | |||
| addf78f01d | |||
| 5e04e84ee4 | |||
| 8d5c2e2b7f | |||
| 788d2c12f2 | |||
| b25a9746cf | |||
| 41fda9aeee | |||
| 550078268c | |||
| 0582690dca | |||
| 88278bd1dd | |||
| d24d6459a0 | |||
| 4b866e5212 | |||
| 6c6bd20f0d | |||
| 2764228abf | |||
| f95d69c915 | |||
| 70351e9d9a | |||
| ff4905126d | |||
| 0e5b5a7de4 | |||
| c8c032336b | |||
| dda27e55fc | |||
| 83ac67471e | |||
| e3c8232e38 | |||
| a4df9fc759 | |||
| f1989c1d77 | |||
| 8efdf5d0e0 | |||
| 96bc5de942 | |||
| 84ccdd53ad | |||
| 29978058ee |
12
.env.example
12
.env.example
@@ -38,3 +38,15 @@ LLM_MODEL=gemma4:26b
|
|||||||
# 1min.ai (si LLM_PROVIDER=onemin)
|
# 1min.ai (si LLM_PROVIDER=onemin)
|
||||||
ONEMIN_API_KEY=
|
ONEMIN_API_KEY=
|
||||||
ONEMIN_MODEL=gpt-4o-mini
|
ONEMIN_MODEL=gpt-4o-mini
|
||||||
|
|
||||||
|
# --- Mises a jour automatiques (Watchtower) ------------------------------
|
||||||
|
# Watchtower verifie les nouvelles versions de core/brain/web et permet
|
||||||
|
# le declenchement manuel via l'UI (bouton "Mettre a jour"). Postgres et
|
||||||
|
# MinIO sont exclus volontairement.
|
||||||
|
#
|
||||||
|
# Activer : COMPOSE_PROFILES=autoupdate + WATCHTOWER_TOKEN non vide.
|
||||||
|
# COMPOSE_PROFILES=autoupdate
|
||||||
|
# WATCHTOWER_TOKEN=change-me-use-openssl-rand-hex-32
|
||||||
|
# WATCHTOWER_MONITOR_ONLY=false # true = detecter sans appliquer
|
||||||
|
# WATCHTOWER_SCHEDULE=0 0 4 * * *
|
||||||
|
# TZ=Europe/Paris
|
||||||
|
|||||||
95
.gitea/workflows/e2e.yml
Normal file
95
.gitea/workflows/e2e.yml
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
name: E2E Tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
e2e:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'npm'
|
||||||
|
cache-dependency-path: web/package-lock.json
|
||||||
|
|
||||||
|
- name: Create .env for stack
|
||||||
|
run: |
|
||||||
|
cat > .env <<'EOF'
|
||||||
|
POSTGRES_PASSWORD=ci-postgres-pass
|
||||||
|
BRAIN_INTERNAL_SECRET=ci-brain-secret
|
||||||
|
ADMIN_USERNAME=admin
|
||||||
|
ADMIN_PASSWORD=ci-admin-pass
|
||||||
|
WEB_PORT=8081
|
||||||
|
LLM_PROVIDER=ollama
|
||||||
|
EOF
|
||||||
|
|
||||||
|
- name: Build & start stack
|
||||||
|
run: |
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.e2e.yml up -d --build
|
||||||
|
|
||||||
|
- name: Attach runner to compose network
|
||||||
|
run: |
|
||||||
|
NET=$(docker network ls --format '{{.Name}}' | grep -E '(^|_)loremind(_|$)' | grep -i default | head -1)
|
||||||
|
if [ -z "$NET" ]; then
|
||||||
|
echo "Compose network not found" >&2
|
||||||
|
docker network ls
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Connecting $(hostname) to network $NET"
|
||||||
|
docker network connect "$NET" "$(hostname)"
|
||||||
|
|
||||||
|
- name: Wait for web to be ready
|
||||||
|
run: |
|
||||||
|
timeout 180 bash -c 'until curl -sf http://web/ > /dev/null; do echo "waiting..."; sleep 3; done'
|
||||||
|
|
||||||
|
- name: Install web deps
|
||||||
|
working-directory: web
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Work around runner clock skew for apt
|
||||||
|
run: |
|
||||||
|
sudo tee /etc/apt/apt.conf.d/99no-check-valid-until >/dev/null <<'EOF'
|
||||||
|
Acquire::Check-Valid-Until "false";
|
||||||
|
Acquire::Check-Date "false";
|
||||||
|
EOF
|
||||||
|
|
||||||
|
- name: Install Playwright browsers
|
||||||
|
working-directory: web
|
||||||
|
run: npx playwright install --with-deps chromium
|
||||||
|
|
||||||
|
- name: Run Playwright tests
|
||||||
|
working-directory: web
|
||||||
|
env:
|
||||||
|
E2E_BASE_URL: http://web
|
||||||
|
CI: 'true'
|
||||||
|
run: npm run e2e
|
||||||
|
|
||||||
|
- name: Dump container logs on failure
|
||||||
|
if: failure()
|
||||||
|
run: docker compose -f docker-compose.yml -f docker-compose.e2e.yml logs --no-color
|
||||||
|
|
||||||
|
- name: Upload Playwright report
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: playwright-report
|
||||||
|
path: web/playwright-report/
|
||||||
|
retention-days: 14
|
||||||
|
|
||||||
|
- name: Stop stack
|
||||||
|
if: always()
|
||||||
|
run: docker compose -f docker-compose.yml -f docker-compose.e2e.yml down -v
|
||||||
@@ -6,8 +6,10 @@ on:
|
|||||||
- 'v*'
|
- 'v*'
|
||||||
|
|
||||||
env:
|
env:
|
||||||
REGISTRY: git.igmlcreation.fr
|
GITEA_REGISTRY: git.igmlcreation.fr
|
||||||
REGISTRY_USER: ietm64
|
GITEA_REGISTRY_USER: ietm64
|
||||||
|
GHCR_REGISTRY: ghcr.io
|
||||||
|
GHCR_NAMESPACE: igmlcreation
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
@@ -26,19 +28,39 @@ jobs:
|
|||||||
- name: Login to Gitea Registry
|
- name: Login to Gitea Registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.REGISTRY }}
|
registry: ${{ env.GITEA_REGISTRY }}
|
||||||
username: ${{ env.REGISTRY_USER }}
|
username: ${{ env.GITEA_REGISTRY_USER }}
|
||||||
password: ${{ secrets.DOCKER_PAT }}
|
password: ${{ secrets.DOCKER_PAT }}
|
||||||
|
|
||||||
|
# Login to GHCR (GitHub Container Registry) pour distribuer les images
|
||||||
|
# publiquement aux utilisateurs finaux. Reputation domaine plus elevee
|
||||||
|
# que git.igmlcreation.fr (mieux pour les antivirus / SmartScreen).
|
||||||
|
- name: Login to GHCR
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.GHCR_REGISTRY }}
|
||||||
|
username: ${{ env.GHCR_NAMESPACE }}
|
||||||
|
password: ${{ secrets.GHCR_TOKEN }}
|
||||||
|
|
||||||
- name: Extract version
|
- name: Extract version
|
||||||
id: meta
|
id: meta
|
||||||
run: echo "version=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT
|
run: echo "version=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
# Push vers les deux registries en un seul build (build-push-action
|
||||||
|
# accepte une liste de tags ; aucun build supplementaire necessaire).
|
||||||
|
# Naming :
|
||||||
|
# - Gitea : conserve l'ancien pattern ietm64/<component> pour ne pas
|
||||||
|
# casser les installs existantes qui ont REGISTRY=git.igmlcreation.fr
|
||||||
|
# dans leur .env.
|
||||||
|
# - GHCR : nouveau pattern igmlcreation/loremind-<component> qui evite
|
||||||
|
# la collision avec d'autres projets de l'org.
|
||||||
- name: Build & push ${{ matrix.component }}
|
- name: Build & push ${{ matrix.component }}
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
context: ./${{ matrix.component }}
|
context: ./${{ matrix.component }}
|
||||||
push: true
|
push: true
|
||||||
tags: |
|
tags: |
|
||||||
${{ env.REGISTRY }}/${{ env.REGISTRY_USER }}/${{ matrix.component }}:latest
|
${{ env.GITEA_REGISTRY }}/${{ env.GITEA_REGISTRY_USER }}/${{ matrix.component }}:latest
|
||||||
${{ env.REGISTRY }}/${{ env.REGISTRY_USER }}/${{ matrix.component }}:${{ steps.meta.outputs.version }}
|
${{ env.GITEA_REGISTRY }}/${{ env.GITEA_REGISTRY_USER }}/${{ matrix.component }}:${{ steps.meta.outputs.version }}
|
||||||
|
${{ env.GHCR_REGISTRY }}/${{ env.GHCR_NAMESPACE }}/loremind-${{ matrix.component }}:latest
|
||||||
|
${{ env.GHCR_REGISTRY }}/${{ env.GHCR_NAMESPACE }}/loremind-${{ matrix.component }}:${{ steps.meta.outputs.version }}
|
||||||
|
|||||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -53,6 +53,12 @@ yarn-error.log*
|
|||||||
.pnpm-debug.log*
|
.pnpm-debug.log*
|
||||||
coverage/
|
coverage/
|
||||||
|
|
||||||
|
# Playwright (E2E)
|
||||||
|
web/test-results/
|
||||||
|
web/playwright-report/
|
||||||
|
web/blob-report/
|
||||||
|
web/playwright/.cache/
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# IDE / Editeurs
|
# IDE / Editeurs
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -85,6 +91,7 @@ Thumbs.db
|
|||||||
# Documentation hors-code (conservee hors du repo)
|
# Documentation hors-code (conservee hors du repo)
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
docs/
|
docs/
|
||||||
|
loremind-docs/
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Docker Compose override (dev uniquement, non-distribue aux end users)
|
# Docker Compose override (dev uniquement, non-distribue aux end users)
|
||||||
|
|||||||
311
INSTALL.md
311
INSTALL.md
@@ -1,311 +0,0 @@
|
|||||||
# Installation de LoreMindMJ
|
|
||||||
|
|
||||||
Ce document decrit la procedure d'installation de LoreMindMJ. Temps estime :
|
|
||||||
5 a 10 minutes selon la qualite de la connexion reseau.
|
|
||||||
|
|
||||||
## 1. 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). Verification :
|
|
||||||
```
|
|
||||||
docker --version
|
|
||||||
docker compose version
|
|
||||||
```
|
|
||||||
Compose v2 est requis : la commande est `docker compose`, non `docker-compose`.
|
|
||||||
|
|
||||||
- **Un fournisseur LLM**, au choix :
|
|
||||||
- **[Ollama](https://ollama.com/)** installe sur la machine hote (gratuit,
|
|
||||||
local, necessite environ 6 Go de RAM libre pour les modeles recommandes).
|
|
||||||
- **Une cle API [1min.ai](https://1min.ai)** (hebergement cloud, facturation
|
|
||||||
a l'usage, aucune installation supplementaire requise).
|
|
||||||
|
|
||||||
- Environ **2 Go d'espace disque** pour les images Docker, auxquels s'ajoute
|
|
||||||
la taille des modeles Ollama si l'option locale est retenue.
|
|
||||||
|
|
||||||
## 2. Recuperation des fichiers
|
|
||||||
|
|
||||||
Telecharger les deux fichiers suivants depuis la
|
|
||||||
[derniere release](https://git.igmlcreation.fr/ietm64/LoreMindMJ/releases) et
|
|
||||||
les placer dans un dossier dedie (par exemple `~/loremind/` ou
|
|
||||||
`C:\Programs\loremind\`) :
|
|
||||||
|
|
||||||
- `docker-compose.yml`
|
|
||||||
- `.env.example`
|
|
||||||
|
|
||||||
Le code source n'est pas necessaire : les images sont pre-construites et
|
|
||||||
publiees sur le registry Gitea `git.igmlcreation.fr` (non Docker Hub). Le
|
|
||||||
premier `docker compose pull` les telechargera automatiquement.
|
|
||||||
|
|
||||||
## 3. Configuration du fichier `.env`
|
|
||||||
|
|
||||||
Renommer `.env.example` en `.env` et l'ouvrir dans un editeur de texte. **Trois
|
|
||||||
variables sont obligatoires** ; sans elles, `docker compose up` refusera de
|
|
||||||
demarrer. Ce comportement est volontaire afin d'eviter tout deploiement
|
|
||||||
non-securise par defaut.
|
|
||||||
|
|
||||||
### `POSTGRES_PASSWORD`
|
|
||||||
|
|
||||||
Mot de passe de la base de donnees PostgreSQL. Choisir une valeur robuste.
|
|
||||||
Seuls les conteneurs utilisent cette valeur : il n'est pas necessaire de la
|
|
||||||
memoriser au-dela du fichier `.env`.
|
|
||||||
|
|
||||||
### `ADMIN_PASSWORD`
|
|
||||||
|
|
||||||
Protege l'ecran **Parametres** de l'application via HTTP Basic. Cette valeur
|
|
||||||
sera demandee par le navigateur lors de toute modification de la configuration
|
|
||||||
(changement de modele LLM, saisie de cle API, etc.). Le nom d'utilisateur par
|
|
||||||
defaut est `admin`, modifiable via la variable `ADMIN_USERNAME`.
|
|
||||||
|
|
||||||
### `BRAIN_INTERNAL_SECRET`
|
|
||||||
|
|
||||||
Secret partage entre le service Java (`core`) et le service Python (`brain`).
|
|
||||||
Empeche toute requete externe d'atteindre directement le service Brain.
|
|
||||||
Generer une valeur aleatoire de 64 caracteres hexadecimaux :
|
|
||||||
|
|
||||||
```
|
|
||||||
openssl rand -hex 32
|
|
||||||
```
|
|
||||||
|
|
||||||
Sous Windows sans `openssl`, utiliser PowerShell :
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
-join ((48..57) + (97..102) | Get-Random -Count 64 | % {[char]$_})
|
|
||||||
```
|
|
||||||
|
|
||||||
### Variables optionnelles
|
|
||||||
|
|
||||||
- `WEB_PORT` (defaut `8081`) : port d'ecoute de l'interface web.
|
|
||||||
- `ADMIN_USERNAME` (defaut `admin`) : identifiant de la popup Parametres.
|
|
||||||
- `LLM_PROVIDER` (defaut `ollama`) : choix du fournisseur LLM (voir
|
|
||||||
section 5).
|
|
||||||
|
|
||||||
Les autres variables (`MINIO_USER`/`MINIO_PASSWORD`, `POSTGRES_DB`,
|
|
||||||
`POSTGRES_USER`) disposent de valeurs par defaut adaptees a un deploiement
|
|
||||||
personnel et peuvent etre conservees en l'etat.
|
|
||||||
|
|
||||||
## 4. Lancement de la stack
|
|
||||||
|
|
||||||
Depuis le dossier contenant `docker-compose.yml` et `.env` :
|
|
||||||
|
|
||||||
```
|
|
||||||
docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
Le premier demarrage telecharge les images (environ 1 a 2 Go au total) et
|
|
||||||
initialise la base. Compter 2 a 5 minutes selon la qualite de la connexion.
|
|
||||||
La progression peut etre suivie via :
|
|
||||||
|
|
||||||
```
|
|
||||||
docker compose logs -f
|
|
||||||
```
|
|
||||||
|
|
||||||
(`Ctrl+C` pour quitter l'affichage ; les services continuent de fonctionner
|
|
||||||
en arriere-plan.)
|
|
||||||
|
|
||||||
Une fois les services en etat `healthy`, ouvrir **http://localhost:8081**
|
|
||||||
dans un navigateur.
|
|
||||||
|
|
||||||
### Verification du fonctionnement
|
|
||||||
|
|
||||||
```
|
|
||||||
docker compose ps
|
|
||||||
```
|
|
||||||
|
|
||||||
Cinq conteneurs doivent apparaitre en etat `Up` ou `healthy` :
|
|
||||||
`loremind-postgres`, `loremind-minio`, `loremind-core`, `loremind-brain`,
|
|
||||||
`loremind-web`. Le conteneur `loremind-minio-init` s'arrete automatiquement
|
|
||||||
apres creation du bucket d'images : ce comportement est normal.
|
|
||||||
|
|
||||||
## 5. Configuration du fournisseur LLM
|
|
||||||
|
|
||||||
### Ollama (local, gratuit)
|
|
||||||
|
|
||||||
Installer Ollama sur la machine hote (pas dans Docker), puis telecharger un
|
|
||||||
modele :
|
|
||||||
|
|
||||||
```
|
|
||||||
ollama pull gemma4:26b
|
|
||||||
```
|
|
||||||
|
|
||||||
Dans `.env` :
|
|
||||||
|
|
||||||
```
|
|
||||||
LLM_PROVIDER=ollama
|
|
||||||
LLM_MODEL=gemma4:26b
|
|
||||||
OLLAMA_BASE_URL=http://host.docker.internal:11434
|
|
||||||
```
|
|
||||||
|
|
||||||
L'adresse `host.docker.internal` permet au conteneur `brain` d'atteindre
|
|
||||||
Ollama sur la machine hote. Cette resolution est native sous Docker Desktop
|
|
||||||
(Mac / Windows). Sous Linux, le fichier `docker-compose.yml` declare un
|
|
||||||
`extra_hosts` equivalent.
|
|
||||||
|
|
||||||
### 1min.ai (cloud, paye)
|
|
||||||
|
|
||||||
Dans `.env` :
|
|
||||||
|
|
||||||
```
|
|
||||||
LLM_PROVIDER=onemin
|
|
||||||
ONEMIN_API_KEY=sk-...
|
|
||||||
ONEMIN_MODEL=gpt-4o-mini
|
|
||||||
```
|
|
||||||
|
|
||||||
### Modification a chaud
|
|
||||||
|
|
||||||
Le fournisseur, le modele et la cle API peuvent etre modifies a chaud depuis
|
|
||||||
l'ecran **Parametres** de l'application. Les modifications sont persistees
|
|
||||||
dans un volume Docker et survivent aux redemarrages. Les variables d'env du
|
|
||||||
fichier `.env` sont uniquement utilisees comme valeurs initiales au premier
|
|
||||||
demarrage.
|
|
||||||
|
|
||||||
## 6. Mise a jour
|
|
||||||
|
|
||||||
```
|
|
||||||
docker compose pull
|
|
||||||
docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
Les donnees (base PostgreSQL, images MinIO, configuration Brain) sont
|
|
||||||
stockees dans des volumes Docker et survivent aux mises a jour.
|
|
||||||
|
|
||||||
## 7. Sauvegarde
|
|
||||||
|
|
||||||
Les donnees sont reparties dans trois volumes Docker :
|
|
||||||
|
|
||||||
- `loremindmj_postgres-data` — ensemble des donnees applicatives (lores,
|
|
||||||
campagnes, pages, templates, branches narratives, etc.).
|
|
||||||
- `loremindmj_minio-data` — images uploadees.
|
|
||||||
- `loremindmj_brain-data` — parametres IA (fournisseur courant, cle API
|
|
||||||
1min.ai).
|
|
||||||
|
|
||||||
### Export SQL de la base
|
|
||||||
|
|
||||||
```
|
|
||||||
docker compose exec postgres pg_dump -U loremind loremind > backup.sql
|
|
||||||
```
|
|
||||||
|
|
||||||
### Sauvegarde complete des volumes
|
|
||||||
|
|
||||||
Arreter la stack au prealable afin de garantir la coherence des donnees :
|
|
||||||
|
|
||||||
```
|
|
||||||
docker compose stop
|
|
||||||
docker run --rm -v loremindmj_postgres-data:/data -v $(pwd):/backup alpine tar czf /backup/postgres-data.tar.gz -C /data .
|
|
||||||
docker run --rm -v loremindmj_minio-data:/data -v $(pwd):/backup alpine tar czf /backup/minio-data.tar.gz -C /data .
|
|
||||||
docker compose start
|
|
||||||
```
|
|
||||||
|
|
||||||
Sous Windows PowerShell, remplacer `$(pwd)` par `${PWD}`.
|
|
||||||
|
|
||||||
## 8. Resolution des problemes
|
|
||||||
|
|
||||||
### Port 8081 deja utilise
|
|
||||||
|
|
||||||
Modifier `WEB_PORT=8082` (ou toute autre valeur libre) dans `.env`, puis
|
|
||||||
relancer :
|
|
||||||
|
|
||||||
```
|
|
||||||
docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
### Erreur "set POSTGRES_PASSWORD in .env" (ou variable equivalente) au lancement
|
|
||||||
|
|
||||||
Une des trois variables obligatoires de l'etape 3 est manquante. Verifier le
|
|
||||||
contenu du fichier `.env`.
|
|
||||||
|
|
||||||
### Popup "Ce site vous demande de vous connecter" sur l'ecran Parametres
|
|
||||||
|
|
||||||
Comportement attendu : il s'agit de l'authentification HTTP Basic. Utiliser
|
|
||||||
la valeur de `ADMIN_USERNAME` (par defaut `admin`) et celle de
|
|
||||||
`ADMIN_PASSWORD`.
|
|
||||||
|
|
||||||
### Erreurs `password authentication failed` en boucle dans les logs Postgres
|
|
||||||
|
|
||||||
Si la variable `POSTGRES_PASSWORD` a ete modifiee apres un premier lancement,
|
|
||||||
le volume Postgres conserve l'ancien mot de passe (initialise une seule fois).
|
|
||||||
Deux options :
|
|
||||||
|
|
||||||
- **Redemarrer avec un volume vierge** (entraine la perte des donnees) :
|
|
||||||
```
|
|
||||||
docker compose down -v
|
|
||||||
docker compose up -d
|
|
||||||
```
|
|
||||||
- **Modifier le mot de passe en base** sans toucher au volume :
|
|
||||||
```
|
|
||||||
docker compose exec postgres psql -U postgres
|
|
||||||
```
|
|
||||||
Puis dans le prompt `psql` :
|
|
||||||
```sql
|
|
||||||
ALTER USER loremind WITH PASSWORD 'valeur_exacte_du_env';
|
|
||||||
\q
|
|
||||||
```
|
|
||||||
Redemarrer ensuite le Core : `docker compose restart core`.
|
|
||||||
|
|
||||||
### Erreur "502 Bad Gateway" ou message d'erreur IA dans l'interface
|
|
||||||
|
|
||||||
Le service Brain ne parvient pas a contacter le fournisseur LLM. Verifier :
|
|
||||||
|
|
||||||
- **Ollama** : `ollama serve` est-il actif ? Le modele est-il telecharge
|
|
||||||
(`ollama list`) ? La valeur de `LLM_MODEL` correspond-elle exactement au
|
|
||||||
nom d'un modele liste ?
|
|
||||||
- **1min.ai** : la cle API est-elle valide ? Le modele existe-t-il ?
|
|
||||||
- Consulter les logs du Brain :
|
|
||||||
```
|
|
||||||
docker compose logs brain
|
|
||||||
```
|
|
||||||
|
|
||||||
### Un service ne demarre pas ou reste en etat `unhealthy`
|
|
||||||
|
|
||||||
Consulter les logs du service concerne :
|
|
||||||
|
|
||||||
```
|
|
||||||
docker compose logs <service>
|
|
||||||
```
|
|
||||||
|
|
||||||
Services disponibles : `postgres`, `minio`, `core`, `brain`, `web`.
|
|
||||||
|
|
||||||
### Redemarrage d'un service apres modification du `.env`
|
|
||||||
|
|
||||||
```
|
|
||||||
docker compose up -d <service>
|
|
||||||
```
|
|
||||||
|
|
||||||
Redemarrage complet : `docker compose restart`.
|
|
||||||
|
|
||||||
### Remise a zero complete (PERTE DES DONNEES)
|
|
||||||
|
|
||||||
```
|
|
||||||
docker compose down -v
|
|
||||||
```
|
|
||||||
|
|
||||||
L'option `-v` supprime les volumes. L'ensemble des lores, campagnes, images
|
|
||||||
et parametres est perdu de maniere definitive.
|
|
||||||
|
|
||||||
### "No such image" ou "pull access denied" au premier lancement
|
|
||||||
|
|
||||||
Le registry Gitea peut necessiter une authentification selon la visibilite
|
|
||||||
configuree pour les images. Contacter l'editeur du projet.
|
|
||||||
|
|
||||||
## 9. Exposition reseau des services
|
|
||||||
|
|
||||||
- **Interface web** : http://localhost:8081 (port configurable via
|
|
||||||
`WEB_PORT`).
|
|
||||||
- **PostgreSQL** : accessible uniquement via le reseau Docker interne, non
|
|
||||||
expose vers l'hote.
|
|
||||||
- **MinIO** : accessible uniquement via le reseau Docker interne. Les images
|
|
||||||
transitent par le reverse-proxy Java sur `/api/images/{id}/content`. Le
|
|
||||||
binding `127.0.0.1:9000/9001` defini dans `docker-compose.override.yml`
|
|
||||||
n'est actif qu'en developpement.
|
|
||||||
- **Brain Python** : accessible uniquement via le reseau Docker interne.
|
|
||||||
Toute requete doit porter l'en-tete `X-Internal-Secret`, injectee
|
|
||||||
automatiquement par le Core Java et jamais exposee au navigateur.
|
|
||||||
|
|
||||||
## 10. Desinstallation
|
|
||||||
|
|
||||||
```
|
|
||||||
docker compose down -v
|
|
||||||
docker image rm git.igmlcreation.fr/ietm64/core git.igmlcreation.fr/ietm64/brain git.igmlcreation.fr/ietm64/web
|
|
||||||
```
|
|
||||||
|
|
||||||
Supprimer ensuite le dossier contenant `docker-compose.yml` et `.env`.
|
|
||||||
70
README.md
70
README.md
@@ -1,69 +1,31 @@
|
|||||||
# LoreMind
|
# 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é.
|

|
||||||
|
|
||||||
|
Loremind est une application web angular auto-hébergable afin de venir en aide aux Maîtres de jeu qui souhaitent centraliser leur univers et leurs campagnes.
|
||||||
|
Cette dernière intègre un moteur IA qui va ingérer le contenu du lore et de la campagne afin de pouvoir répondre à des questions précises sur l'univers ou la campagne, mais également proposer des idées de création dans le contexte de la campagne et du lore.
|
||||||
|
Pour le moment seul Ollama est supporté pour la partie locale, il y-a également une intégration pour 1min.ai. Plus tard, d'autres moteurs seront supportés.
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
La documentation complète est accessible sur le site [loremind-docs](https://loremind-docs.igmlcreation.fr/)
|
||||||
|
|
||||||
|
Pour l'installation, consultez le guide dans cette dernière .
|
||||||
|
|
||||||
## Fonctionnalités
|
## Fonctionnalités
|
||||||
|
|
||||||
- Gestion centralisée du Lore : Lieux, Factions, PNJ, et tous les éléments de votre univers
|
- Gestion centralisée du Lore : Lieux, Factions, PNJ, et tous les éléments de votre univers
|
||||||
- Suivi de campagnes : Sessions, actions des joueurs, chronologie
|
- 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
|
- 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
|
## Démo
|
||||||
|
|
||||||
### Page d'accueil
|
Une démo est disponible sur le site [loremind-demo](https://loremind-demo.igmlcreation.fr/)
|
||||||

|
|
||||||
|
|
||||||
### Recherche
|
!! Attention, la démo est uniquement accessible à 10 personnes à la fois (instances personnalisées). Cette limite est mise en place pour éviter l'overhead sur les ressources serveur.
|
||||||

|
|
||||||
|
|
||||||
## Stack Technologique
|
Cette dernière est utilisable 20 minutes maximum par session avant d'être réinitialiser.
|
||||||
|
Vous comprendrez également qu'elle ne contient pas de démo pour la partie IA, pour laquelle il faut configurer un serveur Ollama (et qui ferait donc exploser le serveur) ou utiliser 1min.ai.
|
||||||
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
|
|
||||||
|
|
||||||
Pour installer LoreMind chez vous (Docker requis), suivez le guide **[INSTALL.md](INSTALL.md)** — 3 étapes, 5 minutes chrono :
|
|
||||||
|
|
||||||
1. Télécharger `docker-compose.yml` + `.env.example` depuis la [dernière release](https://git.igmlcreation.fr/ietm64/LoreMindMJ/releases)
|
|
||||||
2. Renommer `.env.example` → `.env` et changer `POSTGRES_PASSWORD`
|
|
||||||
3. `docker compose up -d` → ouvrir http://localhost:8081
|
|
||||||
|
|
||||||
Mise à jour : `docker compose pull && docker compose up -d`.
|
|
||||||
|
|
||||||
## Développement (contributeurs)
|
|
||||||
|
|
||||||
Pour builder les images localement depuis les sources :
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://git.igmlcreation.fr/ietm64/LoreMindMJ.git
|
|
||||||
cd LoreMindMJ
|
|
||||||
# Créer un docker-compose.override.yml local (voir docs de contrib)
|
|
||||||
docker compose up -d --build
|
|
||||||
```
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ from app.domain.models import (
|
|||||||
ChatMessage,
|
ChatMessage,
|
||||||
ChapterSummary,
|
ChapterSummary,
|
||||||
CharacterSummary,
|
CharacterSummary,
|
||||||
|
NpcSummary,
|
||||||
GameSystemContext,
|
GameSystemContext,
|
||||||
LoreStructuralContext,
|
LoreStructuralContext,
|
||||||
NarrativeEntityContext,
|
NarrativeEntityContext,
|
||||||
@@ -198,10 +199,12 @@ class ChatUseCase:
|
|||||||
else "\n(Cette campagne n'est associée à aucun univers — tu peux proposer des éléments d'ambiance libres.)"
|
else "\n(Cette campagne n'est associée à aucun univers — tu peux proposer des éléments d'ambiance libres.)"
|
||||||
)
|
)
|
||||||
characters_block = ChatUseCase._format_characters(ctx.characters)
|
characters_block = ChatUseCase._format_characters(ctx.characters)
|
||||||
|
npcs_block = ChatUseCase._format_npcs(ctx.npcs)
|
||||||
return (
|
return (
|
||||||
"--- CAMPAGNE COURANTE ---\n"
|
"--- CAMPAGNE COURANTE ---\n"
|
||||||
f"Nom : {ctx.campaign_name}{desc}{lore_note}\n"
|
f"Nom : {ctx.campaign_name}{desc}{lore_note}\n"
|
||||||
f"{characters_block}\n"
|
f"{characters_block}"
|
||||||
|
f"{npcs_block}\n"
|
||||||
"Structure narrative (les flèches → indiquent des transitions de scène "
|
"Structure narrative (les flèches → indiquent des transitions de scène "
|
||||||
"déclenchées par un choix des joueurs) :\n"
|
"déclenchées par un choix des joueurs) :\n"
|
||||||
f"{arcs_block}"
|
f"{arcs_block}"
|
||||||
@@ -231,6 +234,33 @@ class ChatUseCase:
|
|||||||
)
|
)
|
||||||
return "\n".join(lines) + "\n"
|
return "\n".join(lines) + "\n"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _format_npcs(npcs: list[NpcSummary]) -> str:
|
||||||
|
"""Bloc PNJ — symétrique aux PJ avec sa propre instruction anti-halluci.
|
||||||
|
|
||||||
|
Distinction importante : pour les PNJ, l'IA est ENCOURAGÉE à proposer de
|
||||||
|
nouveaux PNJ (création créative = OK). En revanche, elle ne doit pas
|
||||||
|
référencer comme existant un PNJ qui n'est pas dans la liste ci-dessous.
|
||||||
|
"""
|
||||||
|
if not npcs:
|
||||||
|
return (
|
||||||
|
"\nPersonnages non-joueurs (PNJ) : aucun défini pour l'instant. "
|
||||||
|
"Tu peux librement proposer de nouveaux PNJ au MJ, mais ne "
|
||||||
|
"fais pas comme s'ils existaient déjà dans la campagne.\n"
|
||||||
|
)
|
||||||
|
lines = ["\nPersonnages non-joueurs (PNJ) connus :"]
|
||||||
|
for n in npcs:
|
||||||
|
if n.snippet:
|
||||||
|
lines.append(f"- **{n.name}** — {n.snippet}")
|
||||||
|
else:
|
||||||
|
lines.append(f"- **{n.name}** (fiche vide)")
|
||||||
|
lines.append(
|
||||||
|
"Pour une fiche complète d'un PNJ existant (apparence, motivations), "
|
||||||
|
"n'invente rien : demande au MJ d'ouvrir l'éditeur du PNJ. Tu peux "
|
||||||
|
"en revanche proposer librement de NOUVEAUX PNJ."
|
||||||
|
)
|
||||||
|
return "\n".join(lines) + "\n"
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _format_arcs(arcs: list[ArcSummary]) -> str:
|
def _format_arcs(arcs: list[ArcSummary]) -> str:
|
||||||
if not arcs:
|
if not arcs:
|
||||||
@@ -319,7 +349,8 @@ class ChatUseCase:
|
|||||||
"arc": "ARC",
|
"arc": "ARC",
|
||||||
"chapter": "CHAPITRE",
|
"chapter": "CHAPITRE",
|
||||||
"scene": "SCÈNE",
|
"scene": "SCÈNE",
|
||||||
"character": "FICHE DE PERSONNAGE",
|
"character": "FICHE DE PERSONNAGE (PJ)",
|
||||||
|
"npc": "FICHE DE PNJ",
|
||||||
}.get(ne.entity_type.lower(), ne.entity_type.upper())
|
}.get(ne.entity_type.lower(), ne.entity_type.upper())
|
||||||
if ne.fields:
|
if ne.fields:
|
||||||
fields_block = "\n".join(
|
fields_block = "\n".join(
|
||||||
|
|||||||
@@ -170,6 +170,7 @@ class CampaignStructuralContext:
|
|||||||
campaign_description: str | None
|
campaign_description: str | None
|
||||||
arcs: list[ArcSummary]
|
arcs: list[ArcSummary]
|
||||||
characters: list["CharacterSummary"] = field(default_factory=list)
|
characters: list["CharacterSummary"] = field(default_factory=list)
|
||||||
|
npcs: list["NpcSummary"] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -185,6 +186,19 @@ class CharacterSummary:
|
|||||||
snippet: str
|
snippet: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class NpcSummary:
|
||||||
|
"""Résumé d'un PNJ : symétrique à CharacterSummary.
|
||||||
|
|
||||||
|
Permet à l'IA de connaître les PNJ d'une campagne (nom + snippet) sans
|
||||||
|
injecter leurs fiches complètes. Évolution prévue : entity_type="npc"
|
||||||
|
pour focus sur la fiche complète.
|
||||||
|
"""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
snippet: str
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class NarrativeEntityContext:
|
class NarrativeEntityContext:
|
||||||
"""Contexte d'une entité narrative précise en cours d'édition.
|
"""Contexte d'une entité narrative précise en cours d'édition.
|
||||||
|
|||||||
@@ -61,7 +61,16 @@ class OllamaLLMProvider:
|
|||||||
async with httpx.AsyncClient(timeout=self._timeout) as client:
|
async with httpx.AsyncClient(timeout=self._timeout) as client:
|
||||||
try:
|
try:
|
||||||
response = await client.post(url, json=payload)
|
response = await client.post(url, json=payload)
|
||||||
response.raise_for_status()
|
if response.status_code >= 400:
|
||||||
|
body = response.text
|
||||||
|
try:
|
||||||
|
err_obj = json.loads(body)
|
||||||
|
err_msg = err_obj.get("error") or body
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
err_msg = body
|
||||||
|
raise LLMProviderError(
|
||||||
|
f"Ollama HTTP {response.status_code} : {err_msg.strip()[:500]}"
|
||||||
|
)
|
||||||
except httpx.HTTPError as exc:
|
except httpx.HTTPError as exc:
|
||||||
raise LLMProviderError(
|
raise LLMProviderError(
|
||||||
f"Erreur lors de l'appel à Ollama : {exc}"
|
f"Erreur lors de l'appel à Ollama : {exc}"
|
||||||
@@ -105,7 +114,20 @@ class OllamaLLMProvider:
|
|||||||
async with httpx.AsyncClient(timeout=self._timeout) as client:
|
async with httpx.AsyncClient(timeout=self._timeout) as client:
|
||||||
try:
|
try:
|
||||||
async with client.stream("POST", url, json=payload) as response:
|
async with client.stream("POST", url, json=payload) as response:
|
||||||
response.raise_for_status()
|
if response.status_code >= 400:
|
||||||
|
# On lit le body d'erreur pour le remonter a l'utilisateur,
|
||||||
|
# sinon on ne voit que "500 Internal Server Error" sans
|
||||||
|
# savoir POURQUOI Ollama refuse (modele introuvable, OOM,
|
||||||
|
# num_ctx trop grand pour la VRAM, etc.).
|
||||||
|
body = (await response.aread()).decode("utf-8", errors="replace")
|
||||||
|
try:
|
||||||
|
err_obj = json.loads(body)
|
||||||
|
err_msg = err_obj.get("error") or body
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
err_msg = body
|
||||||
|
raise LLMProviderError(
|
||||||
|
f"Ollama HTTP {response.status_code} : {err_msg.strip()[:500]}"
|
||||||
|
)
|
||||||
async for line in response.aiter_lines():
|
async for line in response.aiter_lines():
|
||||||
if not line.strip():
|
if not line.strip():
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ from app.domain.models import (
|
|||||||
CampaignStructuralContext,
|
CampaignStructuralContext,
|
||||||
ChapterSummary,
|
ChapterSummary,
|
||||||
CharacterSummary,
|
CharacterSummary,
|
||||||
|
NpcSummary,
|
||||||
ChatMessage,
|
ChatMessage,
|
||||||
GameSystemContext,
|
GameSystemContext,
|
||||||
LoreStructuralContext,
|
LoreStructuralContext,
|
||||||
@@ -40,7 +41,7 @@ from app.infrastructure.onemin_adapter import OneMinAiLLMProvider
|
|||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="LoreMind Brain",
|
title="LoreMind Brain",
|
||||||
description="Backend IA pour la génération de contenu narratif.",
|
description="Backend IA pour la génération de contenu narratif.",
|
||||||
version="0.5.0",
|
version="0.6.6",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -205,6 +206,13 @@ class CharacterSummaryDTO(BaseModel):
|
|||||||
snippet: str = ""
|
snippet: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class NpcSummaryDTO(BaseModel):
|
||||||
|
"""Résumé d'un PNJ : symétrique à CharacterSummaryDTO."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
snippet: str = ""
|
||||||
|
|
||||||
|
|
||||||
class CampaignContextDTO(BaseModel):
|
class CampaignContextDTO(BaseModel):
|
||||||
"""Carte narrative enrichie : arcs → chapitres → scènes avec synopsis."""
|
"""Carte narrative enrichie : arcs → chapitres → scènes avec synopsis."""
|
||||||
|
|
||||||
@@ -212,12 +220,13 @@ class CampaignContextDTO(BaseModel):
|
|||||||
campaign_description: str | None = None
|
campaign_description: str | None = None
|
||||||
arcs: list[ArcSummaryDTO] = Field(default_factory=list)
|
arcs: list[ArcSummaryDTO] = Field(default_factory=list)
|
||||||
characters: list[CharacterSummaryDTO] = Field(default_factory=list)
|
characters: list[CharacterSummaryDTO] = Field(default_factory=list)
|
||||||
|
npcs: list[NpcSummaryDTO] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class NarrativeEntityDTO(BaseModel):
|
class NarrativeEntityDTO(BaseModel):
|
||||||
"""Entité narrative (arc/chapter/scene/character) en cours d'édition — focus optionnel."""
|
"""Entité narrative (arc/chapter/scene/character) en cours d'édition — focus optionnel."""
|
||||||
|
|
||||||
entity_type: str = Field(pattern="^(arc|chapter|scene|character)$")
|
entity_type: str = Field(pattern="^(arc|chapter|scene|character|npc)$")
|
||||||
title: str
|
title: str
|
||||||
fields: dict[str, str] = Field(default_factory=dict)
|
fields: dict[str, str] = Field(default_factory=dict)
|
||||||
|
|
||||||
@@ -553,11 +562,16 @@ def _to_campaign_context(dto: CampaignContextDTO | None) -> CampaignStructuralCo
|
|||||||
CharacterSummary(name=c.name, snippet=c.snippet)
|
CharacterSummary(name=c.name, snippet=c.snippet)
|
||||||
for c in dto.characters
|
for c in dto.characters
|
||||||
]
|
]
|
||||||
|
npcs = [
|
||||||
|
NpcSummary(name=n.name, snippet=n.snippet)
|
||||||
|
for n in dto.npcs
|
||||||
|
]
|
||||||
return CampaignStructuralContext(
|
return CampaignStructuralContext(
|
||||||
campaign_name=dto.campaign_name,
|
campaign_name=dto.campaign_name,
|
||||||
campaign_description=dto.campaign_description,
|
campaign_description=dto.campaign_description,
|
||||||
arcs=arcs,
|
arcs=arcs,
|
||||||
characters=characters,
|
characters=characters,
|
||||||
|
npcs=npcs,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -689,6 +703,76 @@ async def get_ollama_model_info(
|
|||||||
return OllamaModelInfoDTO(context_length=0)
|
return OllamaModelInfoDTO(context_length=0)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/models/ollama/pull")
|
||||||
|
async def pull_ollama_model(
|
||||||
|
body: dict[str, str],
|
||||||
|
settings: Annotated[Settings, Depends(get_settings)],
|
||||||
|
) -> StreamingResponse:
|
||||||
|
"""Telecharge un modele depuis Ollama et streame la progression.
|
||||||
|
|
||||||
|
Proxifie l'endpoint `/api/pull` d'Ollama qui renvoie du JSON ligne par
|
||||||
|
ligne (NDJSON) avec le statut de chaque etape : manifest, layers,
|
||||||
|
digest, success. On reemet ce flux tel quel au client (le front
|
||||||
|
parsera les lignes et affichera une barre de progression).
|
||||||
|
|
||||||
|
Le timeout est intentionnellement tres long (60 min) car certains
|
||||||
|
modeles font 30+ Go.
|
||||||
|
"""
|
||||||
|
name = (body.get("name") or "").strip()
|
||||||
|
if not name:
|
||||||
|
raise HTTPException(status_code=400, detail="name requis")
|
||||||
|
url = f"{settings.ollama_base_url}/api/pull"
|
||||||
|
|
||||||
|
async def stream() -> AsyncIterator[bytes]:
|
||||||
|
# On utilise un timeout long pour la lecture (60 min) mais court pour
|
||||||
|
# la connexion (10s) — si Ollama n'est pas joignable, on echoue vite.
|
||||||
|
timeout = httpx.Timeout(connect=10, read=3600, write=10, pool=10)
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||||
|
async with client.stream("POST", url, json={"model": name, "stream": True}) as r:
|
||||||
|
if r.status_code != 200:
|
||||||
|
# Ollama renvoie un message JSON d'erreur. On le passe
|
||||||
|
# tel quel au client en preservant le code HTTP.
|
||||||
|
body_text = await r.aread()
|
||||||
|
yield body_text
|
||||||
|
return
|
||||||
|
async for chunk in r.aiter_bytes():
|
||||||
|
yield chunk
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
# Erreur reseau : on emet une ligne JSON d'erreur compatible
|
||||||
|
# avec le format NDJSON d'Ollama.
|
||||||
|
err = json.dumps({"error": f"Connexion a Ollama impossible : {e}"}) + "\n"
|
||||||
|
yield err.encode("utf-8")
|
||||||
|
|
||||||
|
# application/x-ndjson : un objet JSON par ligne, pas de wrapping SSE.
|
||||||
|
# C'est le format natif d'Ollama, le front le parsera ligne par ligne.
|
||||||
|
return StreamingResponse(stream(), media_type="application/x-ndjson")
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/models/ollama/{name:path}")
|
||||||
|
async def delete_ollama_model(
|
||||||
|
name: str,
|
||||||
|
settings: Annotated[Settings, Depends(get_settings)],
|
||||||
|
) -> dict[str, str]:
|
||||||
|
"""Supprime un modele du serveur Ollama.
|
||||||
|
|
||||||
|
Le `:path` dans le pattern autorise les `:` du nom (ex: `gemma4:e4b`)
|
||||||
|
sans avoir besoin de URL-encoder cote client.
|
||||||
|
"""
|
||||||
|
if not name.strip():
|
||||||
|
raise HTTPException(status_code=400, detail="name requis")
|
||||||
|
url = f"{settings.ollama_base_url}/api/delete"
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10) as client:
|
||||||
|
response = await client.request("DELETE", url, json={"model": name})
|
||||||
|
if response.status_code == 404:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Modele '{name}' introuvable")
|
||||||
|
response.raise_for_status()
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
raise HTTPException(status_code=502, detail=f"Ollama injoignable : {e}")
|
||||||
|
return {"status": "deleted", "name": name}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/models/onemin")
|
@app.get("/models/onemin")
|
||||||
def list_onemin_models() -> dict[str, list[dict[str, object]]]:
|
def list_onemin_models() -> dict[str, list[dict[str, object]]]:
|
||||||
"""Catalogue statique des modeles 1min.ai, groupes par fournisseur.
|
"""Catalogue statique des modeles 1min.ai, groupes par fournisseur.
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
<groupId>com.loremind</groupId>
|
<groupId>com.loremind</groupId>
|
||||||
<artifactId>loremind-core</artifactId>
|
<artifactId>loremind-core</artifactId>
|
||||||
<version>0.5.0</version>
|
<version>0.7.0</version>
|
||||||
<name>LoreMind Core</name>
|
<name>LoreMind Core</name>
|
||||||
<description>Backend Core - Architecture Hexagonale</description>
|
<description>Backend Core - Architecture Hexagonale</description>
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
package com.loremind.application.campaigncontext;
|
package com.loremind.application.campaigncontext;
|
||||||
|
|
||||||
import com.loremind.domain.campaigncontext.Arc;
|
import com.loremind.domain.campaigncontext.Arc;
|
||||||
|
import com.loremind.domain.campaigncontext.Chapter;
|
||||||
import com.loremind.domain.campaigncontext.ports.ArcRepository;
|
import com.loremind.domain.campaigncontext.ports.ArcRepository;
|
||||||
|
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
|
||||||
|
import com.loremind.domain.campaigncontext.ports.SceneRepository;
|
||||||
import org.springframework.beans.BeanUtils;
|
import org.springframework.beans.BeanUtils;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
@@ -17,17 +21,31 @@ import java.util.Optional;
|
|||||||
public class ArcService {
|
public class ArcService {
|
||||||
|
|
||||||
private final ArcRepository arcRepository;
|
private final ArcRepository arcRepository;
|
||||||
|
private final ChapterRepository chapterRepository;
|
||||||
|
private final SceneRepository sceneRepository;
|
||||||
|
|
||||||
public ArcService(ArcRepository arcRepository) {
|
public ArcService(ArcRepository arcRepository,
|
||||||
|
ChapterRepository chapterRepository,
|
||||||
|
SceneRepository sceneRepository) {
|
||||||
this.arcRepository = arcRepository;
|
this.arcRepository = arcRepository;
|
||||||
|
this.chapterRepository = chapterRepository;
|
||||||
|
this.sceneRepository = sceneRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Compte des entités qui seront supprimées en cascade avec l'arc. */
|
||||||
|
public record DeletionImpact(int chapters, int scenes) {}
|
||||||
|
|
||||||
public Arc createArc(String name, String description, String campaignId, int order) {
|
public Arc createArc(String name, String description, String campaignId, int order) {
|
||||||
|
return createArc(name, description, campaignId, order, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Arc createArc(String name, String description, String campaignId, int order, String icon) {
|
||||||
Arc arc = Arc.builder()
|
Arc arc = Arc.builder()
|
||||||
.name(name)
|
.name(name)
|
||||||
.description(description)
|
.description(description)
|
||||||
.campaignId(campaignId)
|
.campaignId(campaignId)
|
||||||
.order(order)
|
.order(order)
|
||||||
|
.icon(icon)
|
||||||
.build();
|
.build();
|
||||||
return arcRepository.save(arc);
|
return arcRepository.save(arc);
|
||||||
}
|
}
|
||||||
@@ -59,7 +77,31 @@ public class ArcService {
|
|||||||
return arcRepository.save(arc);
|
return arcRepository.save(arc);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calcule l'impact d'une suppression en cascade : chapitres + scènes
|
||||||
|
* qui disparaîtront avec l'arc.
|
||||||
|
*/
|
||||||
|
public DeletionImpact getDeletionImpact(String id) {
|
||||||
|
List<Chapter> chapters = chapterRepository.findByArcId(id);
|
||||||
|
int sceneTotal = 0;
|
||||||
|
for (Chapter chapter : chapters) {
|
||||||
|
sceneTotal += sceneRepository.findByChapterId(chapter.getId()).size();
|
||||||
|
}
|
||||||
|
return new DeletionImpact(chapters.size(), sceneTotal);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supprime l'arc et toutes ses entités dépendantes (chapitres → scènes).
|
||||||
|
* Transactionnel : atomique.
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
public void deleteArc(String id) {
|
public void deleteArc(String id) {
|
||||||
|
for (Chapter chapter : chapterRepository.findByArcId(id)) {
|
||||||
|
for (var scene : sceneRepository.findByChapterId(chapter.getId())) {
|
||||||
|
sceneRepository.deleteById(scene.getId());
|
||||||
|
}
|
||||||
|
chapterRepository.deleteById(chapter.getId());
|
||||||
|
}
|
||||||
arcRepository.deleteById(id);
|
arcRepository.deleteById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,15 @@
|
|||||||
package com.loremind.application.campaigncontext;
|
package com.loremind.application.campaigncontext;
|
||||||
|
|
||||||
|
import com.loremind.domain.campaigncontext.Arc;
|
||||||
import com.loremind.domain.campaigncontext.Campaign;
|
import com.loremind.domain.campaigncontext.Campaign;
|
||||||
|
import com.loremind.domain.campaigncontext.Chapter;
|
||||||
|
import com.loremind.domain.campaigncontext.ports.ArcRepository;
|
||||||
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
|
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
|
||||||
|
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
|
||||||
|
import com.loremind.domain.campaigncontext.ports.CharacterRepository;
|
||||||
|
import com.loremind.domain.campaigncontext.ports.SceneRepository;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
@@ -16,9 +23,22 @@ import java.util.Optional;
|
|||||||
public class CampaignService {
|
public class CampaignService {
|
||||||
|
|
||||||
private final CampaignRepository campaignRepository;
|
private final CampaignRepository campaignRepository;
|
||||||
|
private final ArcRepository arcRepository;
|
||||||
|
private final ChapterRepository chapterRepository;
|
||||||
|
private final SceneRepository sceneRepository;
|
||||||
|
private final CharacterRepository characterRepository;
|
||||||
|
|
||||||
public CampaignService(CampaignRepository campaignRepository) {
|
public CampaignService(
|
||||||
|
CampaignRepository campaignRepository,
|
||||||
|
ArcRepository arcRepository,
|
||||||
|
ChapterRepository chapterRepository,
|
||||||
|
SceneRepository sceneRepository,
|
||||||
|
CharacterRepository characterRepository) {
|
||||||
this.campaignRepository = campaignRepository;
|
this.campaignRepository = campaignRepository;
|
||||||
|
this.arcRepository = arcRepository;
|
||||||
|
this.chapterRepository = chapterRepository;
|
||||||
|
this.sceneRepository = sceneRepository;
|
||||||
|
this.characterRepository = characterRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -30,6 +50,12 @@ public class CampaignService {
|
|||||||
*/
|
*/
|
||||||
public record CampaignData(String name, String description, String loreId, String gameSystemId) {}
|
public record CampaignData(String name, String description, String loreId, String gameSystemId) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compte des entités qui seront supprimées en cascade si la campagne est effacée.
|
||||||
|
* Utilisé par l'UI pour afficher un récapitulatif dans le dialogue de confirmation.
|
||||||
|
*/
|
||||||
|
public record DeletionImpact(int arcs, int chapters, int scenes, int characters) {}
|
||||||
|
|
||||||
public Campaign createCampaign(CampaignData data) {
|
public Campaign createCampaign(CampaignData data) {
|
||||||
Campaign campaign = Campaign.builder()
|
Campaign campaign = Campaign.builder()
|
||||||
.name(data.name())
|
.name(data.name())
|
||||||
@@ -71,7 +97,48 @@ public class CampaignService {
|
|||||||
return (id == null || id.isBlank()) ? null : id;
|
return (id == null || id.isBlank()) ? null : id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calcule l'impact d'une suppression en cascade : nombre d'arcs, chapitres,
|
||||||
|
* scènes et personnages qui disparaîtront avec la campagne. Utilisé par l'UI
|
||||||
|
* pour afficher "X arcs, Y chapitres, Z scènes seront supprimés".
|
||||||
|
*/
|
||||||
|
public DeletionImpact getDeletionImpact(String id) {
|
||||||
|
List<Arc> arcs = arcRepository.findByCampaignId(id);
|
||||||
|
int chapterTotal = 0;
|
||||||
|
int sceneTotal = 0;
|
||||||
|
for (Arc arc : arcs) {
|
||||||
|
List<Chapter> chapters = chapterRepository.findByArcId(arc.getId());
|
||||||
|
chapterTotal += chapters.size();
|
||||||
|
for (Chapter chapter : chapters) {
|
||||||
|
sceneTotal += sceneRepository.findByChapterId(chapter.getId()).size();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
int characterTotal = characterRepository.findByCampaignId(id).size();
|
||||||
|
return new DeletionImpact(arcs.size(), chapterTotal, sceneTotal, characterTotal);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supprime la campagne et toutes ses entités dépendantes (arcs → chapitres →
|
||||||
|
* scènes, plus les personnages). L'opération est transactionnelle : soit
|
||||||
|
* tout disparaît, soit rien ne change. Les FKs applicatives n'ayant pas
|
||||||
|
* de contrainte CASCADE au niveau DB, on orchestre la cascade ici.
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
public void deleteCampaign(String id) {
|
public void deleteCampaign(String id) {
|
||||||
|
List<Arc> arcs = arcRepository.findByCampaignId(id);
|
||||||
|
for (Arc arc : arcs) {
|
||||||
|
List<Chapter> chapters = chapterRepository.findByArcId(arc.getId());
|
||||||
|
for (Chapter chapter : chapters) {
|
||||||
|
for (var scene : sceneRepository.findByChapterId(chapter.getId())) {
|
||||||
|
sceneRepository.deleteById(scene.getId());
|
||||||
|
}
|
||||||
|
chapterRepository.deleteById(chapter.getId());
|
||||||
|
}
|
||||||
|
arcRepository.deleteById(arc.getId());
|
||||||
|
}
|
||||||
|
for (var character : characterRepository.findByCampaignId(id)) {
|
||||||
|
characterRepository.deleteById(character.getId());
|
||||||
|
}
|
||||||
campaignRepository.deleteById(id);
|
campaignRepository.deleteById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ package com.loremind.application.campaigncontext;
|
|||||||
|
|
||||||
import com.loremind.domain.campaigncontext.Chapter;
|
import com.loremind.domain.campaigncontext.Chapter;
|
||||||
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
|
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
|
||||||
|
import com.loremind.domain.campaigncontext.ports.SceneRepository;
|
||||||
import org.springframework.beans.BeanUtils;
|
import org.springframework.beans.BeanUtils;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
@@ -17,17 +19,27 @@ import java.util.Optional;
|
|||||||
public class ChapterService {
|
public class ChapterService {
|
||||||
|
|
||||||
private final ChapterRepository chapterRepository;
|
private final ChapterRepository chapterRepository;
|
||||||
|
private final SceneRepository sceneRepository;
|
||||||
|
|
||||||
public ChapterService(ChapterRepository chapterRepository) {
|
public ChapterService(ChapterRepository chapterRepository, SceneRepository sceneRepository) {
|
||||||
this.chapterRepository = chapterRepository;
|
this.chapterRepository = chapterRepository;
|
||||||
|
this.sceneRepository = sceneRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Compte des scènes qui seront supprimées en cascade avec le chapitre. */
|
||||||
|
public record DeletionImpact(int scenes) {}
|
||||||
|
|
||||||
public Chapter createChapter(String name, String description, String arcId, int order) {
|
public Chapter createChapter(String name, String description, String arcId, int order) {
|
||||||
|
return createChapter(name, description, arcId, order, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Chapter createChapter(String name, String description, String arcId, int order, String icon) {
|
||||||
Chapter chapter = Chapter.builder()
|
Chapter chapter = Chapter.builder()
|
||||||
.name(name)
|
.name(name)
|
||||||
.description(description)
|
.description(description)
|
||||||
.arcId(arcId)
|
.arcId(arcId)
|
||||||
.order(order)
|
.order(order)
|
||||||
|
.icon(icon)
|
||||||
.build();
|
.build();
|
||||||
return chapterRepository.save(chapter);
|
return chapterRepository.save(chapter);
|
||||||
}
|
}
|
||||||
@@ -58,7 +70,17 @@ public class ChapterService {
|
|||||||
return chapterRepository.save(chapter);
|
return chapterRepository.save(chapter);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Compte des scènes qui tomberont avec le chapitre. */
|
||||||
|
public DeletionImpact getDeletionImpact(String id) {
|
||||||
|
return new DeletionImpact(sceneRepository.findByChapterId(id).size());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Supprime le chapitre et toutes ses scènes. Transactionnel : atomique. */
|
||||||
|
@Transactional
|
||||||
public void deleteChapter(String id) {
|
public void deleteChapter(String id) {
|
||||||
|
for (var scene : sceneRepository.findByChapterId(id)) {
|
||||||
|
sceneRepository.deleteById(scene.getId());
|
||||||
|
}
|
||||||
chapterRepository.deleteById(id);
|
chapterRepository.deleteById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package com.loremind.application.campaigncontext;
|
||||||
|
|
||||||
|
import com.loremind.domain.campaigncontext.Npc;
|
||||||
|
import com.loremind.domain.campaigncontext.ports.NpcRepository;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service d'application pour les fiches de PNJ (campagne).
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class NpcService {
|
||||||
|
|
||||||
|
private final NpcRepository npcRepository;
|
||||||
|
|
||||||
|
public NpcService(NpcRepository npcRepository) {
|
||||||
|
this.npcRepository = npcRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parameter Object pour la création / mise à jour d'un Npc.
|
||||||
|
* `order` est fourni par le controller ; si absent, le service le calcule.
|
||||||
|
*/
|
||||||
|
public record NpcData(String name, String markdownContent, String campaignId, Integer order) {}
|
||||||
|
|
||||||
|
public Npc createNpc(NpcData data) {
|
||||||
|
int order = data.order() != null
|
||||||
|
? data.order()
|
||||||
|
: nextOrderFor(data.campaignId());
|
||||||
|
Npc npc = Npc.builder()
|
||||||
|
.name(data.name())
|
||||||
|
.markdownContent(data.markdownContent())
|
||||||
|
.campaignId(data.campaignId())
|
||||||
|
.order(order)
|
||||||
|
.build();
|
||||||
|
return npcRepository.save(npc);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Optional<Npc> getNpcById(String id) {
|
||||||
|
return npcRepository.findById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Npc> getNpcsByCampaignId(String campaignId) {
|
||||||
|
return npcRepository.findByCampaignId(campaignId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Npc updateNpc(String id, NpcData data) {
|
||||||
|
Npc existing = npcRepository.findById(id)
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("Npc non trouvé avec l'ID: " + id));
|
||||||
|
existing.setName(data.name());
|
||||||
|
existing.setMarkdownContent(data.markdownContent());
|
||||||
|
if (data.order() != null) {
|
||||||
|
existing.setOrder(data.order());
|
||||||
|
}
|
||||||
|
return npcRepository.save(existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void deleteNpc(String id) {
|
||||||
|
npcRepository.deleteById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Renvoie la prochaine position libre — append en fin de liste. */
|
||||||
|
private int nextOrderFor(String campaignId) {
|
||||||
|
return npcRepository.findByCampaignId(campaignId).stream()
|
||||||
|
.mapToInt(Npc::getOrder)
|
||||||
|
.max()
|
||||||
|
.orElse(-1) + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,11 +26,16 @@ public class SceneService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public Scene createScene(String name, String description, String chapterId, int order) {
|
public Scene createScene(String name, String description, String chapterId, int order) {
|
||||||
|
return createScene(name, description, chapterId, order, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Scene createScene(String name, String description, String chapterId, int order, String icon) {
|
||||||
Scene scene = Scene.builder()
|
Scene scene = Scene.builder()
|
||||||
.name(name)
|
.name(name)
|
||||||
.description(description)
|
.description(description)
|
||||||
.chapterId(chapterId)
|
.chapterId(chapterId)
|
||||||
.order(order)
|
.order(order)
|
||||||
|
.icon(icon)
|
||||||
.build();
|
.build();
|
||||||
return sceneRepository.save(scene);
|
return sceneRepository.save(scene);
|
||||||
}
|
}
|
||||||
@@ -93,7 +98,7 @@ public class SceneService {
|
|||||||
.collect(Collectors.toSet());
|
.collect(Collectors.toSet());
|
||||||
|
|
||||||
for (SceneBranch b : branches) {
|
for (SceneBranch b : branches) {
|
||||||
String target = b.getTargetSceneId();
|
String target = b.targetSceneId();
|
||||||
if (target == null || target.isBlank()) {
|
if (target == null || target.isBlank()) {
|
||||||
throw new IllegalArgumentException("Une branche doit avoir une scène de destination");
|
throw new IllegalArgumentException("Une branche doit avoir une scène de destination");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,11 +45,7 @@ public class GameSystemContextBuilder {
|
|||||||
private GameSystemContext build(GameSystem gs, GenerationIntent intent) {
|
private GameSystemContext build(GameSystem gs, GenerationIntent intent) {
|
||||||
Map<String, String> allSections = parseH2Sections(gs.getRulesMarkdown());
|
Map<String, String> allSections = parseH2Sections(gs.getRulesMarkdown());
|
||||||
Map<String, String> filtered = filterByIntent(allSections, intent);
|
Map<String, String> filtered = filterByIntent(allSections, intent);
|
||||||
return GameSystemContext.builder()
|
return new GameSystemContext(gs.getName(), gs.getDescription(), filtered);
|
||||||
.systemName(gs.getName())
|
|
||||||
.systemDescription(gs.getDescription())
|
|
||||||
.sections(filtered)
|
|
||||||
.build();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -4,17 +4,20 @@ import com.loremind.domain.campaigncontext.Arc;
|
|||||||
import com.loremind.domain.campaigncontext.Campaign;
|
import com.loremind.domain.campaigncontext.Campaign;
|
||||||
import com.loremind.domain.campaigncontext.Chapter;
|
import com.loremind.domain.campaigncontext.Chapter;
|
||||||
import com.loremind.domain.campaigncontext.Character;
|
import com.loremind.domain.campaigncontext.Character;
|
||||||
|
import com.loremind.domain.campaigncontext.Npc;
|
||||||
import com.loremind.domain.campaigncontext.Scene;
|
import com.loremind.domain.campaigncontext.Scene;
|
||||||
import com.loremind.domain.campaigncontext.ports.ArcRepository;
|
import com.loremind.domain.campaigncontext.ports.ArcRepository;
|
||||||
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
|
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
|
||||||
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
|
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
|
||||||
import com.loremind.domain.campaigncontext.ports.CharacterRepository;
|
import com.loremind.domain.campaigncontext.ports.CharacterRepository;
|
||||||
|
import com.loremind.domain.campaigncontext.ports.NpcRepository;
|
||||||
import com.loremind.domain.campaigncontext.ports.SceneRepository;
|
import com.loremind.domain.campaigncontext.ports.SceneRepository;
|
||||||
import com.loremind.domain.generationcontext.CampaignStructuralContext;
|
import com.loremind.domain.generationcontext.CampaignStructuralContext;
|
||||||
import com.loremind.domain.generationcontext.CampaignStructuralContext.ArcSummary;
|
import com.loremind.domain.generationcontext.CampaignStructuralContext.ArcSummary;
|
||||||
import com.loremind.domain.generationcontext.CampaignStructuralContext.BranchHint;
|
import com.loremind.domain.generationcontext.CampaignStructuralContext.BranchHint;
|
||||||
import com.loremind.domain.generationcontext.CampaignStructuralContext.ChapterSummary;
|
import com.loremind.domain.generationcontext.CampaignStructuralContext.ChapterSummary;
|
||||||
import com.loremind.domain.generationcontext.CampaignStructuralContext.CharacterSummary;
|
import com.loremind.domain.generationcontext.CampaignStructuralContext.CharacterSummary;
|
||||||
|
import com.loremind.domain.generationcontext.CampaignStructuralContext.NpcSummary;
|
||||||
import com.loremind.domain.generationcontext.CampaignStructuralContext.SceneSummary;
|
import com.loremind.domain.generationcontext.CampaignStructuralContext.SceneSummary;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
@@ -42,21 +45,24 @@ public class CampaignStructuralContextBuilder {
|
|||||||
private final ChapterRepository chapterRepository;
|
private final ChapterRepository chapterRepository;
|
||||||
private final SceneRepository sceneRepository;
|
private final SceneRepository sceneRepository;
|
||||||
private final CharacterRepository characterRepository;
|
private final CharacterRepository characterRepository;
|
||||||
|
private final NpcRepository npcRepository;
|
||||||
|
|
||||||
public CampaignStructuralContextBuilder(
|
public CampaignStructuralContextBuilder(
|
||||||
CampaignRepository campaignRepository,
|
CampaignRepository campaignRepository,
|
||||||
ArcRepository arcRepository,
|
ArcRepository arcRepository,
|
||||||
ChapterRepository chapterRepository,
|
ChapterRepository chapterRepository,
|
||||||
SceneRepository sceneRepository,
|
SceneRepository sceneRepository,
|
||||||
CharacterRepository characterRepository) {
|
CharacterRepository characterRepository,
|
||||||
|
NpcRepository npcRepository) {
|
||||||
this.campaignRepository = campaignRepository;
|
this.campaignRepository = campaignRepository;
|
||||||
this.arcRepository = arcRepository;
|
this.arcRepository = arcRepository;
|
||||||
this.chapterRepository = chapterRepository;
|
this.chapterRepository = chapterRepository;
|
||||||
this.sceneRepository = sceneRepository;
|
this.sceneRepository = sceneRepository;
|
||||||
this.characterRepository = characterRepository;
|
this.characterRepository = characterRepository;
|
||||||
|
this.npcRepository = npcRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Longueur max du snippet de PJ injecté dans le contexte (coût tokens maîtrisé). */
|
/** Longueur max du snippet de PJ/PNJ injecté dans le contexte (coût tokens maîtrisé). */
|
||||||
private static final int CHARACTER_SNIPPET_MAX_LEN = 160;
|
private static final int CHARACTER_SNIPPET_MAX_LEN = 160;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -79,12 +85,17 @@ public class CampaignStructuralContextBuilder {
|
|||||||
.map(this::toCharacterSummary)
|
.map(this::toCharacterSummary)
|
||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
return CampaignStructuralContext.builder()
|
List<NpcSummary> npcs = npcRepository.findByCampaignId(campaignId).stream()
|
||||||
.campaignName(campaign.getName())
|
.sorted(Comparator.comparingInt(Npc::getOrder))
|
||||||
.campaignDescription(campaign.getDescription())
|
.map(this::toNpcSummary)
|
||||||
.arcs(arcs)
|
.collect(Collectors.toList());
|
||||||
.characters(characters)
|
|
||||||
.build();
|
return new CampaignStructuralContext(
|
||||||
|
campaign.getName(),
|
||||||
|
campaign.getDescription(),
|
||||||
|
arcs,
|
||||||
|
characters,
|
||||||
|
npcs);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -93,10 +104,12 @@ public class CampaignStructuralContextBuilder {
|
|||||||
* sans injecter toute sa fiche.
|
* sans injecter toute sa fiche.
|
||||||
*/
|
*/
|
||||||
private CharacterSummary toCharacterSummary(Character c) {
|
private CharacterSummary toCharacterSummary(Character c) {
|
||||||
return CharacterSummary.builder()
|
return new CharacterSummary(c.getName(), extractSnippet(c.getMarkdownContent()));
|
||||||
.name(c.getName())
|
}
|
||||||
.snippet(extractSnippet(c.getMarkdownContent()))
|
|
||||||
.build();
|
/** Symétrique à {@link #toCharacterSummary} pour les PNJ. */
|
||||||
|
private NpcSummary toNpcSummary(Npc n) {
|
||||||
|
return new NpcSummary(n.getName(), extractSnippet(n.getMarkdownContent()));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String extractSnippet(String markdown) {
|
private static String extractSnippet(String markdown) {
|
||||||
@@ -115,12 +128,11 @@ public class CampaignStructuralContextBuilder {
|
|||||||
.sorted(Comparator.comparingInt(Chapter::getOrder))
|
.sorted(Comparator.comparingInt(Chapter::getOrder))
|
||||||
.map(this::toChapterSummary)
|
.map(this::toChapterSummary)
|
||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
return ArcSummary.builder()
|
return new ArcSummary(
|
||||||
.name(arc.getName())
|
arc.getName(),
|
||||||
.description(arc.getDescription())
|
arc.getDescription(),
|
||||||
.illustrationCount(countImages(arc.getIllustrationImageIds()))
|
countImages(arc.getIllustrationImageIds()),
|
||||||
.chapters(chapters)
|
chapters);
|
||||||
.build();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private ChapterSummary toChapterSummary(Chapter chapter) {
|
private ChapterSummary toChapterSummary(Chapter chapter) {
|
||||||
@@ -137,32 +149,28 @@ public class CampaignStructuralContextBuilder {
|
|||||||
.map(s -> toSceneSummary(s, nameById))
|
.map(s -> toSceneSummary(s, nameById))
|
||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
return ChapterSummary.builder()
|
return new ChapterSummary(
|
||||||
.name(chapter.getName())
|
chapter.getName(),
|
||||||
.description(chapter.getDescription())
|
chapter.getDescription(),
|
||||||
.illustrationCount(countImages(chapter.getIllustrationImageIds()))
|
countImages(chapter.getIllustrationImageIds()),
|
||||||
.scenes(summaries)
|
summaries);
|
||||||
.build();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private SceneSummary toSceneSummary(Scene scene, Map<String, String> nameById) {
|
private SceneSummary toSceneSummary(Scene scene, Map<String, String> nameById) {
|
||||||
List<BranchHint> hints = scene.getBranches() == null
|
List<BranchHint> hints = scene.getBranches() == null
|
||||||
? List.of()
|
? List.of()
|
||||||
: scene.getBranches().stream()
|
: scene.getBranches().stream()
|
||||||
.map(b -> BranchHint.builder()
|
.map(b -> new BranchHint(
|
||||||
.label(b.getLabel())
|
b.label(),
|
||||||
.targetSceneName(nameById.getOrDefault(
|
nameById.getOrDefault(b.targetSceneId(), "(scène inconnue)"),
|
||||||
b.getTargetSceneId(), "(scène inconnue)"))
|
b.condition()))
|
||||||
.condition(b.getCondition())
|
|
||||||
.build())
|
|
||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
return SceneSummary.builder()
|
return new SceneSummary(
|
||||||
.name(scene.getName())
|
scene.getName(),
|
||||||
.description(scene.getDescription())
|
scene.getDescription(),
|
||||||
.illustrationCount(countImages(scene.getIllustrationImageIds()))
|
countImages(scene.getIllustrationImageIds()),
|
||||||
.branches(hints)
|
hints);
|
||||||
.build();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Helper defensif : compte les illustrations attachees (null-safe). */
|
/** Helper defensif : compte les illustrations attachees (null-safe). */
|
||||||
|
|||||||
@@ -67,16 +67,15 @@ public class GeneratePageValuesUseCase {
|
|||||||
|
|
||||||
requireNonEmptyFields(template);
|
requireNonEmptyFields(template);
|
||||||
|
|
||||||
GenerationContext context = GenerationContext.builder()
|
// Seuls les champs TEXT sont envoyes a l'IA : les champs IMAGE
|
||||||
.loreName(lore.getName())
|
// necessitent un workflow different (pas de generation LLM texte).
|
||||||
.loreDescription(lore.getDescription())
|
GenerationContext context = new GenerationContext(
|
||||||
.folderName(folder.getName())
|
lore.getName(),
|
||||||
.templateName(template.getName())
|
lore.getDescription(),
|
||||||
// Seuls les champs TEXT sont envoyes a l'IA : les champs IMAGE
|
folder.getName(),
|
||||||
// necessitent un workflow different (pas de generation LLM texte).
|
template.getName(),
|
||||||
.templateFields(template.textFieldNames())
|
template.textFieldNames(),
|
||||||
.pageTitle(page.getTitle())
|
page.getTitle());
|
||||||
.build();
|
|
||||||
|
|
||||||
GenerationResult result = aiProvider.generatePage(context);
|
GenerationResult result = aiProvider.generatePage(context);
|
||||||
return result.values();
|
return result.values();
|
||||||
|
|||||||
@@ -82,12 +82,11 @@ public class LoreStructuralContextBuilder {
|
|||||||
Map<String, String> pageTitleById = pages.stream()
|
Map<String, String> pageTitleById = pages.stream()
|
||||||
.collect(Collectors.toMap(Page::getId, Page::getTitle, (a, b) -> a));
|
.collect(Collectors.toMap(Page::getId, Page::getTitle, (a, b) -> a));
|
||||||
|
|
||||||
return LoreStructuralContext.builder()
|
return new LoreStructuralContext(
|
||||||
.loreName(lore.getName())
|
lore.getName(),
|
||||||
.loreDescription(lore.getDescription())
|
lore.getDescription(),
|
||||||
.folders(buildFoldersMap(nodes, pages, templateNameById, pageTitleById))
|
buildFoldersMap(nodes, pages, templateNameById, pageTitleById),
|
||||||
.tags(extractUniqueTags(pages))
|
extractUniqueTags(pages));
|
||||||
.build();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private Map<String, List<PageSummary>> buildFoldersMap(
|
private Map<String, List<PageSummary>> buildFoldersMap(
|
||||||
@@ -118,13 +117,12 @@ public class LoreStructuralContextBuilder {
|
|||||||
Page page,
|
Page page,
|
||||||
Map<String, String> templateNameById,
|
Map<String, String> templateNameById,
|
||||||
Map<String, String> pageTitleById) {
|
Map<String, String> pageTitleById) {
|
||||||
return PageSummary.builder()
|
return new PageSummary(
|
||||||
.title(page.getTitle())
|
page.getTitle(),
|
||||||
.templateName(templateNameById.getOrDefault(page.getTemplateId(), "?"))
|
templateNameById.getOrDefault(page.getTemplateId(), "?"),
|
||||||
.values(truncatedValues(page.getValues()))
|
truncatedValues(page.getValues()),
|
||||||
.tags(page.getTags() != null ? List.copyOf(page.getTags()) : Collections.emptyList())
|
page.getTags() != null ? List.copyOf(page.getTags()) : Collections.emptyList(),
|
||||||
.relatedPageTitles(resolveRelatedTitles(page.getRelatedPageIds(), pageTitleById))
|
resolveRelatedTitles(page.getRelatedPageIds(), pageTitleById));
|
||||||
.build();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -3,10 +3,12 @@ package com.loremind.application.generationcontext;
|
|||||||
import com.loremind.domain.campaigncontext.Arc;
|
import com.loremind.domain.campaigncontext.Arc;
|
||||||
import com.loremind.domain.campaigncontext.Chapter;
|
import com.loremind.domain.campaigncontext.Chapter;
|
||||||
import com.loremind.domain.campaigncontext.Character;
|
import com.loremind.domain.campaigncontext.Character;
|
||||||
|
import com.loremind.domain.campaigncontext.Npc;
|
||||||
import com.loremind.domain.campaigncontext.Scene;
|
import com.loremind.domain.campaigncontext.Scene;
|
||||||
import com.loremind.domain.campaigncontext.ports.ArcRepository;
|
import com.loremind.domain.campaigncontext.ports.ArcRepository;
|
||||||
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
|
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
|
||||||
import com.loremind.domain.campaigncontext.ports.CharacterRepository;
|
import com.loremind.domain.campaigncontext.ports.CharacterRepository;
|
||||||
|
import com.loremind.domain.campaigncontext.ports.NpcRepository;
|
||||||
import com.loremind.domain.campaigncontext.ports.SceneRepository;
|
import com.loremind.domain.campaigncontext.ports.SceneRepository;
|
||||||
import com.loremind.domain.generationcontext.NarrativeEntityContext;
|
import com.loremind.domain.generationcontext.NarrativeEntityContext;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
@@ -29,22 +31,25 @@ public class NarrativeEntityContextBuilder {
|
|||||||
private final ChapterRepository chapterRepository;
|
private final ChapterRepository chapterRepository;
|
||||||
private final SceneRepository sceneRepository;
|
private final SceneRepository sceneRepository;
|
||||||
private final CharacterRepository characterRepository;
|
private final CharacterRepository characterRepository;
|
||||||
|
private final NpcRepository npcRepository;
|
||||||
|
|
||||||
public NarrativeEntityContextBuilder(
|
public NarrativeEntityContextBuilder(
|
||||||
ArcRepository arcRepository,
|
ArcRepository arcRepository,
|
||||||
ChapterRepository chapterRepository,
|
ChapterRepository chapterRepository,
|
||||||
SceneRepository sceneRepository,
|
SceneRepository sceneRepository,
|
||||||
CharacterRepository characterRepository) {
|
CharacterRepository characterRepository,
|
||||||
|
NpcRepository npcRepository) {
|
||||||
this.arcRepository = arcRepository;
|
this.arcRepository = arcRepository;
|
||||||
this.chapterRepository = chapterRepository;
|
this.chapterRepository = chapterRepository;
|
||||||
this.sceneRepository = sceneRepository;
|
this.sceneRepository = sceneRepository;
|
||||||
this.characterRepository = characterRepository;
|
this.characterRepository = characterRepository;
|
||||||
|
this.npcRepository = npcRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Charge l'entité narrative ciblée et la projette vers un VO du GenerationContext.
|
* Charge l'entité narrative ciblée et la projette vers un VO du GenerationContext.
|
||||||
*
|
*
|
||||||
* @param entityType "arc", "chapter", "scene" ou "character" (insensible à la casse)
|
* @param entityType "arc", "chapter", "scene", "character" ou "npc" (insensible à la casse)
|
||||||
* @param entityId l'ID de l'entité
|
* @param entityId l'ID de l'entité
|
||||||
* @throws IllegalArgumentException si le type est inconnu ou l'entité introuvable
|
* @throws IllegalArgumentException si le type est inconnu ou l'entité introuvable
|
||||||
*/
|
*/
|
||||||
@@ -55,6 +60,7 @@ public class NarrativeEntityContextBuilder {
|
|||||||
case "chapter" -> fromChapter(loadChapter(entityId));
|
case "chapter" -> fromChapter(loadChapter(entityId));
|
||||||
case "scene" -> fromScene(loadScene(entityId));
|
case "scene" -> fromScene(loadScene(entityId));
|
||||||
case "character" -> fromCharacter(loadCharacter(entityId));
|
case "character" -> fromCharacter(loadCharacter(entityId));
|
||||||
|
case "npc" -> fromNpc(loadNpc(entityId));
|
||||||
default -> throw new IllegalArgumentException("Type d'entité narrative inconnu: " + entityType);
|
default -> throw new IllegalArgumentException("Type d'entité narrative inconnu: " + entityType);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -81,6 +87,11 @@ public class NarrativeEntityContextBuilder {
|
|||||||
.orElseThrow(() -> new IllegalArgumentException("Personnage non trouvé: " + id));
|
.orElseThrow(() -> new IllegalArgumentException("Personnage non trouvé: " + id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Npc loadNpc(String id) {
|
||||||
|
return npcRepository.findById(id)
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("PNJ non trouvé: " + id));
|
||||||
|
}
|
||||||
|
|
||||||
// --- Mapping entité → VO ------------------------------------------------
|
// --- Mapping entité → VO ------------------------------------------------
|
||||||
|
|
||||||
private NarrativeEntityContext fromArc(Arc a) {
|
private NarrativeEntityContext fromArc(Arc a) {
|
||||||
@@ -91,11 +102,7 @@ public class NarrativeEntityContextBuilder {
|
|||||||
putField(fields, "rewards", a.getRewards());
|
putField(fields, "rewards", a.getRewards());
|
||||||
putField(fields, "resolution", a.getResolution());
|
putField(fields, "resolution", a.getResolution());
|
||||||
putField(fields, "gmNotes", a.getGmNotes());
|
putField(fields, "gmNotes", a.getGmNotes());
|
||||||
return NarrativeEntityContext.builder()
|
return new NarrativeEntityContext("arc", a.getName(), fields);
|
||||||
.entityType("arc")
|
|
||||||
.title(a.getName())
|
|
||||||
.fields(fields)
|
|
||||||
.build();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private NarrativeEntityContext fromChapter(Chapter c) {
|
private NarrativeEntityContext fromChapter(Chapter c) {
|
||||||
@@ -104,11 +111,7 @@ public class NarrativeEntityContextBuilder {
|
|||||||
putField(fields, "playerObjectives", c.getPlayerObjectives());
|
putField(fields, "playerObjectives", c.getPlayerObjectives());
|
||||||
putField(fields, "narrativeStakes", c.getNarrativeStakes());
|
putField(fields, "narrativeStakes", c.getNarrativeStakes());
|
||||||
putField(fields, "gmNotes", c.getGmNotes());
|
putField(fields, "gmNotes", c.getGmNotes());
|
||||||
return NarrativeEntityContext.builder()
|
return new NarrativeEntityContext("chapter", c.getName(), fields);
|
||||||
.entityType("chapter")
|
|
||||||
.title(c.getName())
|
|
||||||
.fields(fields)
|
|
||||||
.build();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private NarrativeEntityContext fromScene(Scene s) {
|
private NarrativeEntityContext fromScene(Scene s) {
|
||||||
@@ -122,21 +125,19 @@ public class NarrativeEntityContextBuilder {
|
|||||||
putField(fields, "combatDifficulty", s.getCombatDifficulty());
|
putField(fields, "combatDifficulty", s.getCombatDifficulty());
|
||||||
putField(fields, "enemies", s.getEnemies());
|
putField(fields, "enemies", s.getEnemies());
|
||||||
putField(fields, "gmSecretNotes", s.getGmSecretNotes());
|
putField(fields, "gmSecretNotes", s.getGmSecretNotes());
|
||||||
return NarrativeEntityContext.builder()
|
return new NarrativeEntityContext("scene", s.getName(), fields);
|
||||||
.entityType("scene")
|
|
||||||
.title(s.getName())
|
|
||||||
.fields(fields)
|
|
||||||
.build();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private NarrativeEntityContext fromCharacter(Character c) {
|
private NarrativeEntityContext fromCharacter(Character c) {
|
||||||
Map<String, String> fields = new LinkedHashMap<>();
|
Map<String, String> fields = new LinkedHashMap<>();
|
||||||
putField(fields, "fiche complète (markdown)", c.getMarkdownContent());
|
putField(fields, "fiche complète (markdown)", c.getMarkdownContent());
|
||||||
return NarrativeEntityContext.builder()
|
return new NarrativeEntityContext("character", c.getName(), fields);
|
||||||
.entityType("character")
|
}
|
||||||
.title(c.getName())
|
|
||||||
.fields(fields)
|
private NarrativeEntityContext fromNpc(Npc n) {
|
||||||
.build();
|
Map<String, String> fields = new LinkedHashMap<>();
|
||||||
|
putField(fields, "fiche complète (markdown)", n.getMarkdownContent());
|
||||||
|
return new NarrativeEntityContext("npc", n.getName(), fields);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Null/blank devient chaîne vide — uniforme côté prompt, pas de NPE côté LLM. */
|
/** Null/blank devient chaîne vide — uniforme côté prompt, pas de NPE côté LLM. */
|
||||||
|
|||||||
@@ -107,11 +107,6 @@ public class StreamChatForLoreUseCase {
|
|||||||
? page.getValues()
|
? page.getValues()
|
||||||
: Collections.emptyMap();
|
: Collections.emptyMap();
|
||||||
|
|
||||||
return PageContext.builder()
|
return new PageContext(page.getTitle(), templateName, templateFields, values);
|
||||||
.title(page.getTitle())
|
|
||||||
.templateName(templateName)
|
|
||||||
.templateFields(templateFields)
|
|
||||||
.values(values)
|
|
||||||
.build();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
package com.loremind.application.lorecontext;
|
package com.loremind.application.lorecontext;
|
||||||
|
|
||||||
import com.loremind.domain.lorecontext.LoreNode;
|
import com.loremind.domain.lorecontext.LoreNode;
|
||||||
|
import com.loremind.domain.lorecontext.Page;
|
||||||
import com.loremind.domain.lorecontext.ports.LoreNodeRepository;
|
import com.loremind.domain.lorecontext.ports.LoreNodeRepository;
|
||||||
|
import com.loremind.domain.lorecontext.ports.PageRepository;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
@@ -16,11 +20,20 @@ import java.util.Optional;
|
|||||||
public class LoreNodeService {
|
public class LoreNodeService {
|
||||||
|
|
||||||
private final LoreNodeRepository loreNodeRepository;
|
private final LoreNodeRepository loreNodeRepository;
|
||||||
|
private final PageRepository pageRepository;
|
||||||
|
|
||||||
public LoreNodeService(LoreNodeRepository loreNodeRepository) {
|
public LoreNodeService(LoreNodeRepository loreNodeRepository, PageRepository pageRepository) {
|
||||||
this.loreNodeRepository = loreNodeRepository;
|
this.loreNodeRepository = loreNodeRepository;
|
||||||
|
this.pageRepository = pageRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compte des entités qui seront supprimées en cascade si le dossier est effacé :
|
||||||
|
* le dossier lui-même n'est pas compté, seuls les descendants (sous-dossiers
|
||||||
|
* récursifs + pages de l'ensemble du sous-arbre).
|
||||||
|
*/
|
||||||
|
public record DeletionImpact(int folders, int pages) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Crée un LoreNode (dossier) à partir d'un "objet changes" porteur des valeurs
|
* Crée un LoreNode (dossier) à partir d'un "objet changes" porteur des valeurs
|
||||||
* souhaitées (pattern Parameter Object) : évite les signatures qui gonflent
|
* souhaitées (pattern Parameter Object) : évite les signatures qui gonflent
|
||||||
@@ -68,7 +81,64 @@ public class LoreNodeService {
|
|||||||
return loreNodeRepository.save(existing);
|
return loreNodeRepository.save(existing);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calcule l'impact d'une suppression en cascade : nombre de sous-dossiers
|
||||||
|
* (récursif, sans compter la racine) et de pages dans l'ensemble du sous-arbre.
|
||||||
|
*/
|
||||||
|
public DeletionImpact getDeletionImpact(String id) {
|
||||||
|
List<LoreNode> descendants = collectDescendants(id);
|
||||||
|
int pageTotal = pageRepository.findByNodeId(id).size();
|
||||||
|
for (LoreNode descendant : descendants) {
|
||||||
|
pageTotal += pageRepository.findByNodeId(descendant.getId()).size();
|
||||||
|
}
|
||||||
|
return new DeletionImpact(descendants.size(), pageTotal);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supprime le dossier et tout son sous-arbre (sous-dossiers récursifs + pages).
|
||||||
|
* Suppression en profondeur d'abord (feuilles → racine) pour limiter les
|
||||||
|
* références orphelines en cours de transaction. Les FKs applicatives n'ayant
|
||||||
|
* pas de CASCADE en DB, on orchestre la descente ici.
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
public void deleteLoreNode(String id) {
|
public void deleteLoreNode(String id) {
|
||||||
|
List<LoreNode> descendants = collectDescendants(id);
|
||||||
|
// Descendants retournés en ordre BFS (haut → bas) : on inverse pour
|
||||||
|
// supprimer les feuilles en premier, puis on finit par la racine.
|
||||||
|
for (int i = descendants.size() - 1; i >= 0; i--) {
|
||||||
|
String descendantId = descendants.get(i).getId();
|
||||||
|
deletePagesOfNode(descendantId);
|
||||||
|
loreNodeRepository.deleteById(descendantId);
|
||||||
|
}
|
||||||
|
deletePagesOfNode(id);
|
||||||
loreNodeRepository.deleteById(id);
|
loreNodeRepository.deleteById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void deletePagesOfNode(String nodeId) {
|
||||||
|
for (Page page : pageRepository.findByNodeId(nodeId)) {
|
||||||
|
pageRepository.deleteById(page.getId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retourne tous les descendants (hors racine) d'un dossier, en ordre BFS.
|
||||||
|
* Parcours itératif pour éviter tout risque de débordement de pile sur
|
||||||
|
* une arborescence profonde malicieuse.
|
||||||
|
*/
|
||||||
|
private List<LoreNode> collectDescendants(String rootId) {
|
||||||
|
List<LoreNode> result = new ArrayList<>();
|
||||||
|
List<String> frontier = new ArrayList<>();
|
||||||
|
frontier.add(rootId);
|
||||||
|
while (!frontier.isEmpty()) {
|
||||||
|
List<String> nextFrontier = new ArrayList<>();
|
||||||
|
for (String parentId : frontier) {
|
||||||
|
for (LoreNode child : loreNodeRepository.findByParentId(parentId)) {
|
||||||
|
result.add(child);
|
||||||
|
nextFrontier.add(child.getId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
frontier = nextFrontier;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
package com.loremind.application.lorecontext;
|
package com.loremind.application.lorecontext;
|
||||||
|
|
||||||
|
import com.loremind.domain.campaigncontext.Campaign;
|
||||||
|
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
|
||||||
import com.loremind.domain.lorecontext.Lore;
|
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.LoreNodeRepository;
|
||||||
import com.loremind.domain.lorecontext.ports.LoreRepository;
|
import com.loremind.domain.lorecontext.ports.LoreRepository;
|
||||||
import com.loremind.domain.lorecontext.ports.PageRepository;
|
import com.loremind.domain.lorecontext.ports.PageRepository;
|
||||||
|
import com.loremind.domain.lorecontext.ports.TemplateRepository;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
@@ -26,15 +33,28 @@ public class LoreService {
|
|||||||
private final LoreRepository loreRepository;
|
private final LoreRepository loreRepository;
|
||||||
private final LoreNodeRepository loreNodeRepository;
|
private final LoreNodeRepository loreNodeRepository;
|
||||||
private final PageRepository pageRepository;
|
private final PageRepository pageRepository;
|
||||||
|
private final TemplateRepository templateRepository;
|
||||||
|
private final CampaignRepository campaignRepository;
|
||||||
|
|
||||||
public LoreService(LoreRepository loreRepository,
|
public LoreService(LoreRepository loreRepository,
|
||||||
LoreNodeRepository loreNodeRepository,
|
LoreNodeRepository loreNodeRepository,
|
||||||
PageRepository pageRepository) {
|
PageRepository pageRepository,
|
||||||
|
TemplateRepository templateRepository,
|
||||||
|
CampaignRepository campaignRepository) {
|
||||||
this.loreRepository = loreRepository;
|
this.loreRepository = loreRepository;
|
||||||
this.loreNodeRepository = loreNodeRepository;
|
this.loreNodeRepository = loreNodeRepository;
|
||||||
this.pageRepository = pageRepository;
|
this.pageRepository = pageRepository;
|
||||||
|
this.templateRepository = templateRepository;
|
||||||
|
this.campaignRepository = campaignRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compte des entités qui seront supprimées / détachées en cascade si le Lore
|
||||||
|
* est effacé. `detachedCampaigns` : campagnes qui perdront leur référence à
|
||||||
|
* ce Lore (leur loreId sera nullé) mais resteront présentes.
|
||||||
|
*/
|
||||||
|
public record DeletionImpact(int folders, int pages, int templates, int detachedCampaigns) {}
|
||||||
|
|
||||||
public Lore createLore(String name, String description) {
|
public Lore createLore(String name, String description) {
|
||||||
Lore lore = Lore.builder()
|
Lore lore = Lore.builder()
|
||||||
.name(name)
|
.name(name)
|
||||||
@@ -83,7 +103,54 @@ public class LoreService {
|
|||||||
return loreRepository.save(lore);
|
return loreRepository.save(lore);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calcule l'impact d'une suppression de Lore en cascade : dossiers + pages
|
||||||
|
* + templates supprimés, et campagnes qui seront détachées (loreId → null
|
||||||
|
* sans être supprimées, car une campagne peut vivre sans univers).
|
||||||
|
*/
|
||||||
|
public DeletionImpact getDeletionImpact(String id) {
|
||||||
|
int folders = (int) loreNodeRepository.countByLoreId(id);
|
||||||
|
int pages = (int) pageRepository.countByLoreId(id);
|
||||||
|
int templates = templateRepository.findByLoreId(id).size();
|
||||||
|
int detached = countCampaignsReferencingLore(id);
|
||||||
|
return new DeletionImpact(folders, pages, templates, detached);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supprime le Lore et toutes ses entités dépendantes (dossiers, pages, templates).
|
||||||
|
* Les campagnes qui référençaient ce Lore sont conservées — leur loreId est
|
||||||
|
* mis à null (une campagne peut légitimement exister sans univers associé).
|
||||||
|
* Opération transactionnelle : atomique.
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
public void deleteLore(String id) {
|
public void deleteLore(String id) {
|
||||||
|
// Pages d'abord : elles référencent nodeId ET loreId, on les supprime
|
||||||
|
// globalement via loreId pour éviter d'en rater une rattachée à un
|
||||||
|
// node orphelin (ne devrait pas arriver, mais ceinture+bretelles).
|
||||||
|
for (Page page : pageRepository.findByLoreId(id)) {
|
||||||
|
pageRepository.deleteById(page.getId());
|
||||||
|
}
|
||||||
|
for (LoreNode node : loreNodeRepository.findByLoreId(id)) {
|
||||||
|
loreNodeRepository.deleteById(node.getId());
|
||||||
|
}
|
||||||
|
for (Template template : templateRepository.findByLoreId(id)) {
|
||||||
|
templateRepository.deleteById(template.getId());
|
||||||
|
}
|
||||||
|
// Détache les campagnes : on garde la campagne, on nulle juste la référence.
|
||||||
|
for (Campaign campaign : campaignRepository.findAll()) {
|
||||||
|
if (id.equals(campaign.getLoreId())) {
|
||||||
|
campaign.setLoreId(null);
|
||||||
|
campaignRepository.save(campaign);
|
||||||
|
}
|
||||||
|
}
|
||||||
loreRepository.deleteById(id);
|
loreRepository.deleteById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private int countCampaignsReferencingLore(String id) {
|
||||||
|
int count = 0;
|
||||||
|
for (Campaign campaign : campaignRepository.findAll()) {
|
||||||
|
if (id.equals(campaign.getLoreId())) count++;
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ public class Arc {
|
|||||||
private String campaignId; // Référence vers la Campaign parente
|
private String campaignId; // Référence vers la Campaign parente
|
||||||
private int order; // Ordre de l'arc dans la campagne
|
private int order; // Ordre de l'arc dans la campagne
|
||||||
|
|
||||||
|
/** Cle d'icone choisie par l'utilisateur (cf. CAMPAIGN_ICON_OPTIONS cote front). */
|
||||||
|
private String icon;
|
||||||
|
|
||||||
// Champs narratifs enrichis (voir docs/maquettes/campagne/détail/)
|
// Champs narratifs enrichis (voir docs/maquettes/campagne/détail/)
|
||||||
private String themes; // Thèmes principaux explorés dans cet arc
|
private String themes; // Thèmes principaux explorés dans cet arc
|
||||||
private String stakes; // Enjeux globaux pour les personnages
|
private String stakes; // Enjeux globaux pour les personnages
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ public class Chapter {
|
|||||||
private String arcId; // Référence vers l'Arc parent
|
private String arcId; // Référence vers l'Arc parent
|
||||||
private int order; // Ordre du chapitre dans l'arc
|
private int order; // Ordre du chapitre dans l'arc
|
||||||
|
|
||||||
|
/** Cle d'icone choisie par l'utilisateur (cf. CAMPAIGN_ICON_OPTIONS cote front). */
|
||||||
|
private String icon;
|
||||||
|
|
||||||
// Champs narratifs enrichis (voir docs/maquettes/campagne/détail/)
|
// Champs narratifs enrichis (voir docs/maquettes/campagne/détail/)
|
||||||
private String gmNotes; // Notes privées du MJ (non exportées vers FoundryVTT)
|
private String gmNotes; // Notes privées du MJ (non exportées vers FoundryVTT)
|
||||||
private String playerObjectives; // Objectifs des joueurs dans ce chapitre
|
private String playerObjectives; // Objectifs des joueurs dans ce chapitre
|
||||||
|
|||||||
@@ -12,9 +12,10 @@ import java.time.LocalDateTime;
|
|||||||
* backstory, équipement). Évolution prévue vers un système templaté par
|
* backstory, équipement). Évolution prévue vers un système templaté par
|
||||||
* GameSystem (la fiche Nimble n'a pas les mêmes champs qu'une fiche D&D).
|
* GameSystem (la fiche Nimble n'a pas les mêmes champs qu'une fiche D&D).
|
||||||
* <p>
|
* <p>
|
||||||
* Scope strict PJ : les PNJ restent dans le Lore (pages templatées) ou
|
* Scope strict PJ : les PNJ sont gérés par l'entité {@link Npc} dédiée
|
||||||
* dans les scènes elles-mêmes. Si le besoin de PNJ spécifiques à une
|
* (entité distincte plutôt qu'enum PJ/PNJ — invariants métier divergents).
|
||||||
* campagne remonte, on étendra l'entité (ex: type enum PJ/PNJ).
|
* Évolution prévue : système de templating partagé PJ/PNJ piloté par
|
||||||
|
* GameSystem pour adapter les blocs aux différents systèmes de JDR.
|
||||||
*/
|
*/
|
||||||
@Data
|
@Data
|
||||||
@Builder
|
@Builder
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package com.loremind.domain.campaigncontext;
|
||||||
|
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fiche de personnage non-joueur (PNJ) d'une campagne.
|
||||||
|
* <p>
|
||||||
|
* MVP : entité dédiée, distincte de {@link Character} (PJ). Choix DDD assumé —
|
||||||
|
* un PNJ a vocation à porter à terme des invariants métier propres (faction,
|
||||||
|
* statut vivant/mort/disparu, visibilité côté joueurs, relations inter-PNJ)
|
||||||
|
* qui n'ont aucun sens sur un PJ. Mutualiser via un enum aurait pollué l'entité
|
||||||
|
* PJ avec des champs inutiles ({@code if (type == NPC)} partout = anti-pattern).
|
||||||
|
* <p>
|
||||||
|
* Contenu markdown libre comme les PJ. Évolution prévue : templating partagé
|
||||||
|
* PJ/PNJ piloté par GameSystem.
|
||||||
|
* <p>
|
||||||
|
* Scope campagne : les PNJ "univers" (worldboss, figures du Lore) restent
|
||||||
|
* gérés via le système Page/Template du LoreContext.
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
public class Npc {
|
||||||
|
|
||||||
|
private String id;
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
/** Contenu libre markdown — description, motivation, stats, notes MJ. Nullable à la création. */
|
||||||
|
private String markdownContent;
|
||||||
|
|
||||||
|
/** Référence vers la Campaign parente (cross-aggregate via ID, jamais d'objet). */
|
||||||
|
private String campaignId;
|
||||||
|
|
||||||
|
/** Ordre d'affichage dans la liste des PNJ de la campagne. */
|
||||||
|
private int order;
|
||||||
|
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
}
|
||||||
@@ -21,6 +21,9 @@ public class Scene {
|
|||||||
private String chapterId; // Référence vers le Chapter parent
|
private String chapterId; // Référence vers le Chapter parent
|
||||||
private int order; // Ordre de la scène dans le chapitre
|
private int order; // Ordre de la scène dans le chapitre
|
||||||
|
|
||||||
|
/** Cle d'icone choisie par l'utilisateur (cf. CAMPAIGN_ICON_OPTIONS cote front). */
|
||||||
|
private String icon;
|
||||||
|
|
||||||
// === Contexte et ambiance ===
|
// === Contexte et ambiance ===
|
||||||
private String location; // Lieu de la scène (ex: Taverne du Dragon d'Or)
|
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 timing; // Moment (ex: Soir, à la tombée de la nuit)
|
||||||
|
|||||||
@@ -1,31 +1,25 @@
|
|||||||
package com.loremind.domain.campaigncontext;
|
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.
|
* 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.
|
* Décrit un choix offert aux joueurs et la scène de destination associée.
|
||||||
* <p>
|
* <p>
|
||||||
* Immuable (@Value) : pour "modifier" une branche on la remplace.
|
* Record Java : immuable par construction, sans aucune dépendance technique
|
||||||
* @Jacksonized : permet à Jackson (sérialisation JSON via le converter JPA)
|
* (pas de Lombok, pas de Jackson). Jackson 2.12+ sait sérialiser/désérialiser
|
||||||
* de reconstruire l'objet en passant par le builder malgré l'absence de setters.
|
* les records nativement via le constructeur canonique — c'est ce dont
|
||||||
|
* dépend le SceneBranchListJsonConverter pour le stockage JSONB.
|
||||||
* <p>
|
* <p>
|
||||||
* Règle métier : targetSceneId DOIT pointer vers une Scene du MÊME Chapter
|
* Règle métier : targetSceneId DOIT pointer vers une Scene du MÊME Chapter
|
||||||
* (validation portée par SceneService).
|
* (validation portée par SceneService).
|
||||||
|
*
|
||||||
|
* @param label Libellé du choix (ex: "Si les joueurs attaquent le garde").
|
||||||
|
* @param targetSceneId Id de la Scene de destination, intra-chapitre uniquement.
|
||||||
|
* @param condition Notes MJ privées sur la condition de déclenchement (optionnel).
|
||||||
*/
|
*/
|
||||||
@Value
|
public record SceneBranch(String label, String targetSceneId, String condition) {
|
||||||
@Builder
|
|
||||||
@Jacksonized
|
|
||||||
public class SceneBranch {
|
|
||||||
|
|
||||||
/** Libellé du choix (ex: "Si les joueurs attaquent le garde"). */
|
/** Raccourci pour construire une branche sans condition (cas le plus courant). */
|
||||||
String label;
|
public static SceneBranch of(String label, String targetSceneId) {
|
||||||
|
return new SceneBranch(label, targetSceneId, null);
|
||||||
/** 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,22 @@
|
|||||||
|
package com.loremind.domain.campaigncontext.ports;
|
||||||
|
|
||||||
|
import com.loremind.domain.campaigncontext.Npc;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Port de sortie pour la persistance des fiches de PNJ (campagne).
|
||||||
|
*/
|
||||||
|
public interface NpcRepository {
|
||||||
|
|
||||||
|
Npc save(Npc npc);
|
||||||
|
|
||||||
|
Optional<Npc> findById(String id);
|
||||||
|
|
||||||
|
List<Npc> findByCampaignId(String campaignId);
|
||||||
|
|
||||||
|
void deleteById(String id);
|
||||||
|
|
||||||
|
boolean existsById(String id);
|
||||||
|
}
|
||||||
@@ -1,9 +1,5 @@
|
|||||||
package com.loremind.domain.generationcontext;
|
package com.loremind.domain.generationcontext;
|
||||||
|
|
||||||
import lombok.Builder;
|
|
||||||
import lombok.Singular;
|
|
||||||
import lombok.Value;
|
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -22,16 +18,18 @@ import java.util.List;
|
|||||||
* <p>
|
* <p>
|
||||||
* La liste `arcs` préserve l'ordre narratif (tri sur `order` ascendant
|
* La liste `arcs` préserve l'ordre narratif (tri sur `order` ascendant
|
||||||
* fait par le use case côté application layer).
|
* fait par le use case côté application layer).
|
||||||
|
* <p>
|
||||||
|
* Record Java : pur domaine, aucune dépendance technique.
|
||||||
|
*
|
||||||
|
* @param characters Personnages joueurs (PJ) de la campagne. Vide si aucun.
|
||||||
|
* @param npcs Personnages non-joueurs (PNJ) de la campagne. Vide si aucun.
|
||||||
*/
|
*/
|
||||||
@Value
|
public record CampaignStructuralContext(
|
||||||
@Builder
|
String campaignName,
|
||||||
public class CampaignStructuralContext {
|
String campaignDescription,
|
||||||
|
List<ArcSummary> arcs,
|
||||||
String campaignName;
|
List<CharacterSummary> characters,
|
||||||
String campaignDescription;
|
List<NpcSummary> npcs) {
|
||||||
@Singular List<ArcSummary> arcs;
|
|
||||||
/** Personnages joueurs (PJ) de la campagne. Vide si aucun. */
|
|
||||||
@Singular List<CharacterSummary> characters;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Résumé d'un PJ : nom + snippet court du markdown.
|
* Résumé d'un PJ : nom + snippet court du markdown.
|
||||||
@@ -40,53 +38,52 @@ public class CampaignStructuralContext {
|
|||||||
* La fiche complète n'est injectée que si le PJ est l'entité focus
|
* La fiche complète n'est injectée que si le PJ est l'entité focus
|
||||||
* (via NarrativeEntityContext, entity_type="character").
|
* (via NarrativeEntityContext, entity_type="character").
|
||||||
*/
|
*/
|
||||||
@Value
|
public record CharacterSummary(String name, String snippet) {
|
||||||
@Builder
|
|
||||||
public static class CharacterSummary {
|
|
||||||
String name;
|
|
||||||
String snippet;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Résumé d'un arc : nom + description courte + ses chapitres. */
|
/**
|
||||||
@Value
|
* Résumé d'un PNJ : symétrique à {@link CharacterSummary}.
|
||||||
@Builder
|
* Snippet court extrait du markdown — la fiche complète est réservée
|
||||||
public static class ArcSummary {
|
* à un usage focus (à venir, entity_type="npc").
|
||||||
String name;
|
*/
|
||||||
String description;
|
public record NpcSummary(String name, String snippet) {
|
||||||
/** Nombre d'illustrations attachees a cet arc (pour hint dans le prompt IA). */
|
}
|
||||||
int illustrationCount;
|
|
||||||
@Singular List<ChapterSummary> chapters;
|
/**
|
||||||
|
* Résumé d'un arc : nom + description courte + ses chapitres.
|
||||||
|
*
|
||||||
|
* @param illustrationCount Nombre d'illustrations attachees a cet arc (pour hint dans le prompt IA).
|
||||||
|
*/
|
||||||
|
public record ArcSummary(
|
||||||
|
String name,
|
||||||
|
String description,
|
||||||
|
int illustrationCount,
|
||||||
|
List<ChapterSummary> chapters) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Résumé d'un chapitre : nom + description courte + ses scènes. */
|
/** Résumé d'un chapitre : nom + description courte + ses scènes. */
|
||||||
@Value
|
public record ChapterSummary(
|
||||||
@Builder
|
String name,
|
||||||
public static class ChapterSummary {
|
String description,
|
||||||
String name;
|
int illustrationCount,
|
||||||
String description;
|
List<SceneSummary> scenes) {
|
||||||
int illustrationCount;
|
|
||||||
@Singular List<SceneSummary> scenes;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Résumé d'une scène : nom + description courte + branches narratives. */
|
/** Résumé d'une scène : nom + description courte + branches narratives. */
|
||||||
@Value
|
public record SceneSummary(
|
||||||
@Builder
|
String name,
|
||||||
public static class SceneSummary {
|
String description,
|
||||||
String name;
|
int illustrationCount,
|
||||||
String description;
|
List<BranchHint> branches) {
|
||||||
int illustrationCount;
|
|
||||||
@Singular List<BranchHint> branches;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Indice d'une branche narrative vers une autre scène du même chapitre. */
|
/**
|
||||||
@Value
|
* Indice d'une branche narrative vers une autre scène du même chapitre.
|
||||||
@Builder
|
*
|
||||||
public static class BranchHint {
|
* @param label Libellé du choix joueur (ex: "Si les joueurs attaquent le garde").
|
||||||
/** Libellé du choix joueur (ex: "Si les joueurs attaquent le garde"). */
|
* @param targetSceneName Nom de la scène cible (résolu depuis targetSceneId côté builder).
|
||||||
String label;
|
* @param condition Condition MJ privée (optionnel).
|
||||||
/** Nom de la scène cible (résolu depuis targetSceneId côté builder). */
|
*/
|
||||||
String targetSceneName;
|
public record BranchHint(String label, String targetSceneName, String condition) {
|
||||||
/** Condition MJ privée (optionnel). */
|
|
||||||
String condition;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
package com.loremind.domain.generationcontext;
|
package com.loremind.domain.generationcontext;
|
||||||
|
|
||||||
import lombok.Builder;
|
|
||||||
import lombok.Value;
|
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -21,28 +18,74 @@ import java.util.List;
|
|||||||
* Un chat Lore ne reçoit JAMAIS de campaignContext : un Lore ne voit pas
|
* 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,
|
* ses campagnes (asymétrie métier : la campagne est l'emprunteur du Lore,
|
||||||
* pas l'inverse).
|
* pas l'inverse).
|
||||||
|
* <p>
|
||||||
|
* Record Java : pur domaine. Builder manuel fourni en raison des 6 champs
|
||||||
|
* dont 5 sont nullables — l'API fluide reste plus lisible aux call sites
|
||||||
|
* qu'un constructeur à 6 paramètres souvent à null.
|
||||||
|
*
|
||||||
|
* @param loreContext Optionnel : carte structurelle du Lore. Null si campagne non liée à un Lore.
|
||||||
|
* @param pageContext Optionnel : contexte d'une page précise en cours d'édition (chat Lore uniquement).
|
||||||
|
* @param campaignContext Optionnel : carte narrative d'une Campagne (chat Campagne uniquement).
|
||||||
|
* @param narrativeEntity Optionnel : entité narrative en cours d'édition (arc/chapter/scene).
|
||||||
|
* @param gameSystemContext Optionnel : règles du système de JDR de la campagne (filtrées par intent).
|
||||||
|
* Null si la campagne n'a pas de GameSystem associé. Campagne uniquement au MVP.
|
||||||
*/
|
*/
|
||||||
@Value
|
public record ChatRequest(
|
||||||
@Builder
|
List<ChatMessage> messages,
|
||||||
public class ChatRequest {
|
LoreStructuralContext loreContext,
|
||||||
|
PageContext pageContext,
|
||||||
|
CampaignStructuralContext campaignContext,
|
||||||
|
NarrativeEntityContext narrativeEntity,
|
||||||
|
GameSystemContext gameSystemContext) {
|
||||||
|
|
||||||
List<ChatMessage> messages;
|
public static Builder builder() {
|
||||||
|
return new Builder();
|
||||||
|
}
|
||||||
|
|
||||||
/** Optionnel : carte structurelle du Lore. Null si campagne non liée à un Lore. */
|
/** Builder fluide : permet d'omettre les contextes non pertinents. */
|
||||||
LoreStructuralContext loreContext;
|
public static final class Builder {
|
||||||
|
private List<ChatMessage> messages;
|
||||||
|
private LoreStructuralContext loreContext;
|
||||||
|
private PageContext pageContext;
|
||||||
|
private CampaignStructuralContext campaignContext;
|
||||||
|
private NarrativeEntityContext narrativeEntity;
|
||||||
|
private GameSystemContext gameSystemContext;
|
||||||
|
|
||||||
/** Optionnel : contexte d'une page précise en cours d'édition (chat Lore uniquement). */
|
private Builder() {}
|
||||||
PageContext pageContext;
|
|
||||||
|
|
||||||
/** Optionnel : carte narrative d'une Campagne (chat Campagne uniquement). */
|
public Builder messages(List<ChatMessage> messages) {
|
||||||
CampaignStructuralContext campaignContext;
|
this.messages = messages;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
/** Optionnel : entité narrative en cours d'édition (arc/chapter/scene). */
|
public Builder loreContext(LoreStructuralContext loreContext) {
|
||||||
NarrativeEntityContext narrativeEntity;
|
this.loreContext = loreContext;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
public Builder pageContext(PageContext pageContext) {
|
||||||
* Optionnel : règles du système de JDR de la campagne (filtrées par intent).
|
this.pageContext = pageContext;
|
||||||
* Null si la campagne n'a pas de GameSystem associé. Campagne uniquement au MVP.
|
return this;
|
||||||
*/
|
}
|
||||||
GameSystemContext gameSystemContext;
|
|
||||||
|
public Builder campaignContext(CampaignStructuralContext campaignContext) {
|
||||||
|
this.campaignContext = campaignContext;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Builder narrativeEntity(NarrativeEntityContext narrativeEntity) {
|
||||||
|
this.narrativeEntity = narrativeEntity;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Builder gameSystemContext(GameSystemContext gameSystemContext) {
|
||||||
|
this.gameSystemContext = gameSystemContext;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ChatRequest build() {
|
||||||
|
return new ChatRequest(messages, loreContext, pageContext,
|
||||||
|
campaignContext, narrativeEntity, gameSystemContext);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
package com.loremind.domain.generationcontext;
|
package com.loremind.domain.generationcontext;
|
||||||
|
|
||||||
import lombok.Builder;
|
|
||||||
import lombok.Value;
|
|
||||||
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -11,20 +8,14 @@ import java.util.Map;
|
|||||||
* Contient uniquement les sections pertinentes pour l'intent de génération
|
* Contient uniquement les sections pertinentes pour l'intent de génération
|
||||||
* en cours (sélection effectuée par GameSystemContextBuilder). Les sections
|
* en cours (sélection effectuée par GameSystemContextBuilder). Les sections
|
||||||
* sont indexées par leur titre H2 original (ex : "Combat", "Classes").
|
* sont indexées par leur titre H2 original (ex : "Combat", "Classes").
|
||||||
|
*
|
||||||
|
* @param systemName Nom du système de JDR (ex : "Nimble", "D&D 5.1 SRD").
|
||||||
|
* @param systemDescription Description courte du système (nullable).
|
||||||
|
* @param sections Sections de règles pertinentes, indexées par titre H2.
|
||||||
|
* Vide si le GameSystem n'a aucune règle ou si aucune section ne matche l'intent.
|
||||||
*/
|
*/
|
||||||
@Value
|
public record GameSystemContext(
|
||||||
@Builder
|
String systemName,
|
||||||
public class GameSystemContext {
|
String systemDescription,
|
||||||
|
Map<String, String> sections) {
|
||||||
/** Nom du système de JDR (ex : "Nimble", "D&D 5.1 SRD"). */
|
|
||||||
String systemName;
|
|
||||||
|
|
||||||
/** Description courte du système (nullable). */
|
|
||||||
String systemDescription;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sections de règles pertinentes, indexées par titre H2.
|
|
||||||
* Vide si le GameSystem n'a aucune règle ou si aucune section ne matche l'intent.
|
|
||||||
*/
|
|
||||||
Map<String, String> sections;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
package com.loremind.domain.generationcontext;
|
package com.loremind.domain.generationcontext;
|
||||||
|
|
||||||
import lombok.Builder;
|
|
||||||
import lombok.Value;
|
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -10,19 +7,16 @@ import java.util.List;
|
|||||||
* pour remplir une Page à partir d'un Template.
|
* pour remplir une Page à partir d'un Template.
|
||||||
* <p>
|
* <p>
|
||||||
* Équivalent Java du PageGenerationContext Python (brain/app/domain/models.py).
|
* Équivalent Java du PageGenerationContext Python (brain/app/domain/models.py).
|
||||||
* Entité pure du domaine : aucune dépendance technique.
|
* Record Java : pur domaine, aucune dépendance technique.
|
||||||
* <p>
|
*
|
||||||
* Immuable via @Value (Lombok) : pas de setters, tous les champs final.
|
* @param templateFields Champs à générer (clés attendues dans la réponse).
|
||||||
* C'est un DTO de domaine entrant dans le port AiProvider.
|
* @param folderName Nom du LoreNode parent (ex: "PNJ", "Lieux").
|
||||||
*/
|
*/
|
||||||
@Value
|
public record GenerationContext(
|
||||||
@Builder
|
String loreName,
|
||||||
public class GenerationContext {
|
String loreDescription,
|
||||||
|
String folderName,
|
||||||
String loreName;
|
String templateName,
|
||||||
String loreDescription;
|
List<String> templateFields,
|
||||||
String folderName; // Nom du LoreNode parent (ex: "PNJ", "Lieux")
|
String pageTitle) {
|
||||||
String templateName;
|
|
||||||
List<String> templateFields; // Champs à générer (clés attendues dans la réponse)
|
|
||||||
String pageTitle;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
package com.loremind.domain.generationcontext;
|
package com.loremind.domain.generationcontext;
|
||||||
|
|
||||||
import lombok.Builder;
|
|
||||||
import lombok.Singular;
|
|
||||||
import lombok.Value;
|
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
@@ -16,15 +12,14 @@ import java.util.Map;
|
|||||||
* <p>
|
* <p>
|
||||||
* La map `folders` est indexée par nom de dossier et mappe vers la liste
|
* 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).
|
* des pages qu'il contient (liste vide autorisée pour les dossiers vides).
|
||||||
|
* <p>
|
||||||
|
* Record Java : pur domaine, aucune dépendance technique.
|
||||||
*/
|
*/
|
||||||
@Value
|
public record LoreStructuralContext(
|
||||||
@Builder
|
String loreName,
|
||||||
public class LoreStructuralContext {
|
String loreDescription,
|
||||||
|
Map<String, List<PageSummary>> folders,
|
||||||
String loreName;
|
List<String> tags) {
|
||||||
String loreDescription;
|
|
||||||
Map<String, List<PageSummary>> folders;
|
|
||||||
@Singular List<String> tags;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Résumé projeté d'une page pour l'IA.
|
* Résumé projeté d'une page pour l'IA.
|
||||||
@@ -40,13 +35,11 @@ public class LoreStructuralContext {
|
|||||||
* uniquement ce qui est partageable en narration — les secrets MJ
|
* uniquement ce qui est partageable en narration — les secrets MJ
|
||||||
* restent confinés à leur page d'édition).
|
* restent confinés à leur page d'édition).
|
||||||
*/
|
*/
|
||||||
@Value
|
public record PageSummary(
|
||||||
@Builder
|
String title,
|
||||||
public static class PageSummary {
|
String templateName,
|
||||||
String title;
|
Map<String, String> values,
|
||||||
String templateName;
|
List<String> tags,
|
||||||
Map<String, String> values;
|
List<String> relatedPageTitles) {
|
||||||
List<String> tags;
|
|
||||||
List<String> relatedPageTitles;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
package com.loremind.domain.generationcontext;
|
package com.loremind.domain.generationcontext;
|
||||||
|
|
||||||
import lombok.Builder;
|
|
||||||
import lombok.Value;
|
|
||||||
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -17,13 +14,11 @@ import java.util.Map;
|
|||||||
* `fields` associe le nom d'un champ (ex: "themes", "playerNarration")
|
* `fields` associe le nom d'un champ (ex: "themes", "playerNarration")
|
||||||
* à sa valeur actuelle (chaîne vide si non renseigné). Utiliser une
|
* à sa valeur actuelle (chaîne vide si non renseigné). Utiliser une
|
||||||
* LinkedHashMap à la construction pour un prompt lisible (ordre préservé).
|
* LinkedHashMap à la construction pour un prompt lisible (ordre préservé).
|
||||||
|
*
|
||||||
|
* @param entityType "arc", "chapter" ou "scene" — utilisé pour libeller le bloc du prompt.
|
||||||
*/
|
*/
|
||||||
@Value
|
public record NarrativeEntityContext(
|
||||||
@Builder
|
String entityType,
|
||||||
public class NarrativeEntityContext {
|
String title,
|
||||||
|
Map<String, String> fields) {
|
||||||
/** "arc", "chapter" ou "scene" — utilisé pour libeller le bloc du prompt. */
|
|
||||||
String entityType;
|
|
||||||
String title;
|
|
||||||
Map<String, String> fields;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
package com.loremind.domain.generationcontext;
|
package com.loremind.domain.generationcontext;
|
||||||
|
|
||||||
import lombok.Builder;
|
|
||||||
import lombok.Value;
|
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
@@ -14,14 +11,11 @@ import java.util.Map;
|
|||||||
* à l'IA de focaliser ses suggestions sur les bons champs sans déborder
|
* à l'IA de focaliser ses suggestions sur les bons champs sans déborder
|
||||||
* sur d'autres pages/templates.
|
* sur d'autres pages/templates.
|
||||||
* <p>
|
* <p>
|
||||||
* Object de valeur immuable, pur domaine — aucune dépendance technique.
|
* Record Java : immuable, pur domaine, aucune dépendance technique.
|
||||||
*/
|
*/
|
||||||
@Value
|
public record PageContext(
|
||||||
@Builder
|
String title,
|
||||||
public class PageContext {
|
String templateName,
|
||||||
|
List<String> templateFields,
|
||||||
String title;
|
Map<String, String> values) {
|
||||||
String templateName;
|
|
||||||
List<String> templateFields;
|
|
||||||
Map<String, String> values;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,12 +53,12 @@ public class BrainAiClient implements AiProvider {
|
|||||||
|
|
||||||
private BrainGeneratePageRequest toBrainRequest(GenerationContext context) {
|
private BrainGeneratePageRequest toBrainRequest(GenerationContext context) {
|
||||||
return new BrainGeneratePageRequest(
|
return new BrainGeneratePageRequest(
|
||||||
context.getLoreName(),
|
context.loreName(),
|
||||||
context.getLoreDescription(),
|
context.loreDescription(),
|
||||||
context.getFolderName(),
|
context.folderName(),
|
||||||
context.getTemplateName(),
|
context.templateName(),
|
||||||
context.getTemplateFields(),
|
context.templateFields(),
|
||||||
context.getPageTitle()
|
context.pageTitle()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import com.loremind.domain.generationcontext.CampaignStructuralContext.ArcSummar
|
|||||||
import com.loremind.domain.generationcontext.CampaignStructuralContext.BranchHint;
|
import com.loremind.domain.generationcontext.CampaignStructuralContext.BranchHint;
|
||||||
import com.loremind.domain.generationcontext.CampaignStructuralContext.ChapterSummary;
|
import com.loremind.domain.generationcontext.CampaignStructuralContext.ChapterSummary;
|
||||||
import com.loremind.domain.generationcontext.CampaignStructuralContext.CharacterSummary;
|
import com.loremind.domain.generationcontext.CampaignStructuralContext.CharacterSummary;
|
||||||
|
import com.loremind.domain.generationcontext.CampaignStructuralContext.NpcSummary;
|
||||||
import com.loremind.domain.generationcontext.CampaignStructuralContext.SceneSummary;
|
import com.loremind.domain.generationcontext.CampaignStructuralContext.SceneSummary;
|
||||||
import com.loremind.domain.generationcontext.ChatMessage;
|
import com.loremind.domain.generationcontext.ChatMessage;
|
||||||
import com.loremind.domain.generationcontext.ChatRequest;
|
import com.loremind.domain.generationcontext.ChatRequest;
|
||||||
@@ -38,35 +39,35 @@ public class BrainChatPayloadBuilder {
|
|||||||
|
|
||||||
public Map<String, Object> build(ChatRequest request) {
|
public Map<String, Object> build(ChatRequest request) {
|
||||||
Map<String, Object> root = new LinkedHashMap<>();
|
Map<String, Object> root = new LinkedHashMap<>();
|
||||||
root.put("messages", request.getMessages().stream()
|
root.put("messages", request.messages().stream()
|
||||||
.map(this::messageToMap)
|
.map(this::messageToMap)
|
||||||
.collect(Collectors.toList()));
|
.collect(Collectors.toList()));
|
||||||
|
|
||||||
if (request.getLoreContext() != null) {
|
if (request.loreContext() != null) {
|
||||||
root.put("lore_context", loreContextToMap(request.getLoreContext()));
|
root.put("lore_context", loreContextToMap(request.loreContext()));
|
||||||
}
|
}
|
||||||
if (request.getPageContext() != null) {
|
if (request.pageContext() != null) {
|
||||||
root.put("page_context", pageContextToMap(request.getPageContext()));
|
root.put("page_context", pageContextToMap(request.pageContext()));
|
||||||
}
|
}
|
||||||
if (request.getCampaignContext() != null) {
|
if (request.campaignContext() != null) {
|
||||||
root.put("campaign_context", campaignContextToMap(request.getCampaignContext()));
|
root.put("campaign_context", campaignContextToMap(request.campaignContext()));
|
||||||
}
|
}
|
||||||
if (request.getNarrativeEntity() != null) {
|
if (request.narrativeEntity() != null) {
|
||||||
root.put("narrative_entity", narrativeEntityToMap(request.getNarrativeEntity()));
|
root.put("narrative_entity", narrativeEntityToMap(request.narrativeEntity()));
|
||||||
}
|
}
|
||||||
if (request.getGameSystemContext() != null) {
|
if (request.gameSystemContext() != null) {
|
||||||
root.put("game_system_context", gameSystemContextToMap(request.getGameSystemContext()));
|
root.put("game_system_context", gameSystemContextToMap(request.gameSystemContext()));
|
||||||
}
|
}
|
||||||
return root;
|
return root;
|
||||||
}
|
}
|
||||||
|
|
||||||
private Map<String, Object> gameSystemContextToMap(GameSystemContext gs) {
|
private Map<String, Object> gameSystemContextToMap(GameSystemContext gs) {
|
||||||
Map<String, Object> map = new LinkedHashMap<>();
|
Map<String, Object> map = new LinkedHashMap<>();
|
||||||
map.put("system_name", gs.getSystemName());
|
map.put("system_name", gs.systemName());
|
||||||
if (gs.getSystemDescription() != null && !gs.getSystemDescription().isBlank()) {
|
if (gs.systemDescription() != null && !gs.systemDescription().isBlank()) {
|
||||||
map.put("system_description", gs.getSystemDescription());
|
map.put("system_description", gs.systemDescription());
|
||||||
}
|
}
|
||||||
map.put("sections", gs.getSections() != null ? gs.getSections() : Map.of());
|
map.put("sections", gs.sections() != null ? gs.sections() : Map.of());
|
||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,67 +80,82 @@ public class BrainChatPayloadBuilder {
|
|||||||
|
|
||||||
private Map<String, Object> loreContextToMap(LoreStructuralContext ctx) {
|
private Map<String, Object> loreContextToMap(LoreStructuralContext ctx) {
|
||||||
Map<String, Object> map = new LinkedHashMap<>();
|
Map<String, Object> map = new LinkedHashMap<>();
|
||||||
map.put("lore_name", ctx.getLoreName());
|
map.put("lore_name", ctx.loreName());
|
||||||
map.put("lore_description", ctx.getLoreDescription());
|
map.put("lore_description", ctx.loreDescription());
|
||||||
|
|
||||||
Map<String, Object> foldersMap = new LinkedHashMap<>();
|
Map<String, Object> foldersMap = new LinkedHashMap<>();
|
||||||
for (Map.Entry<String, List<PageSummary>> e : ctx.getFolders().entrySet()) {
|
for (Map.Entry<String, List<PageSummary>> e : ctx.folders().entrySet()) {
|
||||||
foldersMap.put(e.getKey(), e.getValue().stream()
|
foldersMap.put(e.getKey(), e.getValue().stream()
|
||||||
.map(this::pageSummaryToMap)
|
.map(this::pageSummaryToMap)
|
||||||
.collect(Collectors.toList()));
|
.collect(Collectors.toList()));
|
||||||
}
|
}
|
||||||
map.put("folders", foldersMap);
|
map.put("folders", foldersMap);
|
||||||
map.put("tags", ctx.getTags());
|
map.put("tags", ctx.tags());
|
||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
private Map<String, Object> pageSummaryToMap(PageSummary ps) {
|
private Map<String, Object> pageSummaryToMap(PageSummary ps) {
|
||||||
Map<String, Object> map = new LinkedHashMap<>();
|
Map<String, Object> map = new LinkedHashMap<>();
|
||||||
map.put("title", ps.getTitle());
|
map.put("title", ps.title());
|
||||||
map.put("template_name", ps.getTemplateName());
|
map.put("template_name", ps.templateName());
|
||||||
// values/tags/related_page_titles : omis si vides pour alléger le payload.
|
// values/tags/related_page_titles : omis si vides pour alléger le payload.
|
||||||
if (ps.getValues() != null && !ps.getValues().isEmpty()) {
|
if (ps.values() != null && !ps.values().isEmpty()) {
|
||||||
map.put("values", ps.getValues());
|
map.put("values", ps.values());
|
||||||
}
|
}
|
||||||
if (ps.getTags() != null && !ps.getTags().isEmpty()) {
|
if (ps.tags() != null && !ps.tags().isEmpty()) {
|
||||||
map.put("tags", ps.getTags());
|
map.put("tags", ps.tags());
|
||||||
}
|
}
|
||||||
if (ps.getRelatedPageTitles() != null && !ps.getRelatedPageTitles().isEmpty()) {
|
if (ps.relatedPageTitles() != null && !ps.relatedPageTitles().isEmpty()) {
|
||||||
map.put("related_page_titles", ps.getRelatedPageTitles());
|
map.put("related_page_titles", ps.relatedPageTitles());
|
||||||
}
|
}
|
||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
private Map<String, Object> pageContextToMap(PageContext pc) {
|
private Map<String, Object> pageContextToMap(PageContext pc) {
|
||||||
Map<String, Object> map = new LinkedHashMap<>();
|
Map<String, Object> map = new LinkedHashMap<>();
|
||||||
map.put("title", pc.getTitle());
|
map.put("title", pc.title());
|
||||||
map.put("template_name", pc.getTemplateName());
|
map.put("template_name", pc.templateName());
|
||||||
map.put("template_fields", pc.getTemplateFields());
|
map.put("template_fields", pc.templateFields());
|
||||||
map.put("values", pc.getValues());
|
map.put("values", pc.values());
|
||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
private Map<String, Object> campaignContextToMap(CampaignStructuralContext ctx) {
|
private Map<String, Object> campaignContextToMap(CampaignStructuralContext ctx) {
|
||||||
Map<String, Object> map = new LinkedHashMap<>();
|
Map<String, Object> map = new LinkedHashMap<>();
|
||||||
map.put("campaign_name", ctx.getCampaignName());
|
map.put("campaign_name", ctx.campaignName());
|
||||||
map.put("campaign_description", ctx.getCampaignDescription());
|
map.put("campaign_description", ctx.campaignDescription());
|
||||||
map.put("arcs", ctx.getArcs().stream()
|
map.put("arcs", ctx.arcs().stream()
|
||||||
.map(this::arcSummaryToMap)
|
.map(this::arcSummaryToMap)
|
||||||
.collect(Collectors.toList()));
|
.collect(Collectors.toList()));
|
||||||
// Liste des PJ : omise si aucun pour alléger le prompt des campagnes sans fiches.
|
// Liste des PJ : omise si aucun pour alléger le prompt des campagnes sans fiches.
|
||||||
if (ctx.getCharacters() != null && !ctx.getCharacters().isEmpty()) {
|
if (ctx.characters() != null && !ctx.characters().isEmpty()) {
|
||||||
map.put("characters", ctx.getCharacters().stream()
|
map.put("characters", ctx.characters().stream()
|
||||||
.map(this::characterSummaryToMap)
|
.map(this::characterSummaryToMap)
|
||||||
.collect(Collectors.toList()));
|
.collect(Collectors.toList()));
|
||||||
}
|
}
|
||||||
|
// Liste des PNJ : symétrique aux PJ, omise si vide pour alléger le payload.
|
||||||
|
if (ctx.npcs() != null && !ctx.npcs().isEmpty()) {
|
||||||
|
map.put("npcs", ctx.npcs().stream()
|
||||||
|
.map(this::npcSummaryToMap)
|
||||||
|
.collect(Collectors.toList()));
|
||||||
|
}
|
||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
private Map<String, Object> characterSummaryToMap(CharacterSummary c) {
|
private Map<String, Object> characterSummaryToMap(CharacterSummary c) {
|
||||||
Map<String, Object> map = new LinkedHashMap<>();
|
Map<String, Object> map = new LinkedHashMap<>();
|
||||||
map.put("name", c.getName());
|
map.put("name", c.name());
|
||||||
if (c.getSnippet() != null && !c.getSnippet().isBlank()) {
|
if (c.snippet() != null && !c.snippet().isBlank()) {
|
||||||
map.put("snippet", c.getSnippet());
|
map.put("snippet", c.snippet());
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> npcSummaryToMap(NpcSummary n) {
|
||||||
|
Map<String, Object> map = new LinkedHashMap<>();
|
||||||
|
map.put("name", n.name());
|
||||||
|
if (n.snippet() != null && !n.snippet().isBlank()) {
|
||||||
|
map.put("snippet", n.snippet());
|
||||||
}
|
}
|
||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
@@ -167,10 +183,10 @@ public class BrainChatPayloadBuilder {
|
|||||||
private Map<String, Object> arcSummaryToMap(ArcSummary a) {
|
private Map<String, Object> arcSummaryToMap(ArcSummary a) {
|
||||||
return structuralSummaryToMap(
|
return structuralSummaryToMap(
|
||||||
a,
|
a,
|
||||||
ArcSummary::getName,
|
ArcSummary::name,
|
||||||
ArcSummary::getDescription,
|
ArcSummary::description,
|
||||||
ArcSummary::getIllustrationCount,
|
ArcSummary::illustrationCount,
|
||||||
(map, arc) -> map.put("chapters", arc.getChapters().stream()
|
(map, arc) -> map.put("chapters", arc.chapters().stream()
|
||||||
.map(this::chapterSummaryToMap)
|
.map(this::chapterSummaryToMap)
|
||||||
.collect(Collectors.toList())));
|
.collect(Collectors.toList())));
|
||||||
}
|
}
|
||||||
@@ -178,10 +194,10 @@ public class BrainChatPayloadBuilder {
|
|||||||
private Map<String, Object> chapterSummaryToMap(ChapterSummary c) {
|
private Map<String, Object> chapterSummaryToMap(ChapterSummary c) {
|
||||||
return structuralSummaryToMap(
|
return structuralSummaryToMap(
|
||||||
c,
|
c,
|
||||||
ChapterSummary::getName,
|
ChapterSummary::name,
|
||||||
ChapterSummary::getDescription,
|
ChapterSummary::description,
|
||||||
ChapterSummary::getIllustrationCount,
|
ChapterSummary::illustrationCount,
|
||||||
(map, chapter) -> map.put("scenes", chapter.getScenes().stream()
|
(map, chapter) -> map.put("scenes", chapter.scenes().stream()
|
||||||
.map(this::sceneSummaryToMap)
|
.map(this::sceneSummaryToMap)
|
||||||
.collect(Collectors.toList())));
|
.collect(Collectors.toList())));
|
||||||
}
|
}
|
||||||
@@ -189,13 +205,13 @@ public class BrainChatPayloadBuilder {
|
|||||||
private Map<String, Object> sceneSummaryToMap(SceneSummary s) {
|
private Map<String, Object> sceneSummaryToMap(SceneSummary s) {
|
||||||
return structuralSummaryToMap(
|
return structuralSummaryToMap(
|
||||||
s,
|
s,
|
||||||
SceneSummary::getName,
|
SceneSummary::name,
|
||||||
SceneSummary::getDescription,
|
SceneSummary::description,
|
||||||
SceneSummary::getIllustrationCount,
|
SceneSummary::illustrationCount,
|
||||||
(map, scene) -> {
|
(map, scene) -> {
|
||||||
// Branches narratives : omises si absentes (scènes linéaires classiques).
|
// Branches narratives : omises si absentes (scènes linéaires classiques).
|
||||||
if (s.getBranches() != null && !s.getBranches().isEmpty()) {
|
if (s.branches() != null && !s.branches().isEmpty()) {
|
||||||
map.put("branches", s.getBranches().stream()
|
map.put("branches", s.branches().stream()
|
||||||
.map(this::branchHintToMap)
|
.map(this::branchHintToMap)
|
||||||
.collect(Collectors.toList()));
|
.collect(Collectors.toList()));
|
||||||
}
|
}
|
||||||
@@ -204,19 +220,19 @@ public class BrainChatPayloadBuilder {
|
|||||||
|
|
||||||
private Map<String, Object> branchHintToMap(BranchHint b) {
|
private Map<String, Object> branchHintToMap(BranchHint b) {
|
||||||
Map<String, Object> map = new LinkedHashMap<>();
|
Map<String, Object> map = new LinkedHashMap<>();
|
||||||
map.put("label", b.getLabel());
|
map.put("label", b.label());
|
||||||
map.put("target_scene_name", b.getTargetSceneName());
|
map.put("target_scene_name", b.targetSceneName());
|
||||||
if (b.getCondition() != null && !b.getCondition().isBlank()) {
|
if (b.condition() != null && !b.condition().isBlank()) {
|
||||||
map.put("condition", b.getCondition());
|
map.put("condition", b.condition());
|
||||||
}
|
}
|
||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
private Map<String, Object> narrativeEntityToMap(NarrativeEntityContext ne) {
|
private Map<String, Object> narrativeEntityToMap(NarrativeEntityContext ne) {
|
||||||
Map<String, Object> map = new LinkedHashMap<>();
|
Map<String, Object> map = new LinkedHashMap<>();
|
||||||
map.put("entity_type", ne.getEntityType());
|
map.put("entity_type", ne.entityType());
|
||||||
map.put("title", ne.getTitle());
|
map.put("title", ne.title());
|
||||||
map.put("fields", ne.getFields());
|
map.put("fields", ne.fields());
|
||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ public class ArcJpaEntity {
|
|||||||
@Column(name = "\"order\"", nullable = false)
|
@Column(name = "\"order\"", nullable = false)
|
||||||
private int order;
|
private int order;
|
||||||
|
|
||||||
|
@Column
|
||||||
|
private String icon;
|
||||||
|
|
||||||
// Champs narratifs enrichis — ajoutés automatiquement par Hibernate DDL (ddl-auto=update)
|
// Champs narratifs enrichis — ajoutés automatiquement par Hibernate DDL (ddl-auto=update)
|
||||||
@Column(columnDefinition = "TEXT")
|
@Column(columnDefinition = "TEXT")
|
||||||
private String themes;
|
private String themes;
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ public class ChapterJpaEntity {
|
|||||||
@Column(name = "\"order\"", nullable = false)
|
@Column(name = "\"order\"", nullable = false)
|
||||||
private int order;
|
private int order;
|
||||||
|
|
||||||
|
@Column
|
||||||
|
private String icon;
|
||||||
|
|
||||||
// Champs narratifs enrichis — ajoutés automatiquement par Hibernate DDL (ddl-auto=update)
|
// Champs narratifs enrichis — ajoutés automatiquement par Hibernate DDL (ddl-auto=update)
|
||||||
@Column(name = "gm_notes", columnDefinition = "TEXT")
|
@Column(name = "gm_notes", columnDefinition = "TEXT")
|
||||||
private String gmNotes;
|
private String gmNotes;
|
||||||
|
|||||||
@@ -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 les fiches de PNJ d'une campagne.
|
||||||
|
* Pas de FK physique vers campaigns (weak reference cross-agrégat intra-contexte).
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "npcs")
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class NpcJpaEntity {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
@Column(name = "markdown_content", columnDefinition = "TEXT")
|
||||||
|
private String markdownContent;
|
||||||
|
|
||||||
|
@Column(name = "campaign_id", nullable = false)
|
||||||
|
private Long campaignId;
|
||||||
|
|
||||||
|
@Column(name = "\"order\"", nullable = false)
|
||||||
|
private int order;
|
||||||
|
|
||||||
|
@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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,6 +39,9 @@ public class SceneJpaEntity {
|
|||||||
@Column(name = "\"order\"", nullable = false)
|
@Column(name = "\"order\"", nullable = false)
|
||||||
private int order;
|
private int order;
|
||||||
|
|
||||||
|
@Column
|
||||||
|
private String icon;
|
||||||
|
|
||||||
// Champs narratifs enrichis — ajoutés automatiquement par Hibernate (ddl-auto=update)
|
// Champs narratifs enrichis — ajoutés automatiquement par Hibernate (ddl-auto=update)
|
||||||
|
|
||||||
// Contexte et ambiance
|
// Contexte et ambiance
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package com.loremind.infrastructure.persistence.jpa;
|
||||||
|
|
||||||
|
import com.loremind.infrastructure.persistence.entity.NpcJpaEntity;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface NpcJpaRepository extends JpaRepository<NpcJpaEntity, Long> {
|
||||||
|
|
||||||
|
List<NpcJpaEntity> findByCampaignIdOrderByOrderAsc(Long campaignId);
|
||||||
|
}
|
||||||
@@ -71,6 +71,7 @@ public class PostgresArcRepository implements ArcRepository {
|
|||||||
.description(jpaEntity.getDescription())
|
.description(jpaEntity.getDescription())
|
||||||
.campaignId(jpaEntity.getCampaignId().toString())
|
.campaignId(jpaEntity.getCampaignId().toString())
|
||||||
.order(jpaEntity.getOrder())
|
.order(jpaEntity.getOrder())
|
||||||
|
.icon(jpaEntity.getIcon())
|
||||||
.themes(jpaEntity.getThemes())
|
.themes(jpaEntity.getThemes())
|
||||||
.stakes(jpaEntity.getStakes())
|
.stakes(jpaEntity.getStakes())
|
||||||
.gmNotes(jpaEntity.getGmNotes())
|
.gmNotes(jpaEntity.getGmNotes())
|
||||||
@@ -99,6 +100,7 @@ public class PostgresArcRepository implements ArcRepository {
|
|||||||
.description(arc.getDescription())
|
.description(arc.getDescription())
|
||||||
.campaignId(Long.parseLong(arc.getCampaignId()))
|
.campaignId(Long.parseLong(arc.getCampaignId()))
|
||||||
.order(arc.getOrder())
|
.order(arc.getOrder())
|
||||||
|
.icon(arc.getIcon())
|
||||||
.themes(arc.getThemes())
|
.themes(arc.getThemes())
|
||||||
.stakes(arc.getStakes())
|
.stakes(arc.getStakes())
|
||||||
.gmNotes(arc.getGmNotes())
|
.gmNotes(arc.getGmNotes())
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ public class PostgresChapterRepository implements ChapterRepository {
|
|||||||
.description(jpaEntity.getDescription())
|
.description(jpaEntity.getDescription())
|
||||||
.arcId(jpaEntity.getArcId().toString())
|
.arcId(jpaEntity.getArcId().toString())
|
||||||
.order(jpaEntity.getOrder())
|
.order(jpaEntity.getOrder())
|
||||||
|
.icon(jpaEntity.getIcon())
|
||||||
.gmNotes(jpaEntity.getGmNotes())
|
.gmNotes(jpaEntity.getGmNotes())
|
||||||
.playerObjectives(jpaEntity.getPlayerObjectives())
|
.playerObjectives(jpaEntity.getPlayerObjectives())
|
||||||
.narrativeStakes(jpaEntity.getNarrativeStakes())
|
.narrativeStakes(jpaEntity.getNarrativeStakes())
|
||||||
@@ -96,6 +97,7 @@ public class PostgresChapterRepository implements ChapterRepository {
|
|||||||
.description(chapter.getDescription())
|
.description(chapter.getDescription())
|
||||||
.arcId(Long.parseLong(chapter.getArcId()))
|
.arcId(Long.parseLong(chapter.getArcId()))
|
||||||
.order(chapter.getOrder())
|
.order(chapter.getOrder())
|
||||||
|
.icon(chapter.getIcon())
|
||||||
.gmNotes(chapter.getGmNotes())
|
.gmNotes(chapter.getGmNotes())
|
||||||
.playerObjectives(chapter.getPlayerObjectives())
|
.playerObjectives(chapter.getPlayerObjectives())
|
||||||
.narrativeStakes(chapter.getNarrativeStakes())
|
.narrativeStakes(chapter.getNarrativeStakes())
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package com.loremind.infrastructure.persistence.postgres;
|
||||||
|
|
||||||
|
import com.loremind.domain.campaigncontext.Npc;
|
||||||
|
import com.loremind.domain.campaigncontext.ports.NpcRepository;
|
||||||
|
import com.loremind.infrastructure.persistence.entity.NpcJpaEntity;
|
||||||
|
import com.loremind.infrastructure.persistence.jpa.NpcJpaRepository;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public class PostgresNpcRepository implements NpcRepository {
|
||||||
|
|
||||||
|
private final NpcJpaRepository jpaRepository;
|
||||||
|
|
||||||
|
public PostgresNpcRepository(NpcJpaRepository jpaRepository) {
|
||||||
|
this.jpaRepository = jpaRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Npc save(Npc npc) {
|
||||||
|
NpcJpaEntity entity = toJpaEntity(npc);
|
||||||
|
NpcJpaEntity saved = jpaRepository.save(entity);
|
||||||
|
return toDomainEntity(saved);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<Npc> findById(String id) {
|
||||||
|
return jpaRepository.findById(Long.parseLong(id)).map(this::toDomainEntity);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<Npc> findByCampaignId(String campaignId) {
|
||||||
|
return jpaRepository.findByCampaignIdOrderByOrderAsc(Long.parseLong(campaignId)).stream()
|
||||||
|
.map(this::toDomainEntity)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void deleteById(String id) {
|
||||||
|
jpaRepository.deleteById(Long.parseLong(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean existsById(String id) {
|
||||||
|
return jpaRepository.existsById(Long.parseLong(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Npc toDomainEntity(NpcJpaEntity e) {
|
||||||
|
return Npc.builder()
|
||||||
|
.id(e.getId().toString())
|
||||||
|
.name(e.getName())
|
||||||
|
.markdownContent(e.getMarkdownContent())
|
||||||
|
.campaignId(e.getCampaignId().toString())
|
||||||
|
.order(e.getOrder())
|
||||||
|
.createdAt(e.getCreatedAt())
|
||||||
|
.updatedAt(e.getUpdatedAt())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private NpcJpaEntity toJpaEntity(Npc n) {
|
||||||
|
Long id = n.getId() != null ? Long.parseLong(n.getId()) : null;
|
||||||
|
return NpcJpaEntity.builder()
|
||||||
|
.id(id)
|
||||||
|
.name(n.getName())
|
||||||
|
.markdownContent(n.getMarkdownContent())
|
||||||
|
.campaignId(Long.parseLong(n.getCampaignId()))
|
||||||
|
.order(n.getOrder())
|
||||||
|
.createdAt(n.getCreatedAt())
|
||||||
|
.updatedAt(n.getUpdatedAt())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -71,6 +71,7 @@ public class PostgresSceneRepository implements SceneRepository {
|
|||||||
.description(jpaEntity.getDescription())
|
.description(jpaEntity.getDescription())
|
||||||
.chapterId(jpaEntity.getChapterId().toString())
|
.chapterId(jpaEntity.getChapterId().toString())
|
||||||
.order(jpaEntity.getOrder())
|
.order(jpaEntity.getOrder())
|
||||||
|
.icon(jpaEntity.getIcon())
|
||||||
.location(jpaEntity.getLocation())
|
.location(jpaEntity.getLocation())
|
||||||
.timing(jpaEntity.getTiming())
|
.timing(jpaEntity.getTiming())
|
||||||
.atmosphere(jpaEntity.getAtmosphere())
|
.atmosphere(jpaEntity.getAtmosphere())
|
||||||
@@ -104,6 +105,7 @@ public class PostgresSceneRepository implements SceneRepository {
|
|||||||
.description(scene.getDescription())
|
.description(scene.getDescription())
|
||||||
.chapterId(Long.parseLong(scene.getChapterId()))
|
.chapterId(Long.parseLong(scene.getChapterId()))
|
||||||
.order(scene.getOrder())
|
.order(scene.getOrder())
|
||||||
|
.icon(scene.getIcon())
|
||||||
.location(scene.getLocation())
|
.location(scene.getLocation())
|
||||||
.timing(scene.getTiming())
|
.timing(scene.getTiming())
|
||||||
.atmosphere(scene.getAtmosphere())
|
.atmosphere(scene.getAtmosphere())
|
||||||
|
|||||||
@@ -0,0 +1,292 @@
|
|||||||
|
package com.loremind.infrastructure.updates;
|
||||||
|
|
||||||
|
import jakarta.annotation.PostConstruct;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.boot.web.client.RestTemplateBuilder;
|
||||||
|
import org.springframework.http.HttpEntity;
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
|
import org.springframework.http.HttpMethod;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.web.client.HttpClientErrorException;
|
||||||
|
import org.springframework.web.client.RestTemplate;
|
||||||
|
|
||||||
|
import java.net.URLEncoder;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detection des mises a jour disponibles + declenchement via Watchtower.
|
||||||
|
*
|
||||||
|
* Strategie :
|
||||||
|
* - Au demarrage, on interroge le registry pour le digest courant de chaque
|
||||||
|
* image suivie ({@code update-check.images}). On stocke ces digests comme
|
||||||
|
* "baseline" (= ce que le conteneur en cours d'execution est cense faire
|
||||||
|
* tourner, puisque le `docker compose pull` precede toujours `up -d`).
|
||||||
|
* - {@link #check()} re-interroge le registry et compare. Si un digest a
|
||||||
|
* change, une mise a jour est disponible.
|
||||||
|
* - {@link #apply()} POST sur /v1/update de Watchtower (qui doit etre lance
|
||||||
|
* avec WATCHTOWER_HTTP_API_UPDATE=true et le meme token).
|
||||||
|
*
|
||||||
|
* Apres un apply reussi, Watchtower redemarre core => ce service est
|
||||||
|
* re-instancie => baseline re-aligne sur le registry => check renvoie
|
||||||
|
* "pas de MAJ" (etat coherent).
|
||||||
|
*
|
||||||
|
* La feature est <b>desactivee silencieusement</b> si {@code WATCHTOWER_TOKEN}
|
||||||
|
* n'est pas defini : check/apply renvoient des reponses neutres et l'UI
|
||||||
|
* masque le badge / bouton.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class UpdateCheckService {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(UpdateCheckService.class);
|
||||||
|
|
||||||
|
private static final List<MediaType> MANIFEST_ACCEPT = List.of(
|
||||||
|
MediaType.parseMediaType("application/vnd.docker.distribution.manifest.v2+json"),
|
||||||
|
MediaType.parseMediaType("application/vnd.docker.distribution.manifest.list.v2+json"),
|
||||||
|
MediaType.parseMediaType("application/vnd.oci.image.manifest.v1+json"),
|
||||||
|
MediaType.parseMediaType("application/vnd.oci.image.index.v1+json")
|
||||||
|
);
|
||||||
|
|
||||||
|
private final RestTemplate http;
|
||||||
|
private final String registry;
|
||||||
|
private final List<String> images;
|
||||||
|
private final String tag;
|
||||||
|
private final String watchtowerUrl;
|
||||||
|
private final String watchtowerToken;
|
||||||
|
|
||||||
|
private final Map<String, String> baselineDigests = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
public UpdateCheckService(
|
||||||
|
RestTemplateBuilder builder,
|
||||||
|
@Value("${update-check.registry:}") String registry,
|
||||||
|
@Value("${update-check.images:}") String imagesCsv,
|
||||||
|
@Value("${update-check.tag:latest}") String tag,
|
||||||
|
@Value("${update-check.watchtower-url:http://watchtower:8080}") String watchtowerUrl,
|
||||||
|
@Value("${update-check.watchtower-token:}") String watchtowerToken) {
|
||||||
|
this.http = builder
|
||||||
|
.setConnectTimeout(Duration.ofSeconds(5))
|
||||||
|
.setReadTimeout(Duration.ofSeconds(15))
|
||||||
|
.build();
|
||||||
|
this.registry = normalizeRegistry(registry);
|
||||||
|
this.images = parseImages(imagesCsv);
|
||||||
|
this.tag = tag;
|
||||||
|
this.watchtowerUrl = watchtowerUrl;
|
||||||
|
this.watchtowerToken = watchtowerToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostConstruct
|
||||||
|
void initBaseline() {
|
||||||
|
if (!isEnabled()) {
|
||||||
|
log.info("Update check disabled (WATCHTOWER_TOKEN not set)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
log.info("Update check enabled - registry={} images={} tag={}", registry, images, tag);
|
||||||
|
for (String image : images) {
|
||||||
|
try {
|
||||||
|
String digest = fetchRemoteDigest(image);
|
||||||
|
if (digest != null) {
|
||||||
|
baselineDigests.put(image, digest);
|
||||||
|
log.debug("Baseline digest for {} = {}", image, digest);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Cannot baseline digest for {}: {}", image, e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isEnabled() {
|
||||||
|
return watchtowerToken != null && !watchtowerToken.isBlank() && !images.isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
public UpdateStatus check() {
|
||||||
|
if (!isEnabled()) {
|
||||||
|
return new UpdateStatus(false, false, List.of(), Instant.now());
|
||||||
|
}
|
||||||
|
List<ImageStatus> statuses = new ArrayList<>();
|
||||||
|
boolean anyUpdate = false;
|
||||||
|
for (String image : images) {
|
||||||
|
String baseline = baselineDigests.get(image);
|
||||||
|
String remote = null;
|
||||||
|
try {
|
||||||
|
remote = fetchRemoteDigest(image);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Check failed for {}: {}", image, e.getMessage());
|
||||||
|
}
|
||||||
|
// Si on n'a pas de baseline (echec au boot), on l'aligne maintenant
|
||||||
|
// pour eviter un faux positif "MAJ dispo".
|
||||||
|
if (baseline == null && remote != null) {
|
||||||
|
baselineDigests.put(image, remote);
|
||||||
|
baseline = remote;
|
||||||
|
}
|
||||||
|
boolean updateAvailable = baseline != null && remote != null && !baseline.equals(remote);
|
||||||
|
if (updateAvailable) anyUpdate = true;
|
||||||
|
statuses.add(new ImageStatus(image, baseline, remote, updateAvailable));
|
||||||
|
}
|
||||||
|
return new UpdateStatus(true, anyUpdate, statuses, Instant.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
public void apply() {
|
||||||
|
if (!isEnabled()) {
|
||||||
|
throw new IllegalStateException("Update apply not configured (WATCHTOWER_TOKEN missing)");
|
||||||
|
}
|
||||||
|
HttpHeaders headers = new HttpHeaders();
|
||||||
|
headers.setBearerAuth(watchtowerToken);
|
||||||
|
// Watchtower /v1/update declenche un scan+update immediat de tous les
|
||||||
|
// conteneurs labellises. La reponse est synchrone et peut prendre
|
||||||
|
// plusieurs secondes; en cas de redemarrage de core, le client
|
||||||
|
// recevra une connexion coupee — c'est attendu, l'UI le gere.
|
||||||
|
http.exchange(
|
||||||
|
watchtowerUrl + "/v1/update",
|
||||||
|
HttpMethod.POST,
|
||||||
|
new HttpEntity<>(headers),
|
||||||
|
Void.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Registry HTTP API v2
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
private String fetchRemoteDigest(String image) {
|
||||||
|
String url = registry + "/v2/" + image + "/manifests/" + tag;
|
||||||
|
HttpHeaders headers = new HttpHeaders();
|
||||||
|
headers.setAccept(MANIFEST_ACCEPT);
|
||||||
|
try {
|
||||||
|
return digestCall(url, headers);
|
||||||
|
} catch (HttpClientErrorException.Unauthorized e) {
|
||||||
|
String www = e.getResponseHeaders() == null ? null
|
||||||
|
: e.getResponseHeaders().getFirst(HttpHeaders.WWW_AUTHENTICATE);
|
||||||
|
String token = obtainBearerToken(www);
|
||||||
|
if (token == null) {
|
||||||
|
log.warn("Cannot obtain bearer token for {} (registry response: {})", image, www);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
headers.setBearerAuth(token);
|
||||||
|
return digestCall(url, headers);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String digestCall(String url, HttpHeaders headers) {
|
||||||
|
ResponseEntity<Void> resp = http.exchange(
|
||||||
|
url, HttpMethod.HEAD, new HttpEntity<>(headers), Void.class);
|
||||||
|
return resp.getHeaders().getFirst("Docker-Content-Digest");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Suit le challenge {@code WWW-Authenticate: Bearer realm="...",service="...",scope="..."}
|
||||||
|
* pour obtenir un jeton (anonyme — suffisant pour les images publiques).
|
||||||
|
*/
|
||||||
|
@SuppressWarnings("rawtypes")
|
||||||
|
private String obtainBearerToken(String wwwAuth) {
|
||||||
|
if (wwwAuth == null) return null;
|
||||||
|
String prefix = "Bearer ";
|
||||||
|
if (!wwwAuth.regionMatches(true, 0, prefix, 0, prefix.length())) return null;
|
||||||
|
Map<String, String> params = parseAuthParams(wwwAuth.substring(prefix.length()));
|
||||||
|
String realm = params.get("realm");
|
||||||
|
if (realm == null) return null;
|
||||||
|
StringBuilder url = new StringBuilder(realm);
|
||||||
|
boolean hasQuery = realm.contains("?");
|
||||||
|
for (String key : new String[]{"service", "scope"}) {
|
||||||
|
String v = params.get(key);
|
||||||
|
if (v != null) {
|
||||||
|
// URLEncoder fait du "form encoding" qui transforme `:` et `/`
|
||||||
|
// en %3A et %2F. La plupart des registries (Docker Hub, Gitea)
|
||||||
|
// acceptent les deux, mais GHCR est strict et rejette le scope
|
||||||
|
// encode (403 DENIED). On preserve donc `:` et `/` dans la
|
||||||
|
// valeur, conformement a ce que GHCR attend
|
||||||
|
// (et que docker pull lui-meme envoie).
|
||||||
|
String encoded = URLEncoder.encode(v, StandardCharsets.UTF_8)
|
||||||
|
.replace("%3A", ":")
|
||||||
|
.replace("%2F", "/");
|
||||||
|
url.append(hasQuery ? '&' : '?')
|
||||||
|
.append(key).append('=')
|
||||||
|
.append(encoded);
|
||||||
|
hasQuery = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
ResponseEntity<Map> resp = http.getForEntity(url.toString(), Map.class);
|
||||||
|
Map<?, ?> body = resp.getBody();
|
||||||
|
if (body == null) return null;
|
||||||
|
Object t = body.get("token");
|
||||||
|
if (t == null) t = body.get("access_token");
|
||||||
|
return t == null ? null : t.toString();
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Bearer token request failed: {}", e.getMessage());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parser minimaliste pour {@code key="value", key2="value2"}. */
|
||||||
|
private static Map<String, String> parseAuthParams(String s) {
|
||||||
|
Map<String, String> out = new HashMap<>();
|
||||||
|
int i = 0;
|
||||||
|
int n = s.length();
|
||||||
|
while (i < n) {
|
||||||
|
while (i < n && (s.charAt(i) == ',' || s.charAt(i) == ' ')) i++;
|
||||||
|
int eq = s.indexOf('=', i);
|
||||||
|
if (eq < 0) break;
|
||||||
|
String key = s.substring(i, eq).trim();
|
||||||
|
int valStart = eq + 1;
|
||||||
|
String val;
|
||||||
|
if (valStart < n && s.charAt(valStart) == '"') {
|
||||||
|
int valEnd = s.indexOf('"', valStart + 1);
|
||||||
|
if (valEnd < 0) break;
|
||||||
|
val = s.substring(valStart + 1, valEnd);
|
||||||
|
i = valEnd + 1;
|
||||||
|
} else {
|
||||||
|
int valEnd = s.indexOf(',', valStart);
|
||||||
|
if (valEnd < 0) valEnd = n;
|
||||||
|
val = s.substring(valStart, valEnd).trim();
|
||||||
|
i = valEnd;
|
||||||
|
}
|
||||||
|
out.put(key, val);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String normalizeRegistry(String value) {
|
||||||
|
if (value == null || value.isBlank()) return "";
|
||||||
|
String v = value.trim();
|
||||||
|
if (!v.startsWith("http://") && !v.startsWith("https://")) {
|
||||||
|
v = "https://" + v;
|
||||||
|
}
|
||||||
|
if (v.endsWith("/")) v = v.substring(0, v.length() - 1);
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<String> parseImages(String csv) {
|
||||||
|
if (csv == null || csv.isBlank()) return List.of();
|
||||||
|
List<String> out = new ArrayList<>();
|
||||||
|
for (String part : csv.split(",")) {
|
||||||
|
String p = part.trim();
|
||||||
|
if (!p.isEmpty()) out.add(p);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Records de retour (sortis sous forme JSON par Jackson)
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
public record UpdateStatus(
|
||||||
|
boolean enabled,
|
||||||
|
boolean updateAvailable,
|
||||||
|
List<ImageStatus> images,
|
||||||
|
Instant checkedAt) {}
|
||||||
|
|
||||||
|
public record ImageStatus(
|
||||||
|
String image,
|
||||||
|
String localDigest,
|
||||||
|
String remoteDigest,
|
||||||
|
boolean updateAvailable) {}
|
||||||
|
}
|
||||||
@@ -66,6 +66,7 @@ public class SecurityConfig {
|
|||||||
// Preflight CORS toujours libre (le browser n'envoie pas Authorization sur OPTIONS)
|
// Preflight CORS toujours libre (le browser n'envoie pas Authorization sur OPTIONS)
|
||||||
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
|
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
|
||||||
.requestMatchers("/api/settings/**").hasRole("ADMIN")
|
.requestMatchers("/api/settings/**").hasRole("ADMIN")
|
||||||
|
.requestMatchers("/api/admin/**").hasRole("ADMIN")
|
||||||
.anyRequest().permitAll()
|
.anyRequest().permitAll()
|
||||||
)
|
)
|
||||||
.httpBasic(basic -> {});
|
.httpBasic(basic -> {});
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ public class ArcController {
|
|||||||
@PostMapping
|
@PostMapping
|
||||||
public ResponseEntity<ArcDTO> createArc(@RequestBody ArcDTO arcDTO) {
|
public ResponseEntity<ArcDTO> createArc(@RequestBody ArcDTO arcDTO) {
|
||||||
Arc arc = arcMapper.toDomain(arcDTO);
|
Arc arc = arcMapper.toDomain(arcDTO);
|
||||||
Arc createdArc = arcService.createArc(arc.getName(), arc.getDescription(), arc.getCampaignId(), arc.getOrder());
|
Arc createdArc = arcService.createArc(arc.getName(), arc.getDescription(), arc.getCampaignId(), arc.getOrder(), arc.getIcon());
|
||||||
return ResponseEntity.ok(arcMapper.toDTO(createdArc));
|
return ResponseEntity.ok(arcMapper.toDTO(createdArc));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,17 +40,11 @@ public class ArcController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
public ResponseEntity<List<ArcDTO>> getAllArcs() {
|
public ResponseEntity<List<ArcDTO>> getAllArcs(
|
||||||
List<Arc> arcs = arcService.getAllArcs();
|
@RequestParam(value = "campaignId", required = false) String campaignId) {
|
||||||
List<ArcDTO> arcDTOs = arcs.stream()
|
List<Arc> arcs = (campaignId != null && !campaignId.isBlank())
|
||||||
.map(arcMapper::toDTO)
|
? arcService.getArcsByCampaignId(campaignId)
|
||||||
.collect(Collectors.toList());
|
: arcService.getAllArcs();
|
||||||
return ResponseEntity.ok(arcDTOs);
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/campaign/{campaignId}")
|
|
||||||
public ResponseEntity<List<ArcDTO>> getArcsByCampaignId(@PathVariable String campaignId) {
|
|
||||||
List<Arc> arcs = arcService.getArcsByCampaignId(campaignId);
|
|
||||||
List<ArcDTO> arcDTOs = arcs.stream()
|
List<ArcDTO> arcDTOs = arcs.stream()
|
||||||
.map(arcMapper::toDTO)
|
.map(arcMapper::toDTO)
|
||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
@@ -68,4 +62,12 @@ public class ArcController {
|
|||||||
arcService.deleteArc(id);
|
arcService.deleteArc(id);
|
||||||
return ResponseEntity.noContent().build();
|
return ResponseEntity.noContent().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}/deletion-impact")
|
||||||
|
public ResponseEntity<ArcService.DeletionImpact> getDeletionImpact(@PathVariable String id) {
|
||||||
|
if (!arcService.arcExists(id)) {
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
return ResponseEntity.ok(arcService.getDeletionImpact(id));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,4 +74,16 @@ public class CampaignController {
|
|||||||
campaignService.deleteCampaign(id);
|
campaignService.deleteCampaign(id);
|
||||||
return ResponseEntity.noContent().build();
|
return ResponseEntity.noContent().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Récapitulatif des entités qui seront supprimées en cascade : utilisé par
|
||||||
|
* l'UI pour afficher "X arcs, Y chapitres, Z scènes..." dans la confirmation.
|
||||||
|
*/
|
||||||
|
@GetMapping("/{id}/deletion-impact")
|
||||||
|
public ResponseEntity<CampaignService.DeletionImpact> getDeletionImpact(@PathVariable String id) {
|
||||||
|
if (!campaignService.campaignExists(id)) {
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
return ResponseEntity.ok(campaignService.getDeletionImpact(id));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ public class ChapterController {
|
|||||||
@PostMapping
|
@PostMapping
|
||||||
public ResponseEntity<ChapterDTO> createChapter(@RequestBody ChapterDTO chapterDTO) {
|
public ResponseEntity<ChapterDTO> createChapter(@RequestBody ChapterDTO chapterDTO) {
|
||||||
Chapter chapter = chapterMapper.toDomain(chapterDTO);
|
Chapter chapter = chapterMapper.toDomain(chapterDTO);
|
||||||
Chapter createdChapter = chapterService.createChapter(chapter.getName(), chapter.getDescription(), chapter.getArcId(), chapter.getOrder());
|
Chapter createdChapter = chapterService.createChapter(chapter.getName(), chapter.getDescription(), chapter.getArcId(), chapter.getOrder(), chapter.getIcon());
|
||||||
return ResponseEntity.ok(chapterMapper.toDTO(createdChapter));
|
return ResponseEntity.ok(chapterMapper.toDTO(createdChapter));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,17 +40,11 @@ public class ChapterController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
public ResponseEntity<List<ChapterDTO>> getAllChapters() {
|
public ResponseEntity<List<ChapterDTO>> getAllChapters(
|
||||||
List<Chapter> chapters = chapterService.getAllChapters();
|
@RequestParam(value = "arcId", required = false) String arcId) {
|
||||||
List<ChapterDTO> chapterDTOs = chapters.stream()
|
List<Chapter> chapters = (arcId != null && !arcId.isBlank())
|
||||||
.map(chapterMapper::toDTO)
|
? chapterService.getChaptersByArcId(arcId)
|
||||||
.collect(Collectors.toList());
|
: chapterService.getAllChapters();
|
||||||
return ResponseEntity.ok(chapterDTOs);
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/arc/{arcId}")
|
|
||||||
public ResponseEntity<List<ChapterDTO>> getChaptersByArcId(@PathVariable String arcId) {
|
|
||||||
List<Chapter> chapters = chapterService.getChaptersByArcId(arcId);
|
|
||||||
List<ChapterDTO> chapterDTOs = chapters.stream()
|
List<ChapterDTO> chapterDTOs = chapters.stream()
|
||||||
.map(chapterMapper::toDTO)
|
.map(chapterMapper::toDTO)
|
||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
@@ -68,4 +62,12 @@ public class ChapterController {
|
|||||||
chapterService.deleteChapter(id);
|
chapterService.deleteChapter(id);
|
||||||
return ResponseEntity.noContent().build();
|
return ResponseEntity.noContent().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}/deletion-impact")
|
||||||
|
public ResponseEntity<ChapterService.DeletionImpact> getDeletionImpact(@PathVariable String id) {
|
||||||
|
if (!chapterService.chapterExists(id)) {
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
return ResponseEntity.ok(chapterService.getDeletionImpact(id));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package com.loremind.infrastructure.web.controller;
|
||||||
|
|
||||||
|
import com.loremind.infrastructure.updates.UpdateCheckService;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expose la configuration publique consommee par le frontend au demarrage.
|
||||||
|
* Activer le mode demo via la variable d'env DEMO_MODE=true : le front
|
||||||
|
* masque alors Settings / Export VTT, et les endpoints sensibles sont
|
||||||
|
* verrouilles cote serveur (cf. SettingsController).
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/config")
|
||||||
|
public class ConfigController {
|
||||||
|
|
||||||
|
private final boolean demoMode;
|
||||||
|
private final UpdateCheckService updates;
|
||||||
|
|
||||||
|
public ConfigController(@Value("${app.demo-mode:false}") boolean demoMode,
|
||||||
|
UpdateCheckService updates) {
|
||||||
|
this.demoMode = demoMode;
|
||||||
|
this.updates = updates;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public Map<String, Object> getPublicConfig() {
|
||||||
|
return Map.of(
|
||||||
|
"demoMode", demoMode,
|
||||||
|
"updateCheckEnabled", updates.isEnabled());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -69,4 +69,17 @@ public class LoreController {
|
|||||||
loreService.deleteLore(id);
|
loreService.deleteLore(id);
|
||||||
return ResponseEntity.noContent().build();
|
return ResponseEntity.noContent().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Récapitulatif des entités qui seront supprimées / détachées en cascade.
|
||||||
|
* Utilisé par l'UI pour afficher "X dossiers, Y pages, Z templates,
|
||||||
|
* N campagne(s) détachée(s)" dans la confirmation.
|
||||||
|
*/
|
||||||
|
@GetMapping("/{id}/deletion-impact")
|
||||||
|
public ResponseEntity<LoreService.DeletionImpact> getDeletionImpact(@PathVariable String id) {
|
||||||
|
if (loreService.getLoreById(id).isEmpty()) {
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
return ResponseEntity.ok(loreService.getDeletionImpact(id));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,4 +97,16 @@ public class LoreNodeController {
|
|||||||
loreNodeService.deleteLoreNode(id);
|
loreNodeService.deleteLoreNode(id);
|
||||||
return ResponseEntity.noContent().build();
|
return ResponseEntity.noContent().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Récapitulatif des entités qui seront supprimées en cascade : utilisé par
|
||||||
|
* l'UI pour afficher "X sous-dossiers, Y pages..." dans la confirmation.
|
||||||
|
*/
|
||||||
|
@GetMapping("/{id}/deletion-impact")
|
||||||
|
public ResponseEntity<LoreNodeService.DeletionImpact> getDeletionImpact(@PathVariable String id) {
|
||||||
|
if (loreNodeService.getLoreNodeById(id).isEmpty()) {
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
return ResponseEntity.ok(loreNodeService.getDeletionImpact(id));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package com.loremind.infrastructure.web.controller;
|
||||||
|
|
||||||
|
import com.loremind.application.campaigncontext.NpcService;
|
||||||
|
import com.loremind.domain.campaigncontext.Npc;
|
||||||
|
import com.loremind.infrastructure.web.dto.campaigncontext.NpcDTO;
|
||||||
|
import com.loremind.infrastructure.web.mapper.NpcMapper;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/npcs")
|
||||||
|
public class NpcController {
|
||||||
|
|
||||||
|
private final NpcService npcService;
|
||||||
|
private final NpcMapper npcMapper;
|
||||||
|
|
||||||
|
public NpcController(NpcService npcService, NpcMapper npcMapper) {
|
||||||
|
this.npcService = npcService;
|
||||||
|
this.npcMapper = npcMapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
public ResponseEntity<NpcDTO> createNpc(@RequestBody NpcDTO dto) {
|
||||||
|
Npc created = npcService.createNpc(
|
||||||
|
new NpcService.NpcData(dto.getName(), dto.getMarkdownContent(), dto.getCampaignId(), null)
|
||||||
|
);
|
||||||
|
return ResponseEntity.ok(npcMapper.toDTO(created));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
public ResponseEntity<NpcDTO> getNpcById(@PathVariable String id) {
|
||||||
|
return npcService.getNpcById(id)
|
||||||
|
.map(n -> ResponseEntity.ok(npcMapper.toDTO(n)))
|
||||||
|
.orElse(ResponseEntity.notFound().build());
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/campaign/{campaignId}")
|
||||||
|
public ResponseEntity<List<NpcDTO>> getNpcsByCampaign(@PathVariable String campaignId) {
|
||||||
|
List<NpcDTO> dtos = npcService.getNpcsByCampaignId(campaignId).stream()
|
||||||
|
.map(npcMapper::toDTO)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
return ResponseEntity.ok(dtos);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{id}")
|
||||||
|
public ResponseEntity<NpcDTO> updateNpc(@PathVariable String id, @RequestBody NpcDTO dto) {
|
||||||
|
Npc updated = npcService.updateNpc(
|
||||||
|
id,
|
||||||
|
new NpcService.NpcData(dto.getName(), dto.getMarkdownContent(), dto.getCampaignId(), dto.getOrder())
|
||||||
|
);
|
||||||
|
return ResponseEntity.ok(npcMapper.toDTO(updated));
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/{id}")
|
||||||
|
public ResponseEntity<Void> deleteNpc(@PathVariable String id) {
|
||||||
|
npcService.deleteNpc(id);
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,7 +28,7 @@ public class SceneController {
|
|||||||
@PostMapping
|
@PostMapping
|
||||||
public ResponseEntity<SceneDTO> createScene(@RequestBody SceneDTO sceneDTO) {
|
public ResponseEntity<SceneDTO> createScene(@RequestBody SceneDTO sceneDTO) {
|
||||||
Scene scene = sceneMapper.toDomain(sceneDTO);
|
Scene scene = sceneMapper.toDomain(sceneDTO);
|
||||||
Scene createdScene = sceneService.createScene(scene.getName(), scene.getDescription(), scene.getChapterId(), scene.getOrder());
|
Scene createdScene = sceneService.createScene(scene.getName(), scene.getDescription(), scene.getChapterId(), scene.getOrder(), scene.getIcon());
|
||||||
return ResponseEntity.ok(sceneMapper.toDTO(createdScene));
|
return ResponseEntity.ok(sceneMapper.toDTO(createdScene));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,17 +40,11 @@ public class SceneController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
public ResponseEntity<List<SceneDTO>> getAllScenes() {
|
public ResponseEntity<List<SceneDTO>> getAllScenes(
|
||||||
List<Scene> scenes = sceneService.getAllScenes();
|
@RequestParam(value = "chapterId", required = false) String chapterId) {
|
||||||
List<SceneDTO> sceneDTOs = scenes.stream()
|
List<Scene> scenes = (chapterId != null && !chapterId.isBlank())
|
||||||
.map(sceneMapper::toDTO)
|
? sceneService.getScenesByChapterId(chapterId)
|
||||||
.collect(Collectors.toList());
|
: sceneService.getAllScenes();
|
||||||
return ResponseEntity.ok(sceneDTOs);
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/chapter/{chapterId}")
|
|
||||||
public ResponseEntity<List<SceneDTO>> getScenesByChapterId(@PathVariable String chapterId) {
|
|
||||||
List<Scene> scenes = sceneService.getScenesByChapterId(chapterId);
|
|
||||||
List<SceneDTO> sceneDTOs = scenes.stream()
|
List<SceneDTO> sceneDTOs = scenes.stream()
|
||||||
.map(sceneMapper::toDTO)
|
.map(sceneMapper::toDTO)
|
||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
|
|||||||
@@ -4,16 +4,28 @@ import org.springframework.beans.factory.annotation.Value;
|
|||||||
import org.springframework.http.HttpEntity;
|
import org.springframework.http.HttpEntity;
|
||||||
import org.springframework.http.HttpHeaders;
|
import org.springframework.http.HttpHeaders;
|
||||||
import org.springframework.http.HttpMethod;
|
import org.springframework.http.HttpMethod;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
import org.springframework.web.bind.annotation.PutMapping;
|
import org.springframework.web.bind.annotation.PutMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestBody;
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
import org.springframework.web.client.RestTemplate;
|
import org.springframework.web.client.RestTemplate;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.http.HttpClient;
|
||||||
|
import java.net.http.HttpRequest;
|
||||||
|
import java.net.http.HttpResponse;
|
||||||
|
import java.time.Duration;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -32,20 +44,28 @@ public class SettingsController {
|
|||||||
|
|
||||||
private final RestTemplate restTemplate;
|
private final RestTemplate restTemplate;
|
||||||
private final String brainBaseUrl;
|
private final String brainBaseUrl;
|
||||||
|
private final String brainInternalSecret;
|
||||||
|
private final boolean demoMode;
|
||||||
|
|
||||||
public SettingsController(RestTemplate restTemplate,
|
public SettingsController(RestTemplate restTemplate,
|
||||||
@Value("${brain.base-url}") String brainBaseUrl) {
|
@Value("${brain.base-url}") String brainBaseUrl,
|
||||||
|
@Value("${brain.internal-secret}") String brainInternalSecret,
|
||||||
|
@Value("${app.demo-mode:false}") boolean demoMode) {
|
||||||
this.restTemplate = restTemplate;
|
this.restTemplate = restTemplate;
|
||||||
this.brainBaseUrl = brainBaseUrl;
|
this.brainBaseUrl = brainBaseUrl;
|
||||||
|
this.brainInternalSecret = brainInternalSecret;
|
||||||
|
this.demoMode = demoMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
public ResponseEntity<Map<String, Object>> getSettings() {
|
public ResponseEntity<Map<String, Object>> getSettings() {
|
||||||
|
guardDemoMode();
|
||||||
return forward(HttpMethod.GET, "/settings", null);
|
return forward(HttpMethod.GET, "/settings", null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@PutMapping
|
@PutMapping
|
||||||
public ResponseEntity<Map<String, Object>> updateSettings(@RequestBody Map<String, Object> patch) {
|
public ResponseEntity<Map<String, Object>> updateSettings(@RequestBody Map<String, Object> patch) {
|
||||||
|
guardDemoMode();
|
||||||
return forward(HttpMethod.PUT, "/settings", patch);
|
return forward(HttpMethod.PUT, "/settings", patch);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,11 +79,98 @@ public class SettingsController {
|
|||||||
return forward(HttpMethod.POST, "/models/ollama/info", body);
|
return forward(HttpMethod.POST, "/models/ollama/info", body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Telecharge un modele Ollama et streame la progression au client.
|
||||||
|
* <p>
|
||||||
|
* On bypass RestTemplate (qui bufferise toute la reponse) au profit du
|
||||||
|
* client HTTP standard de Java en mode streaming. Le Brain renvoie du
|
||||||
|
* NDJSON ligne par ligne ; on relaie chaque chunk tel quel pour que le
|
||||||
|
* frontend voie la progression en temps reel.
|
||||||
|
*/
|
||||||
|
@PostMapping(value = "/models/ollama/pull", produces = "application/x-ndjson")
|
||||||
|
public ResponseEntity<StreamingResponseBody> pullOllamaModel(@RequestBody Map<String, Object> body) {
|
||||||
|
guardDemoMode();
|
||||||
|
StreamingResponseBody stream = output -> {
|
||||||
|
// Force HTTP/1.1 : le HttpClient JDK essaie HTTP/2 par defaut,
|
||||||
|
// mais uvicorn (Brain) ne supporte que HTTP/1.1 et rejette la
|
||||||
|
// tentative d'upgrade ("Unsupported upgrade request") -> la
|
||||||
|
// requete n'arrive jamais a notre endpoint Python.
|
||||||
|
HttpClient http = HttpClient.newBuilder()
|
||||||
|
.version(HttpClient.Version.HTTP_1_1)
|
||||||
|
.connectTimeout(Duration.ofSeconds(10))
|
||||||
|
.build();
|
||||||
|
// Le RestTemplate auto-injecte X-Internal-Secret via un interceptor,
|
||||||
|
// mais on bypass RestTemplate pour le streaming -> on doit ajouter
|
||||||
|
// l'entete a la main, sinon le Brain repond 401.
|
||||||
|
HttpRequest.Builder reqBuilder = HttpRequest.newBuilder()
|
||||||
|
.uri(URI.create(brainBaseUrl + "/models/ollama/pull"))
|
||||||
|
.timeout(Duration.ofMinutes(60))
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.POST(HttpRequest.BodyPublishers.ofString(toJson(body)));
|
||||||
|
if (brainInternalSecret != null && !brainInternalSecret.isBlank()) {
|
||||||
|
reqBuilder.header("X-Internal-Secret", brainInternalSecret);
|
||||||
|
}
|
||||||
|
HttpRequest req = reqBuilder.build();
|
||||||
|
try {
|
||||||
|
HttpResponse<InputStream> resp = http.send(req, HttpResponse.BodyHandlers.ofInputStream());
|
||||||
|
try (InputStream in = resp.body()) {
|
||||||
|
byte[] buf = new byte[4096];
|
||||||
|
int n;
|
||||||
|
while ((n = in.read(buf)) != -1) {
|
||||||
|
output.write(buf, 0, n);
|
||||||
|
output.flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (InterruptedException ie) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
throw new IOException("Pull interrompu", ie);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return ResponseEntity.ok().contentType(MediaType.parseMediaType("application/x-ndjson")).body(stream);
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/models/ollama/{name}")
|
||||||
|
public ResponseEntity<Map<String, Object>> deleteOllamaModel(@PathVariable("name") String name) {
|
||||||
|
guardDemoMode();
|
||||||
|
return forward(HttpMethod.DELETE, "/models/ollama/" + name, null);
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/models/onemin")
|
@GetMapping("/models/onemin")
|
||||||
public ResponseEntity<Map<String, Object>> listOneMinModels() {
|
public ResponseEntity<Map<String, Object>> listOneMinModels() {
|
||||||
return forward(HttpMethod.GET, "/models/onemin", null);
|
return forward(HttpMethod.GET, "/models/onemin", null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialiseur JSON minimal pour eviter d'instancier ObjectMapper a chaque
|
||||||
|
* appel. Suffisant pour notre cas d'usage : Map<String,Object> avec des
|
||||||
|
* String/Number/Boolean en valeur.
|
||||||
|
*/
|
||||||
|
private static String toJson(Map<String, Object> m) {
|
||||||
|
StringBuilder sb = new StringBuilder("{");
|
||||||
|
boolean first = true;
|
||||||
|
for (Map.Entry<String, Object> e : m.entrySet()) {
|
||||||
|
if (!first) sb.append(",");
|
||||||
|
sb.append("\"").append(escape(e.getKey())).append("\":");
|
||||||
|
Object v = e.getValue();
|
||||||
|
if (v == null) sb.append("null");
|
||||||
|
else if (v instanceof Number || v instanceof Boolean) sb.append(v);
|
||||||
|
else sb.append("\"").append(escape(v.toString())).append("\"");
|
||||||
|
first = false;
|
||||||
|
}
|
||||||
|
return sb.append("}").toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String escape(String s) {
|
||||||
|
return s.replace("\\", "\\\\").replace("\"", "\\\"")
|
||||||
|
.replace("\n", "\\n").replace("\r", "\\r").replace("\t", "\\t");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void guardDemoMode() {
|
||||||
|
if (demoMode) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Settings disabled in demo mode");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@SuppressWarnings({"rawtypes", "unchecked"})
|
@SuppressWarnings({"rawtypes", "unchecked"})
|
||||||
private ResponseEntity<Map<String, Object>> forward(HttpMethod method, String path, Object body) {
|
private ResponseEntity<Map<String, Object>> forward(HttpMethod method, String path, Object body) {
|
||||||
HttpHeaders headers = new HttpHeaders();
|
HttpHeaders headers = new HttpHeaders();
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package com.loremind.infrastructure.web.controller;
|
||||||
|
|
||||||
|
import com.loremind.infrastructure.updates.UpdateCheckService;
|
||||||
|
import com.loremind.infrastructure.updates.UpdateCheckService.UpdateStatus;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Endpoints admin pour la verification et le declenchement des mises a jour
|
||||||
|
* des conteneurs LoreMind (core/brain/web).
|
||||||
|
*
|
||||||
|
* Protege par HTTP Basic via SecurityConfig (path /api/admin/**).
|
||||||
|
* Si la feature n'est pas configuree (WATCHTOWER_TOKEN vide), check renvoie
|
||||||
|
* {enabled:false} et apply repond 503.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/admin/updates")
|
||||||
|
public class UpdatesController {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(UpdatesController.class);
|
||||||
|
|
||||||
|
private final UpdateCheckService updates;
|
||||||
|
private final boolean demoMode;
|
||||||
|
|
||||||
|
public UpdatesController(UpdateCheckService updates,
|
||||||
|
@Value("${app.demo-mode:false}") boolean demoMode) {
|
||||||
|
this.updates = updates;
|
||||||
|
this.demoMode = demoMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/check")
|
||||||
|
public UpdateStatus check() {
|
||||||
|
guardDemoMode();
|
||||||
|
return updates.check();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/apply")
|
||||||
|
public ResponseEntity<Map<String, Object>> apply() {
|
||||||
|
guardDemoMode();
|
||||||
|
if (!updates.isEnabled()) {
|
||||||
|
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
|
||||||
|
.body(Map.of("error", "Update apply not configured"));
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
updates.apply();
|
||||||
|
return ResponseEntity.accepted()
|
||||||
|
.body(Map.of("status", "triggered",
|
||||||
|
"message", "Watchtower va telecharger et redemarrer les conteneurs."));
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Apply update failed", e);
|
||||||
|
return ResponseEntity.status(HttpStatus.BAD_GATEWAY)
|
||||||
|
.body(Map.of("error", "Watchtower unreachable: " + e.getMessage()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* En mode demo, les instances ne doivent pas se mettre a jour ni meme
|
||||||
|
* exposer leur statut (pas de surface d'attaque, et pas de redemarrage
|
||||||
|
* intempestif d'une demo en cours). Cohérent avec SettingsController.
|
||||||
|
*/
|
||||||
|
private void guardDemoMode() {
|
||||||
|
if (demoMode) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Updates disabled in demo mode");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,9 @@ public class ArcDTO {
|
|||||||
private String campaignId;
|
private String campaignId;
|
||||||
private int order;
|
private int order;
|
||||||
|
|
||||||
|
/** Cle d'icone (cf. CAMPAIGN_ICON_OPTIONS cote front). */
|
||||||
|
private String icon;
|
||||||
|
|
||||||
// Champs narratifs enrichis
|
// Champs narratifs enrichis
|
||||||
private String themes;
|
private String themes;
|
||||||
private String stakes;
|
private String stakes;
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ public class ChapterDTO {
|
|||||||
private String arcId;
|
private String arcId;
|
||||||
private int order;
|
private int order;
|
||||||
|
|
||||||
|
/** Cle d'icone (cf. CAMPAIGN_ICON_OPTIONS cote front). */
|
||||||
|
private String icon;
|
||||||
|
|
||||||
// Champs narratifs enrichis
|
// Champs narratifs enrichis
|
||||||
private String gmNotes;
|
private String gmNotes;
|
||||||
private String playerObjectives;
|
private String playerObjectives;
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package com.loremind.infrastructure.web.dto.campaigncontext;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO pour les fiches de PNJ d'une campagne.
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class NpcDTO {
|
||||||
|
|
||||||
|
private String id;
|
||||||
|
private String name;
|
||||||
|
private String markdownContent;
|
||||||
|
private String campaignId;
|
||||||
|
private int order;
|
||||||
|
}
|
||||||
@@ -17,6 +17,9 @@ public class SceneDTO {
|
|||||||
private String chapterId;
|
private String chapterId;
|
||||||
private int order;
|
private int order;
|
||||||
|
|
||||||
|
/** Cle d'icone (cf. CAMPAIGN_ICON_OPTIONS cote front). */
|
||||||
|
private String icon;
|
||||||
|
|
||||||
// Champs narratifs enrichis
|
// Champs narratifs enrichis
|
||||||
private String location;
|
private String location;
|
||||||
private String timing;
|
private String timing;
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ public class ArcMapper {
|
|||||||
dto.setDescription(arc.getDescription());
|
dto.setDescription(arc.getDescription());
|
||||||
dto.setCampaignId(arc.getCampaignId());
|
dto.setCampaignId(arc.getCampaignId());
|
||||||
dto.setOrder(arc.getOrder());
|
dto.setOrder(arc.getOrder());
|
||||||
|
dto.setIcon(arc.getIcon());
|
||||||
dto.setThemes(arc.getThemes());
|
dto.setThemes(arc.getThemes());
|
||||||
dto.setStakes(arc.getStakes());
|
dto.setStakes(arc.getStakes());
|
||||||
dto.setGmNotes(arc.getGmNotes());
|
dto.setGmNotes(arc.getGmNotes());
|
||||||
@@ -46,6 +47,7 @@ public class ArcMapper {
|
|||||||
.description(dto.getDescription())
|
.description(dto.getDescription())
|
||||||
.campaignId(dto.getCampaignId())
|
.campaignId(dto.getCampaignId())
|
||||||
.order(dto.getOrder())
|
.order(dto.getOrder())
|
||||||
|
.icon(dto.getIcon())
|
||||||
.themes(dto.getThemes())
|
.themes(dto.getThemes())
|
||||||
.stakes(dto.getStakes())
|
.stakes(dto.getStakes())
|
||||||
.gmNotes(dto.getGmNotes())
|
.gmNotes(dto.getGmNotes())
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ public class ChapterMapper {
|
|||||||
dto.setDescription(chapter.getDescription());
|
dto.setDescription(chapter.getDescription());
|
||||||
dto.setArcId(chapter.getArcId());
|
dto.setArcId(chapter.getArcId());
|
||||||
dto.setOrder(chapter.getOrder());
|
dto.setOrder(chapter.getOrder());
|
||||||
|
dto.setIcon(chapter.getIcon());
|
||||||
dto.setGmNotes(chapter.getGmNotes());
|
dto.setGmNotes(chapter.getGmNotes());
|
||||||
dto.setPlayerObjectives(chapter.getPlayerObjectives());
|
dto.setPlayerObjectives(chapter.getPlayerObjectives());
|
||||||
dto.setNarrativeStakes(chapter.getNarrativeStakes());
|
dto.setNarrativeStakes(chapter.getNarrativeStakes());
|
||||||
@@ -44,6 +45,7 @@ public class ChapterMapper {
|
|||||||
.description(dto.getDescription())
|
.description(dto.getDescription())
|
||||||
.arcId(dto.getArcId())
|
.arcId(dto.getArcId())
|
||||||
.order(dto.getOrder())
|
.order(dto.getOrder())
|
||||||
|
.icon(dto.getIcon())
|
||||||
.gmNotes(dto.getGmNotes())
|
.gmNotes(dto.getGmNotes())
|
||||||
.playerObjectives(dto.getPlayerObjectives())
|
.playerObjectives(dto.getPlayerObjectives())
|
||||||
.narrativeStakes(dto.getNarrativeStakes())
|
.narrativeStakes(dto.getNarrativeStakes())
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package com.loremind.infrastructure.web.mapper;
|
||||||
|
|
||||||
|
import com.loremind.domain.campaigncontext.Npc;
|
||||||
|
import com.loremind.infrastructure.web.dto.campaigncontext.NpcDTO;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class NpcMapper {
|
||||||
|
|
||||||
|
public NpcDTO toDTO(Npc n) {
|
||||||
|
if (n == null) return null;
|
||||||
|
NpcDTO dto = new NpcDTO();
|
||||||
|
dto.setId(n.getId());
|
||||||
|
dto.setName(n.getName());
|
||||||
|
dto.setMarkdownContent(n.getMarkdownContent());
|
||||||
|
dto.setCampaignId(n.getCampaignId());
|
||||||
|
dto.setOrder(n.getOrder());
|
||||||
|
return dto;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Npc toDomain(NpcDTO dto) {
|
||||||
|
if (dto == null) return null;
|
||||||
|
return Npc.builder()
|
||||||
|
.id(dto.getId())
|
||||||
|
.name(dto.getName())
|
||||||
|
.markdownContent(dto.getMarkdownContent())
|
||||||
|
.campaignId(dto.getCampaignId())
|
||||||
|
.order(dto.getOrder())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@ public class SceneMapper {
|
|||||||
dto.setDescription(scene.getDescription());
|
dto.setDescription(scene.getDescription());
|
||||||
dto.setChapterId(scene.getChapterId());
|
dto.setChapterId(scene.getChapterId());
|
||||||
dto.setOrder(scene.getOrder());
|
dto.setOrder(scene.getOrder());
|
||||||
|
dto.setIcon(scene.getIcon());
|
||||||
dto.setLocation(scene.getLocation());
|
dto.setLocation(scene.getLocation());
|
||||||
dto.setTiming(scene.getTiming());
|
dto.setTiming(scene.getTiming());
|
||||||
dto.setAtmosphere(scene.getAtmosphere());
|
dto.setAtmosphere(scene.getAtmosphere());
|
||||||
@@ -59,6 +60,7 @@ public class SceneMapper {
|
|||||||
.description(dto.getDescription())
|
.description(dto.getDescription())
|
||||||
.chapterId(dto.getChapterId())
|
.chapterId(dto.getChapterId())
|
||||||
.order(dto.getOrder())
|
.order(dto.getOrder())
|
||||||
|
.icon(dto.getIcon())
|
||||||
.location(dto.getLocation())
|
.location(dto.getLocation())
|
||||||
.timing(dto.getTiming())
|
.timing(dto.getTiming())
|
||||||
.atmosphere(dto.getAtmosphere())
|
.atmosphere(dto.getAtmosphere())
|
||||||
@@ -85,18 +87,14 @@ public class SceneMapper {
|
|||||||
private List<SceneBranchDTO> toBranchDTOs(List<SceneBranch> branches) {
|
private List<SceneBranchDTO> toBranchDTOs(List<SceneBranch> branches) {
|
||||||
if (branches == null) return new ArrayList<>();
|
if (branches == null) return new ArrayList<>();
|
||||||
return branches.stream()
|
return branches.stream()
|
||||||
.map(b -> new SceneBranchDTO(b.getLabel(), b.getTargetSceneId(), b.getCondition()))
|
.map(b -> new SceneBranchDTO(b.label(), b.targetSceneId(), b.condition()))
|
||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<SceneBranch> toBranchDomain(List<SceneBranchDTO> dtos) {
|
private List<SceneBranch> toBranchDomain(List<SceneBranchDTO> dtos) {
|
||||||
if (dtos == null) return new ArrayList<>();
|
if (dtos == null) return new ArrayList<>();
|
||||||
return dtos.stream()
|
return dtos.stream()
|
||||||
.map(d -> SceneBranch.builder()
|
.map(d -> new SceneBranch(d.getLabel(), d.getTargetSceneId(), d.getCondition()))
|
||||||
.label(d.getLabel())
|
|
||||||
.targetSceneId(d.getTargetSceneId())
|
|
||||||
.condition(d.getCondition())
|
|
||||||
.build())
|
|
||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,12 @@ server.port=8080
|
|||||||
# de WebFlux (utilisé uniquement pour WebClient côté adapter SSE vers le Brain).
|
# de WebFlux (utilisé uniquement pour WebClient côté adapter SSE vers le Brain).
|
||||||
spring.main.web-application-type=servlet
|
spring.main.web-application-type=servlet
|
||||||
|
|
||||||
|
# Pas de timeout sur les requetes async (StreamingResponseBody, SSE).
|
||||||
|
# Le defaut Tomcat coupe a 30s, ce qui interrompt le streaming d'un pull
|
||||||
|
# de modele Ollama (peut durer des dizaines de minutes pour un GGUF de 10+ Go).
|
||||||
|
# -1 = pas de timeout, on s'appuie sur la fermeture cote client ou cote upstream.
|
||||||
|
spring.mvc.async.request-timeout=-1
|
||||||
|
|
||||||
# Configuration de la base de donnees PostgreSQL
|
# Configuration de la base de donnees PostgreSQL
|
||||||
# Valeurs surchargeables via variables d'env (cf. docker-compose.yml).
|
# Valeurs surchargeables via variables d'env (cf. docker-compose.yml).
|
||||||
# En dev local, creez un fichier .env a la racine de core/ OU definissez les
|
# En dev local, creez un fichier .env a la racine de core/ OU definissez les
|
||||||
@@ -21,13 +27,13 @@ spring.jpa.show-sql=true
|
|||||||
spring.jpa.properties.hibernate.format_sql=true
|
spring.jpa.properties.hibernate.format_sql=true
|
||||||
|
|
||||||
# Configuration CORS pour autoriser le Frontend Angular
|
# Configuration CORS pour autoriser le Frontend Angular
|
||||||
spring.web.cors.allowed-origins=http://localhost:4200
|
spring.web.cors.allowed-origins=${CORS_ALLOWED_ORIGINS:http://localhost:4200}
|
||||||
spring.web.cors.allowed-methods=GET,POST,PUT,DELETE,OPTIONS
|
spring.web.cors.allowed-methods=GET,POST,PUT,DELETE,OPTIONS
|
||||||
spring.web.cors.allowed-headers=*
|
spring.web.cors.allowed-headers=*
|
||||||
spring.web.cors.allow-credentials=true
|
spring.web.cors.allow-credentials=true
|
||||||
|
|
||||||
# Configuration du Brain (service IA Python)
|
# Configuration du Brain (service IA Python)
|
||||||
brain.base-url=http://localhost:8000
|
brain.base-url=${BRAIN_BASE_URL:http://localhost:8000}
|
||||||
brain.timeout-seconds=120
|
brain.timeout-seconds=120
|
||||||
|
|
||||||
# Secret partage Core <-> Brain (auth inter-service via entete X-Internal-Secret).
|
# Secret partage Core <-> Brain (auth inter-service via entete X-Internal-Secret).
|
||||||
@@ -50,3 +56,15 @@ minio.bucket=${MINIO_BUCKET:loremind-images}
|
|||||||
# Limites d'upload d'images (MB)
|
# Limites d'upload d'images (MB)
|
||||||
spring.servlet.multipart.max-file-size=10MB
|
spring.servlet.multipart.max-file-size=10MB
|
||||||
spring.servlet.multipart.max-request-size=10MB
|
spring.servlet.multipart.max-request-size=10MB
|
||||||
|
|
||||||
|
# Mode demo : masque Settings/Export cote front et bloque les PUT /api/settings
|
||||||
|
# cote serveur. Activer via DEMO_MODE=true sur les deploiements publics.
|
||||||
|
app.demo-mode=${DEMO_MODE:false}
|
||||||
|
|
||||||
|
# Detection des mises a jour des conteneurs Docker (registry HTTP API + Watchtower).
|
||||||
|
# Feature desactivee si UPDATE_CHECK_WATCHTOWER_TOKEN est vide.
|
||||||
|
update-check.registry=${UPDATE_CHECK_REGISTRY:}
|
||||||
|
update-check.images=${UPDATE_CHECK_IMAGES:}
|
||||||
|
update-check.tag=${UPDATE_CHECK_TAG:latest}
|
||||||
|
update-check.watchtower-url=${WATCHTOWER_URL:http://watchtower:8080}
|
||||||
|
update-check.watchtower-token=${WATCHTOWER_TOKEN:}
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
package com.loremind.application.campaigncontext;
|
package com.loremind.application.campaigncontext;
|
||||||
|
|
||||||
import com.loremind.domain.campaigncontext.Arc;
|
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.ArcRepository;
|
||||||
|
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
|
||||||
|
import com.loremind.domain.campaigncontext.ports.SceneRepository;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.junit.jupiter.api.extension.ExtendWith;
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
@@ -14,6 +18,7 @@ import java.util.Optional;
|
|||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.*;
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
import static org.mockito.Mockito.*;
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -26,6 +31,10 @@ public class ArcServiceTest {
|
|||||||
|
|
||||||
@Mock
|
@Mock
|
||||||
private ArcRepository arcRepository;
|
private ArcRepository arcRepository;
|
||||||
|
@Mock
|
||||||
|
private ChapterRepository chapterRepository;
|
||||||
|
@Mock
|
||||||
|
private SceneRepository sceneRepository;
|
||||||
|
|
||||||
@InjectMocks
|
@InjectMocks
|
||||||
private ArcService arcService;
|
private ArcService arcService;
|
||||||
@@ -159,15 +168,48 @@ public class ArcServiceTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void testDeleteArc() {
|
void testDeleteArc_EmptyArc() {
|
||||||
// Arrange
|
// Aucun chapitre : Mockito renvoie List.of() par défaut.
|
||||||
doNothing().when(arcRepository).deleteById("arc-1");
|
|
||||||
|
|
||||||
// Act
|
|
||||||
arcService.deleteArc("arc-1");
|
arcService.deleteArc("arc-1");
|
||||||
|
|
||||||
// Assert
|
verify(arcRepository).deleteById("arc-1");
|
||||||
verify(arcRepository, times(1)).deleteById("arc-1");
|
verify(chapterRepository, never()).deleteById(anyString());
|
||||||
|
verify(sceneRepository, never()).deleteById(anyString());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testDeleteArc_CascadesChaptersAndScenes() {
|
||||||
|
Chapter chapter = Chapter.builder().id("chap-1").arcId("arc-1").name("C").build();
|
||||||
|
Scene s1 = Scene.builder().id("s-1").chapterId("chap-1").name("S1").build();
|
||||||
|
Scene s2 = Scene.builder().id("s-2").chapterId("chap-1").name("S2").build();
|
||||||
|
|
||||||
|
when(chapterRepository.findByArcId("arc-1")).thenReturn(List.of(chapter));
|
||||||
|
when(sceneRepository.findByChapterId("chap-1")).thenReturn(List.of(s1, s2));
|
||||||
|
|
||||||
|
arcService.deleteArc("arc-1");
|
||||||
|
|
||||||
|
verify(sceneRepository).deleteById("s-1");
|
||||||
|
verify(sceneRepository).deleteById("s-2");
|
||||||
|
verify(chapterRepository).deleteById("chap-1");
|
||||||
|
verify(arcRepository).deleteById("arc-1");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testGetDeletionImpact() {
|
||||||
|
Chapter c1 = Chapter.builder().id("chap-1").arcId("arc-1").name("C1").build();
|
||||||
|
Chapter c2 = Chapter.builder().id("chap-2").arcId("arc-1").name("C2").build();
|
||||||
|
Scene s1 = Scene.builder().id("s-1").chapterId("chap-1").name("S1").build();
|
||||||
|
Scene s2 = Scene.builder().id("s-2").chapterId("chap-2").name("S2").build();
|
||||||
|
Scene s3 = Scene.builder().id("s-3").chapterId("chap-2").name("S3").build();
|
||||||
|
|
||||||
|
when(chapterRepository.findByArcId("arc-1")).thenReturn(List.of(c1, c2));
|
||||||
|
when(sceneRepository.findByChapterId("chap-1")).thenReturn(List.of(s1));
|
||||||
|
when(sceneRepository.findByChapterId("chap-2")).thenReturn(List.of(s2, s3));
|
||||||
|
|
||||||
|
ArcService.DeletionImpact impact = arcService.getDeletionImpact("arc-1");
|
||||||
|
|
||||||
|
assertEquals(2, impact.chapters());
|
||||||
|
assertEquals(3, impact.scenes());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
package com.loremind.application.campaigncontext;
|
package com.loremind.application.campaigncontext;
|
||||||
|
|
||||||
|
import com.loremind.domain.campaigncontext.Arc;
|
||||||
import com.loremind.domain.campaigncontext.Campaign;
|
import com.loremind.domain.campaigncontext.Campaign;
|
||||||
|
import com.loremind.domain.campaigncontext.Chapter;
|
||||||
|
import com.loremind.domain.campaigncontext.Character;
|
||||||
|
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.CampaignRepository;
|
||||||
|
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
|
||||||
|
import com.loremind.domain.campaigncontext.ports.CharacterRepository;
|
||||||
|
import com.loremind.domain.campaigncontext.ports.SceneRepository;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.junit.jupiter.api.extension.ExtendWith;
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
@@ -27,6 +35,14 @@ public class CampaignServiceTest {
|
|||||||
|
|
||||||
@Mock
|
@Mock
|
||||||
private CampaignRepository campaignRepository;
|
private CampaignRepository campaignRepository;
|
||||||
|
@Mock
|
||||||
|
private ArcRepository arcRepository;
|
||||||
|
@Mock
|
||||||
|
private ChapterRepository chapterRepository;
|
||||||
|
@Mock
|
||||||
|
private SceneRepository sceneRepository;
|
||||||
|
@Mock
|
||||||
|
private CharacterRepository characterRepository;
|
||||||
|
|
||||||
@InjectMocks
|
@InjectMocks
|
||||||
private CampaignService campaignService;
|
private CampaignService campaignService;
|
||||||
@@ -50,9 +66,13 @@ public class CampaignServiceTest {
|
|||||||
CampaignService.CampaignData data = new CampaignService.CampaignData(
|
CampaignService.CampaignData data = new CampaignService.CampaignData(
|
||||||
"New Campaign",
|
"New Campaign",
|
||||||
"Description",
|
"Description",
|
||||||
"lore-123"
|
"lore-123",
|
||||||
|
null
|
||||||
);
|
);
|
||||||
when(campaignRepository.save(any(Campaign.class))).thenReturn(testCampaign);
|
// Le repo renvoie la Campaign telle que passée — on teste la normalisation
|
||||||
|
// du loreId dans le service, pas le comportement du repo.
|
||||||
|
when(campaignRepository.save(any(Campaign.class)))
|
||||||
|
.thenAnswer(invocation -> invocation.getArgument(0));
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
Campaign result = campaignService.createCampaign(data);
|
Campaign result = campaignService.createCampaign(data);
|
||||||
@@ -69,9 +89,11 @@ public class CampaignServiceTest {
|
|||||||
CampaignService.CampaignData data = new CampaignService.CampaignData(
|
CampaignService.CampaignData data = new CampaignService.CampaignData(
|
||||||
"New Campaign",
|
"New Campaign",
|
||||||
"Description",
|
"Description",
|
||||||
|
null,
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
when(campaignRepository.save(any(Campaign.class))).thenReturn(testCampaign);
|
when(campaignRepository.save(any(Campaign.class)))
|
||||||
|
.thenAnswer(invocation -> invocation.getArgument(0));
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
Campaign result = campaignService.createCampaign(data);
|
Campaign result = campaignService.createCampaign(data);
|
||||||
@@ -88,9 +110,11 @@ public class CampaignServiceTest {
|
|||||||
CampaignService.CampaignData data = new CampaignService.CampaignData(
|
CampaignService.CampaignData data = new CampaignService.CampaignData(
|
||||||
"New Campaign",
|
"New Campaign",
|
||||||
"Description",
|
"Description",
|
||||||
" "
|
" ",
|
||||||
|
null
|
||||||
);
|
);
|
||||||
when(campaignRepository.save(any(Campaign.class))).thenReturn(testCampaign);
|
when(campaignRepository.save(any(Campaign.class)))
|
||||||
|
.thenAnswer(invocation -> invocation.getArgument(0));
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
Campaign result = campaignService.createCampaign(data);
|
Campaign result = campaignService.createCampaign(data);
|
||||||
@@ -151,7 +175,8 @@ public class CampaignServiceTest {
|
|||||||
CampaignService.CampaignData data = new CampaignService.CampaignData(
|
CampaignService.CampaignData data = new CampaignService.CampaignData(
|
||||||
"Updated Campaign",
|
"Updated Campaign",
|
||||||
"Updated Description",
|
"Updated Description",
|
||||||
"lore-456"
|
"lore-456",
|
||||||
|
null
|
||||||
);
|
);
|
||||||
when(campaignRepository.findById("campaign-1")).thenReturn(Optional.of(testCampaign));
|
when(campaignRepository.findById("campaign-1")).thenReturn(Optional.of(testCampaign));
|
||||||
when(campaignRepository.save(any(Campaign.class))).thenReturn(testCampaign);
|
when(campaignRepository.save(any(Campaign.class))).thenReturn(testCampaign);
|
||||||
@@ -171,7 +196,8 @@ public class CampaignServiceTest {
|
|||||||
CampaignService.CampaignData data = new CampaignService.CampaignData(
|
CampaignService.CampaignData data = new CampaignService.CampaignData(
|
||||||
"Updated Campaign",
|
"Updated Campaign",
|
||||||
"Updated Description",
|
"Updated Description",
|
||||||
"lore-456"
|
"lore-456",
|
||||||
|
null
|
||||||
);
|
);
|
||||||
when(campaignRepository.findById("invalid-id")).thenReturn(Optional.empty());
|
when(campaignRepository.findById("invalid-id")).thenReturn(Optional.empty());
|
||||||
|
|
||||||
@@ -186,15 +212,75 @@ public class CampaignServiceTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void testDeleteCampaign() {
|
void testDeleteCampaign_EmptyCampaign() {
|
||||||
// Arrange
|
// Arrange : aucune dépendance ; Mockito renvoie List.of() par défaut.
|
||||||
doNothing().when(campaignRepository).deleteById("campaign-1");
|
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
campaignService.deleteCampaign("campaign-1");
|
campaignService.deleteCampaign("campaign-1");
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
verify(campaignRepository, times(1)).deleteById("campaign-1");
|
verify(campaignRepository, times(1)).deleteById("campaign-1");
|
||||||
|
verify(arcRepository, never()).deleteById(anyString());
|
||||||
|
verify(chapterRepository, never()).deleteById(anyString());
|
||||||
|
verify(sceneRepository, never()).deleteById(anyString());
|
||||||
|
verify(characterRepository, never()).deleteById(anyString());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testDeleteCampaign_CascadesArcsChaptersScenes() {
|
||||||
|
// Arrange : campagne avec 1 arc → 1 chapitre → 2 scènes.
|
||||||
|
Arc arc = Arc.builder().id("arc-1").campaignId("campaign-1").name("Arc 1").build();
|
||||||
|
Chapter chapter = Chapter.builder().id("chap-1").arcId("arc-1").name("Chap 1").build();
|
||||||
|
Scene scene1 = Scene.builder().id("scene-1").chapterId("chap-1").name("Scene 1").build();
|
||||||
|
Scene scene2 = Scene.builder().id("scene-2").chapterId("chap-1").name("Scene 2").build();
|
||||||
|
|
||||||
|
when(arcRepository.findByCampaignId("campaign-1")).thenReturn(List.of(arc));
|
||||||
|
when(chapterRepository.findByArcId("arc-1")).thenReturn(List.of(chapter));
|
||||||
|
when(sceneRepository.findByChapterId("chap-1")).thenReturn(List.of(scene1, scene2));
|
||||||
|
|
||||||
|
// Act
|
||||||
|
campaignService.deleteCampaign("campaign-1");
|
||||||
|
|
||||||
|
// Assert : tout disparaît, dans l'ordre feuilles → racine.
|
||||||
|
verify(sceneRepository).deleteById("scene-1");
|
||||||
|
verify(sceneRepository).deleteById("scene-2");
|
||||||
|
verify(chapterRepository).deleteById("chap-1");
|
||||||
|
verify(arcRepository).deleteById("arc-1");
|
||||||
|
verify(campaignRepository).deleteById("campaign-1");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testDeleteCampaign_CascadesCharacters() {
|
||||||
|
Character pc = Character.builder().id("char-1").campaignId("campaign-1").name("Alric").build();
|
||||||
|
when(characterRepository.findByCampaignId("campaign-1")).thenReturn(List.of(pc));
|
||||||
|
|
||||||
|
campaignService.deleteCampaign("campaign-1");
|
||||||
|
|
||||||
|
verify(characterRepository).deleteById("char-1");
|
||||||
|
verify(campaignRepository).deleteById("campaign-1");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testGetDeletionImpact() {
|
||||||
|
Arc arc = Arc.builder().id("arc-1").campaignId("campaign-1").name("Arc 1").build();
|
||||||
|
Chapter c1 = Chapter.builder().id("chap-1").arcId("arc-1").name("C1").build();
|
||||||
|
Chapter c2 = Chapter.builder().id("chap-2").arcId("arc-1").name("C2").build();
|
||||||
|
Scene s1 = Scene.builder().id("s-1").chapterId("chap-1").name("S1").build();
|
||||||
|
Scene s2 = Scene.builder().id("s-2").chapterId("chap-2").name("S2").build();
|
||||||
|
Scene s3 = Scene.builder().id("s-3").chapterId("chap-2").name("S3").build();
|
||||||
|
Character pc = Character.builder().id("char-1").campaignId("campaign-1").name("Alric").build();
|
||||||
|
|
||||||
|
when(arcRepository.findByCampaignId("campaign-1")).thenReturn(List.of(arc));
|
||||||
|
when(chapterRepository.findByArcId("arc-1")).thenReturn(List.of(c1, c2));
|
||||||
|
when(sceneRepository.findByChapterId("chap-1")).thenReturn(List.of(s1));
|
||||||
|
when(sceneRepository.findByChapterId("chap-2")).thenReturn(List.of(s2, s3));
|
||||||
|
when(characterRepository.findByCampaignId("campaign-1")).thenReturn(List.of(pc));
|
||||||
|
|
||||||
|
CampaignService.DeletionImpact impact = campaignService.getDeletionImpact("campaign-1");
|
||||||
|
|
||||||
|
assertEquals(1, impact.arcs());
|
||||||
|
assertEquals(2, impact.chapters());
|
||||||
|
assertEquals(3, impact.scenes());
|
||||||
|
assertEquals(1, impact.characters());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package com.loremind.application.campaigncontext;
|
package com.loremind.application.campaigncontext;
|
||||||
|
|
||||||
import com.loremind.domain.campaigncontext.Chapter;
|
import com.loremind.domain.campaigncontext.Chapter;
|
||||||
|
import com.loremind.domain.campaigncontext.Scene;
|
||||||
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
|
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
|
||||||
|
import com.loremind.domain.campaigncontext.ports.SceneRepository;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.junit.jupiter.api.extension.ExtendWith;
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
@@ -14,6 +16,7 @@ import java.util.Optional;
|
|||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.*;
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
import static org.mockito.Mockito.*;
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -26,6 +29,8 @@ public class ChapterServiceTest {
|
|||||||
|
|
||||||
@Mock
|
@Mock
|
||||||
private ChapterRepository chapterRepository;
|
private ChapterRepository chapterRepository;
|
||||||
|
@Mock
|
||||||
|
private SceneRepository sceneRepository;
|
||||||
|
|
||||||
@InjectMocks
|
@InjectMocks
|
||||||
private ChapterService chapterService;
|
private ChapterService chapterService;
|
||||||
@@ -157,15 +162,36 @@ public class ChapterServiceTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void testDeleteChapter() {
|
void testDeleteChapter_EmptyChapter() {
|
||||||
// Arrange
|
// Aucune scène : Mockito renvoie List.of() par défaut.
|
||||||
doNothing().when(chapterRepository).deleteById("chapter-1");
|
|
||||||
|
|
||||||
// Act
|
|
||||||
chapterService.deleteChapter("chapter-1");
|
chapterService.deleteChapter("chapter-1");
|
||||||
|
|
||||||
// Assert
|
verify(chapterRepository).deleteById("chapter-1");
|
||||||
verify(chapterRepository, times(1)).deleteById("chapter-1");
|
verify(sceneRepository, never()).deleteById(anyString());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testDeleteChapter_CascadesScenes() {
|
||||||
|
Scene s1 = Scene.builder().id("s-1").chapterId("chapter-1").name("S1").build();
|
||||||
|
Scene s2 = Scene.builder().id("s-2").chapterId("chapter-1").name("S2").build();
|
||||||
|
when(sceneRepository.findByChapterId("chapter-1")).thenReturn(List.of(s1, s2));
|
||||||
|
|
||||||
|
chapterService.deleteChapter("chapter-1");
|
||||||
|
|
||||||
|
verify(sceneRepository).deleteById("s-1");
|
||||||
|
verify(sceneRepository).deleteById("s-2");
|
||||||
|
verify(chapterRepository).deleteById("chapter-1");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testGetDeletionImpact() {
|
||||||
|
Scene s1 = Scene.builder().id("s-1").chapterId("chapter-1").name("S1").build();
|
||||||
|
Scene s2 = Scene.builder().id("s-2").chapterId("chapter-1").name("S2").build();
|
||||||
|
when(sceneRepository.findByChapterId("chapter-1")).thenReturn(List.of(s1, s2));
|
||||||
|
|
||||||
|
ChapterService.DeletionImpact impact = chapterService.getDeletionImpact("chapter-1");
|
||||||
|
|
||||||
|
assertEquals(2, impact.scenes());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
package com.loremind.application.campaigncontext;
|
||||||
|
|
||||||
|
import com.loremind.domain.campaigncontext.Npc;
|
||||||
|
import com.loremind.domain.campaigncontext.ports.NpcRepository;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
|
import org.mockito.InjectMocks;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test unitaire pour NpcService.
|
||||||
|
* Couvre la création (avec auto-calcul de l'order), la lecture, la mise à jour
|
||||||
|
* (incl. cas non trouvé), la suppression, et le calcul d'order.
|
||||||
|
*/
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
public class NpcServiceTest {
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private NpcRepository npcRepository;
|
||||||
|
|
||||||
|
@InjectMocks
|
||||||
|
private NpcService npcService;
|
||||||
|
|
||||||
|
private Npc testNpc;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
testNpc = Npc.builder()
|
||||||
|
.id("npc-1")
|
||||||
|
.name("Borin le forgeron")
|
||||||
|
.markdownContent("# Borin\nForgeron nain")
|
||||||
|
.campaignId("camp-1")
|
||||||
|
.order(1)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testCreateNpc_WithExplicitOrder() {
|
||||||
|
when(npcRepository.save(any(Npc.class))).thenReturn(testNpc);
|
||||||
|
|
||||||
|
Npc result = npcService.createNpc(
|
||||||
|
new NpcService.NpcData("Borin le forgeron", "# Borin", "camp-1", 5));
|
||||||
|
|
||||||
|
assertNotNull(result);
|
||||||
|
ArgumentCaptor<Npc> captor = ArgumentCaptor.forClass(Npc.class);
|
||||||
|
verify(npcRepository).save(captor.capture());
|
||||||
|
assertEquals(5, captor.getValue().getOrder());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testCreateNpc_AutoComputesNextOrder_WhenNullProvided() {
|
||||||
|
// Existant : 2 PNJ avec orders 0 et 3 → next = 4
|
||||||
|
Npc a = Npc.builder().id("a").campaignId("camp-1").order(0).build();
|
||||||
|
Npc b = Npc.builder().id("b").campaignId("camp-1").order(3).build();
|
||||||
|
when(npcRepository.findByCampaignId("camp-1")).thenReturn(List.of(a, b));
|
||||||
|
when(npcRepository.save(any(Npc.class))).thenReturn(testNpc);
|
||||||
|
|
||||||
|
npcService.createNpc(new NpcService.NpcData("Nouveau", null, "camp-1", null));
|
||||||
|
|
||||||
|
ArgumentCaptor<Npc> captor = ArgumentCaptor.forClass(Npc.class);
|
||||||
|
verify(npcRepository).save(captor.capture());
|
||||||
|
assertEquals(4, captor.getValue().getOrder());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testCreateNpc_FirstNpcGetsOrderZero() {
|
||||||
|
when(npcRepository.findByCampaignId("camp-1")).thenReturn(List.of());
|
||||||
|
when(npcRepository.save(any(Npc.class))).thenReturn(testNpc);
|
||||||
|
|
||||||
|
npcService.createNpc(new NpcService.NpcData("Premier", null, "camp-1", null));
|
||||||
|
|
||||||
|
ArgumentCaptor<Npc> captor = ArgumentCaptor.forClass(Npc.class);
|
||||||
|
verify(npcRepository).save(captor.capture());
|
||||||
|
assertEquals(0, captor.getValue().getOrder());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testGetNpcById_Found() {
|
||||||
|
when(npcRepository.findById("npc-1")).thenReturn(Optional.of(testNpc));
|
||||||
|
|
||||||
|
Optional<Npc> result = npcService.getNpcById("npc-1");
|
||||||
|
|
||||||
|
assertTrue(result.isPresent());
|
||||||
|
assertEquals("Borin le forgeron", result.get().getName());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testGetNpcById_NotFound() {
|
||||||
|
when(npcRepository.findById("missing")).thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
Optional<Npc> result = npcService.getNpcById("missing");
|
||||||
|
|
||||||
|
assertFalse(result.isPresent());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testGetNpcsByCampaignId_DelegatesToRepository() {
|
||||||
|
Npc a = Npc.builder().id("a").campaignId("camp-1").order(1).build();
|
||||||
|
Npc b = Npc.builder().id("b").campaignId("camp-1").order(2).build();
|
||||||
|
when(npcRepository.findByCampaignId("camp-1")).thenReturn(List.of(a, b));
|
||||||
|
|
||||||
|
List<Npc> result = npcService.getNpcsByCampaignId("camp-1");
|
||||||
|
|
||||||
|
assertEquals(2, result.size());
|
||||||
|
verify(npcRepository).findByCampaignId("camp-1");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testUpdateNpc_Success() {
|
||||||
|
when(npcRepository.findById("npc-1")).thenReturn(Optional.of(testNpc));
|
||||||
|
when(npcRepository.save(any(Npc.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
Npc result = npcService.updateNpc("npc-1",
|
||||||
|
new NpcService.NpcData("Borin renommé", "# v2", "camp-1", 7));
|
||||||
|
|
||||||
|
assertEquals("Borin renommé", result.getName());
|
||||||
|
assertEquals("# v2", result.getMarkdownContent());
|
||||||
|
assertEquals(7, result.getOrder());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testUpdateNpc_OrderNullPreservesExistingOrder() {
|
||||||
|
when(npcRepository.findById("npc-1")).thenReturn(Optional.of(testNpc));
|
||||||
|
when(npcRepository.save(any(Npc.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
Npc result = npcService.updateNpc("npc-1",
|
||||||
|
new NpcService.NpcData("Borin", "# txt", "camp-1", null));
|
||||||
|
|
||||||
|
// testNpc avait order=1 → préservé
|
||||||
|
assertEquals(1, result.getOrder());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testUpdateNpc_NotFoundThrows() {
|
||||||
|
when(npcRepository.findById("missing")).thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
|
||||||
|
() -> npcService.updateNpc("missing",
|
||||||
|
new NpcService.NpcData("x", null, "camp-1", null)));
|
||||||
|
assertTrue(ex.getMessage().contains("missing"));
|
||||||
|
verify(npcRepository, never()).save(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testDeleteNpc_DelegatesToRepository() {
|
||||||
|
npcService.deleteNpc("npc-1");
|
||||||
|
verify(npcRepository).deleteById("npc-1");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -178,10 +178,7 @@ public class SceneServiceTest {
|
|||||||
@Test
|
@Test
|
||||||
void testUpdateScene_WithValidBranches() {
|
void testUpdateScene_WithValidBranches() {
|
||||||
// Arrange
|
// Arrange
|
||||||
SceneBranch branch = SceneBranch.builder()
|
SceneBranch branch = SceneBranch.of("Go to scene 2", "scene-2");
|
||||||
.targetSceneId("scene-2")
|
|
||||||
.label("Go to scene 2")
|
|
||||||
.build();
|
|
||||||
Scene updatedScene = Scene.builder()
|
Scene updatedScene = Scene.builder()
|
||||||
.name("Updated Scene")
|
.name("Updated Scene")
|
||||||
.branches(List.of(branch))
|
.branches(List.of(branch))
|
||||||
@@ -203,10 +200,7 @@ public class SceneServiceTest {
|
|||||||
@Test
|
@Test
|
||||||
void testUpdateScene_WithBranchToSelf() {
|
void testUpdateScene_WithBranchToSelf() {
|
||||||
// Arrange
|
// Arrange
|
||||||
SceneBranch branch = SceneBranch.builder()
|
SceneBranch branch = SceneBranch.of("Self-reference", "scene-1");
|
||||||
.targetSceneId("scene-1")
|
|
||||||
.label("Self-reference")
|
|
||||||
.build();
|
|
||||||
Scene updatedScene = Scene.builder()
|
Scene updatedScene = Scene.builder()
|
||||||
.name("Updated Scene")
|
.name("Updated Scene")
|
||||||
.branches(List.of(branch))
|
.branches(List.of(branch))
|
||||||
@@ -228,10 +222,7 @@ public class SceneServiceTest {
|
|||||||
@Test
|
@Test
|
||||||
void testUpdateScene_WithBranchToDifferentChapter() {
|
void testUpdateScene_WithBranchToDifferentChapter() {
|
||||||
// Arrange
|
// Arrange
|
||||||
SceneBranch branch = SceneBranch.builder()
|
SceneBranch branch = SceneBranch.of("Go to other chapter", "scene-other-chapter");
|
||||||
.targetSceneId("scene-other-chapter")
|
|
||||||
.label("Go to other chapter")
|
|
||||||
.build();
|
|
||||||
Scene updatedScene = Scene.builder()
|
Scene updatedScene = Scene.builder()
|
||||||
.name("Updated Scene")
|
.name("Updated Scene")
|
||||||
.branches(List.of(branch))
|
.branches(List.of(branch))
|
||||||
@@ -253,10 +244,7 @@ public class SceneServiceTest {
|
|||||||
@Test
|
@Test
|
||||||
void testUpdateScene_WithBranchNullTarget() {
|
void testUpdateScene_WithBranchNullTarget() {
|
||||||
// Arrange
|
// Arrange
|
||||||
SceneBranch branch = SceneBranch.builder()
|
SceneBranch branch = SceneBranch.of("Null target", null);
|
||||||
.targetSceneId(null)
|
|
||||||
.label("Null target")
|
|
||||||
.build();
|
|
||||||
Scene updatedScene = Scene.builder()
|
Scene updatedScene = Scene.builder()
|
||||||
.name("Updated Scene")
|
.name("Updated Scene")
|
||||||
.branches(List.of(branch))
|
.branches(List.of(branch))
|
||||||
@@ -277,10 +265,7 @@ public class SceneServiceTest {
|
|||||||
@Test
|
@Test
|
||||||
void testUpdateScene_WithBranchBlankTarget() {
|
void testUpdateScene_WithBranchBlankTarget() {
|
||||||
// Arrange
|
// Arrange
|
||||||
SceneBranch branch = SceneBranch.builder()
|
SceneBranch branch = SceneBranch.of("Blank target", " ");
|
||||||
.targetSceneId(" ")
|
|
||||||
.label("Blank target")
|
|
||||||
.build();
|
|
||||||
Scene updatedScene = Scene.builder()
|
Scene updatedScene = Scene.builder()
|
||||||
.name("Updated Scene")
|
.name("Updated Scene")
|
||||||
.branches(List.of(branch))
|
.branches(List.of(branch))
|
||||||
|
|||||||
@@ -3,11 +3,15 @@ package com.loremind.application.generationcontext;
|
|||||||
import com.loremind.domain.campaigncontext.Arc;
|
import com.loremind.domain.campaigncontext.Arc;
|
||||||
import com.loremind.domain.campaigncontext.Campaign;
|
import com.loremind.domain.campaigncontext.Campaign;
|
||||||
import com.loremind.domain.campaigncontext.Chapter;
|
import com.loremind.domain.campaigncontext.Chapter;
|
||||||
|
import com.loremind.domain.campaigncontext.Character;
|
||||||
|
import com.loremind.domain.campaigncontext.Npc;
|
||||||
import com.loremind.domain.campaigncontext.Scene;
|
import com.loremind.domain.campaigncontext.Scene;
|
||||||
import com.loremind.domain.campaigncontext.SceneBranch;
|
import com.loremind.domain.campaigncontext.SceneBranch;
|
||||||
import com.loremind.domain.campaigncontext.ports.ArcRepository;
|
import com.loremind.domain.campaigncontext.ports.ArcRepository;
|
||||||
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
|
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
|
||||||
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
|
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
|
||||||
|
import com.loremind.domain.campaigncontext.ports.CharacterRepository;
|
||||||
|
import com.loremind.domain.campaigncontext.ports.NpcRepository;
|
||||||
import com.loremind.domain.campaigncontext.ports.SceneRepository;
|
import com.loremind.domain.campaigncontext.ports.SceneRepository;
|
||||||
import com.loremind.domain.generationcontext.CampaignStructuralContext;
|
import com.loremind.domain.generationcontext.CampaignStructuralContext;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
@@ -40,6 +44,10 @@ public class CampaignStructuralContextBuilderTest {
|
|||||||
private ChapterRepository chapterRepository;
|
private ChapterRepository chapterRepository;
|
||||||
@Mock
|
@Mock
|
||||||
private SceneRepository sceneRepository;
|
private SceneRepository sceneRepository;
|
||||||
|
@Mock
|
||||||
|
private CharacterRepository characterRepository;
|
||||||
|
@Mock
|
||||||
|
private NpcRepository npcRepository;
|
||||||
|
|
||||||
@InjectMocks
|
@InjectMocks
|
||||||
private CampaignStructuralContextBuilder builder;
|
private CampaignStructuralContextBuilder builder;
|
||||||
@@ -71,9 +79,9 @@ public class CampaignStructuralContextBuilderTest {
|
|||||||
|
|
||||||
CampaignStructuralContext ctx = builder.build("camp-1");
|
CampaignStructuralContext ctx = builder.build("camp-1");
|
||||||
|
|
||||||
assertEquals("Les Terres Brisées", ctx.getCampaignName());
|
assertEquals("Les Terres Brisées", ctx.campaignName());
|
||||||
assertEquals("Campagne dark fantasy", ctx.getCampaignDescription());
|
assertEquals("Campagne dark fantasy", ctx.campaignDescription());
|
||||||
assertTrue(ctx.getArcs().isEmpty());
|
assertTrue(ctx.arcs().isEmpty());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -97,19 +105,19 @@ public class CampaignStructuralContextBuilderTest {
|
|||||||
|
|
||||||
CampaignStructuralContext ctx = builder.build("camp-1");
|
CampaignStructuralContext ctx = builder.build("camp-1");
|
||||||
|
|
||||||
assertEquals(2, ctx.getArcs().size());
|
assertEquals(2, ctx.arcs().size());
|
||||||
assertEquals("Arc A", ctx.getArcs().get(0).getName());
|
assertEquals("Arc A", ctx.arcs().get(0).name());
|
||||||
assertEquals("Arc B", ctx.getArcs().get(1).getName());
|
assertEquals("Arc B", ctx.arcs().get(1).name());
|
||||||
|
|
||||||
// Chapitres tries : ch2 (order 1) avant ch1 (order 2)
|
// Chapitres tries : ch2 (order 1) avant ch1 (order 2)
|
||||||
assertEquals(2, ctx.getArcs().get(0).getChapters().size());
|
assertEquals(2, ctx.arcs().get(0).chapters().size());
|
||||||
assertEquals("Ch B", ctx.getArcs().get(0).getChapters().get(0).getName());
|
assertEquals("Ch B", ctx.arcs().get(0).chapters().get(0).name());
|
||||||
assertEquals("Ch A", ctx.getArcs().get(0).getChapters().get(1).getName());
|
assertEquals("Ch A", ctx.arcs().get(0).chapters().get(1).name());
|
||||||
|
|
||||||
// Scenes dans ch-1 : s2 (order 1) avant s1 (order 2)
|
// Scenes dans ch-1 : s2 (order 1) avant s1 (order 2)
|
||||||
var chADto = ctx.getArcs().get(0).getChapters().get(1);
|
var chADto = ctx.arcs().get(0).chapters().get(1);
|
||||||
assertEquals("Scene B", chADto.getScenes().get(0).getName());
|
assertEquals("Scene B", chADto.scenes().get(0).name());
|
||||||
assertEquals("Scene A", chADto.getScenes().get(1).getName());
|
assertEquals("Scene A", chADto.scenes().get(1).name());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -117,15 +125,8 @@ public class CampaignStructuralContextBuilderTest {
|
|||||||
Arc arc = Arc.builder().id("arc-1").name("Arc").description("").order(1).build();
|
Arc arc = Arc.builder().id("arc-1").name("Arc").description("").order(1).build();
|
||||||
Chapter ch = Chapter.builder().id("ch-1").arcId("arc-1").name("Ch").description("").order(1).build();
|
Chapter ch = Chapter.builder().id("ch-1").arcId("arc-1").name("Ch").description("").order(1).build();
|
||||||
|
|
||||||
SceneBranch validBranch = SceneBranch.builder()
|
SceneBranch validBranch = new SceneBranch("Si les joueurs fuient", "s-2", "en cas de combat perdu");
|
||||||
.label("Si les joueurs fuient")
|
SceneBranch danglingBranch = SceneBranch.of("Vers l'inconnu", "s-inconnu");
|
||||||
.targetSceneId("s-2")
|
|
||||||
.condition("en cas de combat perdu")
|
|
||||||
.build();
|
|
||||||
SceneBranch danglingBranch = SceneBranch.builder()
|
|
||||||
.label("Vers l'inconnu")
|
|
||||||
.targetSceneId("s-inconnu")
|
|
||||||
.build();
|
|
||||||
|
|
||||||
Scene s1 = Scene.builder().id("s-1").chapterId("ch-1").name("Entrée").description("")
|
Scene s1 = Scene.builder().id("s-1").chapterId("ch-1").name("Entrée").description("")
|
||||||
.order(1)
|
.order(1)
|
||||||
@@ -140,12 +141,72 @@ public class CampaignStructuralContextBuilderTest {
|
|||||||
|
|
||||||
CampaignStructuralContext ctx = builder.build("camp-1");
|
CampaignStructuralContext ctx = builder.build("camp-1");
|
||||||
|
|
||||||
var scene1Summary = ctx.getArcs().get(0).getChapters().get(0).getScenes().get(0);
|
var scene1Summary = ctx.arcs().get(0).chapters().get(0).scenes().get(0);
|
||||||
assertEquals(2, scene1Summary.getBranches().size());
|
assertEquals(2, scene1Summary.branches().size());
|
||||||
assertEquals("Fuite", scene1Summary.getBranches().get(0).getTargetSceneName());
|
assertEquals("Fuite", scene1Summary.branches().get(0).targetSceneName());
|
||||||
assertEquals("en cas de combat perdu", scene1Summary.getBranches().get(0).getCondition());
|
assertEquals("en cas de combat perdu", scene1Summary.branches().get(0).condition());
|
||||||
// ID inconnu → libellé de fallback
|
// ID inconnu → libellé de fallback
|
||||||
assertEquals("(scène inconnue)", scene1Summary.getBranches().get(1).getTargetSceneName());
|
assertEquals("(scène inconnue)", scene1Summary.branches().get(1).targetSceneName());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testBuild_ProjectsCharactersAndNpcsWithSnippets() {
|
||||||
|
Character pj1 = Character.builder().id("c-1").campaignId("camp-1").order(1)
|
||||||
|
.name("Aragorn")
|
||||||
|
.markdownContent("# Aragorn\n\nRôdeur du Nord, héritier d'Isildur.")
|
||||||
|
.build();
|
||||||
|
Character pj2 = Character.builder().id("c-2").campaignId("camp-1").order(2)
|
||||||
|
.name("Legolas")
|
||||||
|
.markdownContent(null) // pas de snippet → string vide
|
||||||
|
.build();
|
||||||
|
Npc npc1 = Npc.builder().id("n-1").campaignId("camp-1").order(2)
|
||||||
|
.name("Borin le forgeron")
|
||||||
|
.markdownContent("# Borin\n\nNain barbu au regard perçant, ancien clan Feuillefer.")
|
||||||
|
.build();
|
||||||
|
Npc npc2 = Npc.builder().id("n-2").campaignId("camp-1").order(1)
|
||||||
|
.name("Dame Elara")
|
||||||
|
.markdownContent("")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
when(campaignRepository.findById("camp-1")).thenReturn(Optional.of(campaign));
|
||||||
|
when(arcRepository.findByCampaignId("camp-1")).thenReturn(List.of());
|
||||||
|
when(characterRepository.findByCampaignId("camp-1")).thenReturn(List.of(pj2, pj1));
|
||||||
|
when(npcRepository.findByCampaignId("camp-1")).thenReturn(List.of(npc1, npc2));
|
||||||
|
|
||||||
|
CampaignStructuralContext ctx = builder.build("camp-1");
|
||||||
|
|
||||||
|
// PJ triés par order croissant
|
||||||
|
assertEquals(2, ctx.characters().size());
|
||||||
|
assertEquals("Aragorn", ctx.characters().get(0).name());
|
||||||
|
assertEquals("Rôdeur du Nord, héritier d'Isildur.", ctx.characters().get(0).snippet());
|
||||||
|
assertEquals("Legolas", ctx.characters().get(1).name());
|
||||||
|
assertEquals("", ctx.characters().get(1).snippet());
|
||||||
|
|
||||||
|
// PNJ triés par order croissant : Elara (1) avant Borin (2)
|
||||||
|
assertEquals(2, ctx.npcs().size());
|
||||||
|
assertEquals("Dame Elara", ctx.npcs().get(0).name());
|
||||||
|
assertEquals("", ctx.npcs().get(0).snippet());
|
||||||
|
assertEquals("Borin le forgeron", ctx.npcs().get(1).name());
|
||||||
|
assertEquals("Nain barbu au regard perçant, ancien clan Feuillefer.",
|
||||||
|
ctx.npcs().get(1).snippet());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testBuild_TruncatesLongSnippet() {
|
||||||
|
// Snippet > 160 chars : doit être tronqué à 159 + "…"
|
||||||
|
String longLine = "x".repeat(200);
|
||||||
|
Npc longNpc = Npc.builder().id("n-1").campaignId("camp-1").order(1)
|
||||||
|
.name("Verbeux").markdownContent(longLine).build();
|
||||||
|
|
||||||
|
when(campaignRepository.findById("camp-1")).thenReturn(Optional.of(campaign));
|
||||||
|
when(arcRepository.findByCampaignId("camp-1")).thenReturn(List.of());
|
||||||
|
when(npcRepository.findByCampaignId("camp-1")).thenReturn(List.of(longNpc));
|
||||||
|
|
||||||
|
CampaignStructuralContext ctx = builder.build("camp-1");
|
||||||
|
|
||||||
|
String snippet = ctx.npcs().get(0).snippet();
|
||||||
|
assertEquals(160, snippet.length());
|
||||||
|
assertTrue(snippet.endsWith("…"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -167,9 +228,9 @@ public class CampaignStructuralContextBuilderTest {
|
|||||||
|
|
||||||
CampaignStructuralContext ctx = builder.build("camp-1");
|
CampaignStructuralContext ctx = builder.build("camp-1");
|
||||||
|
|
||||||
assertEquals(2, ctx.getArcs().get(0).getIllustrationCount());
|
assertEquals(2, ctx.arcs().get(0).illustrationCount());
|
||||||
assertEquals(0, ctx.getArcs().get(0).getChapters().get(0).getIllustrationCount());
|
assertEquals(0, ctx.arcs().get(0).chapters().get(0).illustrationCount());
|
||||||
assertEquals(1, ctx.getArcs().get(0).getChapters().get(0).getScenes().get(0).getIllustrationCount());
|
assertEquals(1, ctx.arcs().get(0).chapters().get(0).scenes().get(0).illustrationCount());
|
||||||
assertTrue(ctx.getArcs().get(0).getChapters().get(0).getScenes().get(0).getBranches().isEmpty());
|
assertTrue(ctx.arcs().get(0).chapters().get(0).scenes().get(0).branches().isEmpty());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,13 +89,13 @@ public class GeneratePageValuesUseCaseTest {
|
|||||||
verify(aiProvider).generatePage(captor.capture());
|
verify(aiProvider).generatePage(captor.capture());
|
||||||
GenerationContext ctx = captor.getValue();
|
GenerationContext ctx = captor.getValue();
|
||||||
|
|
||||||
assertEquals("Aetheria", ctx.getLoreName());
|
assertEquals("Aetheria", ctx.loreName());
|
||||||
assertEquals("monde aérien", ctx.getLoreDescription());
|
assertEquals("monde aérien", ctx.loreDescription());
|
||||||
assertEquals("PNJ", ctx.getFolderName());
|
assertEquals("PNJ", ctx.folderName());
|
||||||
assertEquals("Personnage", ctx.getTemplateName());
|
assertEquals("Personnage", ctx.templateName());
|
||||||
assertEquals("Alice", ctx.getPageTitle());
|
assertEquals("Alice", ctx.pageTitle());
|
||||||
// Seuls les champs TEXT doivent etre envoyes (pas "Portrait" IMAGE).
|
// Seuls les champs TEXT doivent etre envoyes (pas "Portrait" IMAGE).
|
||||||
assertEquals(List.of("Histoire", "Apparence"), ctx.getTemplateFields());
|
assertEquals(List.of("Histoire", "Apparence"), ctx.templateFields());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -71,10 +71,10 @@ public class LoreStructuralContextBuilderTest {
|
|||||||
|
|
||||||
LoreStructuralContext ctx = builder.build("lore-1");
|
LoreStructuralContext ctx = builder.build("lore-1");
|
||||||
|
|
||||||
assertEquals("Aetheria", ctx.getLoreName());
|
assertEquals("Aetheria", ctx.loreName());
|
||||||
assertEquals("Monde aérien", ctx.getLoreDescription());
|
assertEquals("Monde aérien", ctx.loreDescription());
|
||||||
assertTrue(ctx.getFolders().isEmpty());
|
assertTrue(ctx.folders().isEmpty());
|
||||||
assertTrue(ctx.getTags().isEmpty());
|
assertTrue(ctx.tags().isEmpty());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -110,32 +110,32 @@ public class LoreStructuralContextBuilderTest {
|
|||||||
|
|
||||||
LoreStructuralContext ctx = builder.build("lore-1");
|
LoreStructuralContext ctx = builder.build("lore-1");
|
||||||
|
|
||||||
assertEquals(2, ctx.getFolders().size());
|
assertEquals(2, ctx.folders().size());
|
||||||
assertTrue(ctx.getFolders().containsKey("PNJ"));
|
assertTrue(ctx.folders().containsKey("PNJ"));
|
||||||
assertTrue(ctx.getFolders().containsKey("Lieux"));
|
assertTrue(ctx.folders().containsKey("Lieux"));
|
||||||
|
|
||||||
var pnjPages = ctx.getFolders().get("PNJ");
|
var pnjPages = ctx.folders().get("PNJ");
|
||||||
assertEquals(1, pnjPages.size());
|
assertEquals(1, pnjPages.size());
|
||||||
var aliceSummary = pnjPages.get(0);
|
var aliceSummary = pnjPages.get(0);
|
||||||
assertEquals("Alice", aliceSummary.getTitle());
|
assertEquals("Alice", aliceSummary.title());
|
||||||
assertEquals("Personnage", aliceSummary.getTemplateName());
|
assertEquals("Personnage", aliceSummary.templateName());
|
||||||
// Blank/null filtrés
|
// Blank/null filtrés
|
||||||
assertEquals(1, aliceSummary.getValues().size());
|
assertEquals(1, aliceSummary.values().size());
|
||||||
assertEquals("Il était une fois...", aliceSummary.getValues().get("Histoire"));
|
assertEquals("Il était une fois...", aliceSummary.values().get("Histoire"));
|
||||||
assertEquals(List.of("hero", "magic"), aliceSummary.getTags());
|
assertEquals(List.of("hero", "magic"), aliceSummary.tags());
|
||||||
// p-2 resolved into title, p-ghost dropped silently
|
// p-2 resolved into title, p-ghost dropped silently
|
||||||
assertEquals(List.of("La Forêt"), aliceSummary.getRelatedPageTitles());
|
assertEquals(List.of("La Forêt"), aliceSummary.relatedPageTitles());
|
||||||
|
|
||||||
var forestSummary = ctx.getFolders().get("Lieux").get(0);
|
var forestSummary = ctx.folders().get("Lieux").get(0);
|
||||||
// Template introuvable → "?"
|
// Template introuvable → "?"
|
||||||
assertEquals("?", forestSummary.getTemplateName());
|
assertEquals("?", forestSummary.templateName());
|
||||||
assertTrue(forestSummary.getValues().isEmpty());
|
assertTrue(forestSummary.values().isEmpty());
|
||||||
assertTrue(forestSummary.getRelatedPageTitles().isEmpty());
|
assertTrue(forestSummary.relatedPageTitles().isEmpty());
|
||||||
|
|
||||||
// Tags uniques entre les 2 pages
|
// Tags uniques entre les 2 pages
|
||||||
assertEquals(2, ctx.getTags().size());
|
assertEquals(2, ctx.tags().size());
|
||||||
assertTrue(ctx.getTags().contains("hero"));
|
assertTrue(ctx.tags().contains("hero"));
|
||||||
assertTrue(ctx.getTags().contains("magic"));
|
assertTrue(ctx.tags().contains("magic"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -160,7 +160,7 @@ public class LoreStructuralContextBuilderTest {
|
|||||||
|
|
||||||
LoreStructuralContext ctx = builder.build("lore-1");
|
LoreStructuralContext ctx = builder.build("lore-1");
|
||||||
|
|
||||||
String truncated = ctx.getFolders().get("PNJ").get(0).getValues().get("Histoire");
|
String truncated = ctx.folders().get("PNJ").get(0).values().get("Histoire");
|
||||||
assertNotNull(truncated);
|
assertNotNull(truncated);
|
||||||
assertEquals(500 + 1, truncated.length()); // 500 + ellipse
|
assertEquals(500 + 1, truncated.length()); // 500 + ellipse
|
||||||
assertTrue(truncated.endsWith("…"));
|
assertTrue(truncated.endsWith("…"));
|
||||||
@@ -185,9 +185,9 @@ public class LoreStructuralContextBuilderTest {
|
|||||||
|
|
||||||
LoreStructuralContext ctx = builder.build("lore-1");
|
LoreStructuralContext ctx = builder.build("lore-1");
|
||||||
|
|
||||||
var summary = ctx.getFolders().get("PNJ").get(0);
|
var summary = ctx.folders().get("PNJ").get(0);
|
||||||
assertTrue(summary.getValues().isEmpty());
|
assertTrue(summary.values().isEmpty());
|
||||||
assertTrue(summary.getTags().isEmpty());
|
assertTrue(summary.tags().isEmpty());
|
||||||
assertTrue(summary.getRelatedPageTitles().isEmpty());
|
assertTrue(summary.relatedPageTitles().isEmpty());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,13 @@ package com.loremind.application.generationcontext;
|
|||||||
|
|
||||||
import com.loremind.domain.campaigncontext.Arc;
|
import com.loremind.domain.campaigncontext.Arc;
|
||||||
import com.loremind.domain.campaigncontext.Chapter;
|
import com.loremind.domain.campaigncontext.Chapter;
|
||||||
|
import com.loremind.domain.campaigncontext.Character;
|
||||||
|
import com.loremind.domain.campaigncontext.Npc;
|
||||||
import com.loremind.domain.campaigncontext.Scene;
|
import com.loremind.domain.campaigncontext.Scene;
|
||||||
import com.loremind.domain.campaigncontext.ports.ArcRepository;
|
import com.loremind.domain.campaigncontext.ports.ArcRepository;
|
||||||
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
|
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
|
||||||
|
import com.loremind.domain.campaigncontext.ports.CharacterRepository;
|
||||||
|
import com.loremind.domain.campaigncontext.ports.NpcRepository;
|
||||||
import com.loremind.domain.campaigncontext.ports.SceneRepository;
|
import com.loremind.domain.campaigncontext.ports.SceneRepository;
|
||||||
import com.loremind.domain.generationcontext.NarrativeEntityContext;
|
import com.loremind.domain.generationcontext.NarrativeEntityContext;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
@@ -30,6 +34,8 @@ public class NarrativeEntityContextBuilderTest {
|
|||||||
@Mock private ArcRepository arcRepository;
|
@Mock private ArcRepository arcRepository;
|
||||||
@Mock private ChapterRepository chapterRepository;
|
@Mock private ChapterRepository chapterRepository;
|
||||||
@Mock private SceneRepository sceneRepository;
|
@Mock private SceneRepository sceneRepository;
|
||||||
|
@Mock private CharacterRepository characterRepository;
|
||||||
|
@Mock private NpcRepository npcRepository;
|
||||||
|
|
||||||
@InjectMocks private NarrativeEntityContextBuilder builder;
|
@InjectMocks private NarrativeEntityContextBuilder builder;
|
||||||
|
|
||||||
@@ -44,14 +50,14 @@ public class NarrativeEntityContextBuilderTest {
|
|||||||
|
|
||||||
NarrativeEntityContext ctx = builder.build("arc", "arc-1");
|
NarrativeEntityContext ctx = builder.build("arc", "arc-1");
|
||||||
|
|
||||||
assertEquals("arc", ctx.getEntityType());
|
assertEquals("arc", ctx.entityType());
|
||||||
assertEquals("L'arc sombre", ctx.getTitle());
|
assertEquals("L'arc sombre", ctx.title());
|
||||||
assertEquals("synopsis", ctx.getFields().get("description (synopsis)"));
|
assertEquals("synopsis", ctx.fields().get("description (synopsis)"));
|
||||||
assertEquals("trahison", ctx.getFields().get("themes"));
|
assertEquals("trahison", ctx.fields().get("themes"));
|
||||||
assertEquals("vie ou mort", ctx.getFields().get("stakes"));
|
assertEquals("vie ou mort", ctx.fields().get("stakes"));
|
||||||
assertEquals("pouvoir", ctx.getFields().get("rewards"));
|
assertEquals("pouvoir", ctx.fields().get("rewards"));
|
||||||
assertEquals("le roi meurt", ctx.getFields().get("resolution"));
|
assertEquals("le roi meurt", ctx.fields().get("resolution"));
|
||||||
assertEquals("secret", ctx.getFields().get("gmNotes"));
|
assertEquals("secret", ctx.fields().get("gmNotes"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -64,12 +70,12 @@ public class NarrativeEntityContextBuilderTest {
|
|||||||
|
|
||||||
NarrativeEntityContext ctx = builder.build("chapter", "ch-1");
|
NarrativeEntityContext ctx = builder.build("chapter", "ch-1");
|
||||||
|
|
||||||
assertEquals("chapter", ctx.getEntityType());
|
assertEquals("chapter", ctx.entityType());
|
||||||
assertEquals("Chapitre 1", ctx.getTitle());
|
assertEquals("Chapitre 1", ctx.title());
|
||||||
assertEquals("", ctx.getFields().get("description (synopsis)"));
|
assertEquals("", ctx.fields().get("description (synopsis)"));
|
||||||
assertEquals("", ctx.getFields().get("playerObjectives"));
|
assertEquals("", ctx.fields().get("playerObjectives"));
|
||||||
assertEquals("haut", ctx.getFields().get("narrativeStakes"));
|
assertEquals("haut", ctx.fields().get("narrativeStakes"));
|
||||||
assertEquals("", ctx.getFields().get("gmNotes"));
|
assertEquals("", ctx.fields().get("gmNotes"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -85,17 +91,17 @@ public class NarrativeEntityContextBuilderTest {
|
|||||||
|
|
||||||
NarrativeEntityContext ctx = builder.build("scene", "s-1");
|
NarrativeEntityContext ctx = builder.build("scene", "s-1");
|
||||||
|
|
||||||
assertEquals("scene", ctx.getEntityType());
|
assertEquals("scene", ctx.entityType());
|
||||||
assertEquals("L'auberge", ctx.getTitle());
|
assertEquals("L'auberge", ctx.title());
|
||||||
assertEquals("lieu calme", ctx.getFields().get("description"));
|
assertEquals("lieu calme", ctx.fields().get("description"));
|
||||||
assertEquals("Taverne", ctx.getFields().get("location"));
|
assertEquals("Taverne", ctx.fields().get("location"));
|
||||||
assertEquals("Soir", ctx.getFields().get("timing"));
|
assertEquals("Soir", ctx.fields().get("timing"));
|
||||||
assertEquals("tendue", ctx.getFields().get("atmosphere"));
|
assertEquals("tendue", ctx.fields().get("atmosphere"));
|
||||||
assertEquals("Vous entrez...", ctx.getFields().get("playerNarration"));
|
assertEquals("Vous entrez...", ctx.fields().get("playerNarration"));
|
||||||
assertEquals("option A...", ctx.getFields().get("choicesConsequences"));
|
assertEquals("option A...", ctx.fields().get("choicesConsequences"));
|
||||||
assertEquals("moyen", ctx.getFields().get("combatDifficulty"));
|
assertEquals("moyen", ctx.fields().get("combatDifficulty"));
|
||||||
assertEquals("3 bandits", ctx.getFields().get("enemies"));
|
assertEquals("3 bandits", ctx.fields().get("enemies"));
|
||||||
assertEquals("trésor caché", ctx.getFields().get("gmSecretNotes"));
|
assertEquals("trésor caché", ctx.fields().get("gmSecretNotes"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -104,14 +110,62 @@ public class NarrativeEntityContextBuilderTest {
|
|||||||
when(arcRepository.findById("arc-1")).thenReturn(Optional.of(arc));
|
when(arcRepository.findById("arc-1")).thenReturn(Optional.of(arc));
|
||||||
|
|
||||||
NarrativeEntityContext ctx = builder.build(" ARC ", "arc-1");
|
NarrativeEntityContext ctx = builder.build(" ARC ", "arc-1");
|
||||||
assertEquals("arc", ctx.getEntityType());
|
assertEquals("arc", ctx.entityType());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testBuild_Character_MarkdownProjected() {
|
||||||
|
Character c = Character.builder()
|
||||||
|
.id("c-1").name("Aragorn").markdownContent("# Aragorn\nRôdeur")
|
||||||
|
.build();
|
||||||
|
when(characterRepository.findById("c-1")).thenReturn(Optional.of(c));
|
||||||
|
|
||||||
|
NarrativeEntityContext ctx = builder.build("character", "c-1");
|
||||||
|
|
||||||
|
assertEquals("character", ctx.entityType());
|
||||||
|
assertEquals("Aragorn", ctx.title());
|
||||||
|
assertEquals("# Aragorn\nRôdeur", ctx.fields().get("fiche complète (markdown)"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testBuild_Npc_MarkdownProjected() {
|
||||||
|
Npc n = Npc.builder()
|
||||||
|
.id("n-1").name("Borin le forgeron")
|
||||||
|
.markdownContent("# Borin\n**Faction :** Clan Feuillefer")
|
||||||
|
.build();
|
||||||
|
when(npcRepository.findById("n-1")).thenReturn(Optional.of(n));
|
||||||
|
|
||||||
|
NarrativeEntityContext ctx = builder.build("npc", "n-1");
|
||||||
|
|
||||||
|
assertEquals("npc", ctx.entityType());
|
||||||
|
assertEquals("Borin le forgeron", ctx.title());
|
||||||
|
assertEquals("# Borin\n**Faction :** Clan Feuillefer",
|
||||||
|
ctx.fields().get("fiche complète (markdown)"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testBuild_Npc_NormalizesCase() {
|
||||||
|
Npc n = Npc.builder().id("n-1").name("Elara").markdownContent("desc").build();
|
||||||
|
when(npcRepository.findById("n-1")).thenReturn(Optional.of(n));
|
||||||
|
|
||||||
|
NarrativeEntityContext ctx = builder.build(" NPC ", "n-1");
|
||||||
|
assertEquals("npc", ctx.entityType());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testBuild_NpcNotFoundThrows() {
|
||||||
|
when(npcRepository.findById("missing")).thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
|
||||||
|
() -> builder.build("npc", "missing"));
|
||||||
|
assertTrue(ex.getMessage().contains("missing"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void testBuild_UnknownTypeThrows() {
|
void testBuild_UnknownTypeThrows() {
|
||||||
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
|
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
|
||||||
() -> builder.build("npc", "id"));
|
() -> builder.build("alien", "id"));
|
||||||
assertTrue(ex.getMessage().contains("npc"));
|
assertTrue(ex.getMessage().contains("alien"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -55,9 +55,7 @@ public class StreamChatForCampaignUseCaseTest {
|
|||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
void setUp() {
|
void setUp() {
|
||||||
campaignCtx = CampaignStructuralContext.builder()
|
campaignCtx = new CampaignStructuralContext("X", "d", List.of(), List.of(), List.of());
|
||||||
.campaignName("X").campaignDescription("d")
|
|
||||||
.build();
|
|
||||||
messages = List.of();
|
messages = List.of();
|
||||||
onUsage = mock(Consumer.class);
|
onUsage = mock(Consumer.class);
|
||||||
onToken = mock(Consumer.class);
|
onToken = mock(Consumer.class);
|
||||||
@@ -85,10 +83,10 @@ public class StreamChatForCampaignUseCaseTest {
|
|||||||
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
|
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
|
||||||
verify(aiChatProvider).streamChat(captor.capture(), eq(onUsage), eq(onToken), eq(onComplete), eq(onError));
|
verify(aiChatProvider).streamChat(captor.capture(), eq(onUsage), eq(onToken), eq(onComplete), eq(onError));
|
||||||
ChatRequest req = captor.getValue();
|
ChatRequest req = captor.getValue();
|
||||||
assertSame(campaignCtx, req.getCampaignContext());
|
assertSame(campaignCtx, req.campaignContext());
|
||||||
assertNull(req.getLoreContext());
|
assertNull(req.loreContext());
|
||||||
assertNull(req.getNarrativeEntity());
|
assertNull(req.narrativeEntity());
|
||||||
assertNull(req.getPageContext());
|
assertNull(req.pageContext());
|
||||||
verifyNoInteractions(loreContextBuilder);
|
verifyNoInteractions(loreContextBuilder);
|
||||||
verifyNoInteractions(narrativeEntityContextBuilder);
|
verifyNoInteractions(narrativeEntityContextBuilder);
|
||||||
}
|
}
|
||||||
@@ -96,8 +94,8 @@ public class StreamChatForCampaignUseCaseTest {
|
|||||||
@Test
|
@Test
|
||||||
void testExecute_LinkedCampaign_LoadsLoreContext() {
|
void testExecute_LinkedCampaign_LoadsLoreContext() {
|
||||||
Campaign linked = Campaign.builder().id("c-1").name("C").loreId("lore-1").build();
|
Campaign linked = Campaign.builder().id("c-1").name("C").loreId("lore-1").build();
|
||||||
LoreStructuralContext loreCtx = LoreStructuralContext.builder()
|
LoreStructuralContext loreCtx = new LoreStructuralContext(
|
||||||
.loreName("L").loreDescription("d").folders(Collections.emptyMap()).build();
|
"L", "d", Collections.emptyMap(), List.of());
|
||||||
|
|
||||||
when(campaignRepository.findById("c-1")).thenReturn(Optional.of(linked));
|
when(campaignRepository.findById("c-1")).thenReturn(Optional.of(linked));
|
||||||
when(campaignContextBuilder.build("c-1")).thenReturn(campaignCtx);
|
when(campaignContextBuilder.build("c-1")).thenReturn(campaignCtx);
|
||||||
@@ -107,7 +105,7 @@ public class StreamChatForCampaignUseCaseTest {
|
|||||||
|
|
||||||
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
|
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
|
||||||
verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any(), any());
|
verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any(), any());
|
||||||
assertSame(loreCtx, captor.getValue().getLoreContext());
|
assertSame(loreCtx, captor.getValue().loreContext());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -122,15 +120,14 @@ public class StreamChatForCampaignUseCaseTest {
|
|||||||
|
|
||||||
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
|
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
|
||||||
verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any(), any());
|
verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any(), any());
|
||||||
assertNull(captor.getValue().getLoreContext());
|
assertNull(captor.getValue().loreContext());
|
||||||
// La requete doit tout de meme partir (pas d'exception).
|
// La requete doit tout de meme partir (pas d'exception).
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void testExecute_WithEntityFocus_BuildsNarrativeEntity() {
|
void testExecute_WithEntityFocus_BuildsNarrativeEntity() {
|
||||||
Campaign standalone = Campaign.builder().id("c-1").name("C").loreId(null).build();
|
Campaign standalone = Campaign.builder().id("c-1").name("C").loreId(null).build();
|
||||||
NarrativeEntityContext entity = NarrativeEntityContext.builder()
|
NarrativeEntityContext entity = new NarrativeEntityContext("scene", "L'auberge", Map.of());
|
||||||
.entityType("scene").title("L'auberge").fields(Map.of()).build();
|
|
||||||
|
|
||||||
when(campaignRepository.findById("c-1")).thenReturn(Optional.of(standalone));
|
when(campaignRepository.findById("c-1")).thenReturn(Optional.of(standalone));
|
||||||
when(campaignContextBuilder.build("c-1")).thenReturn(campaignCtx);
|
when(campaignContextBuilder.build("c-1")).thenReturn(campaignCtx);
|
||||||
@@ -140,7 +137,7 @@ public class StreamChatForCampaignUseCaseTest {
|
|||||||
|
|
||||||
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
|
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
|
||||||
verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any(), any());
|
verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any(), any());
|
||||||
assertSame(entity, captor.getValue().getNarrativeEntity());
|
assertSame(entity, captor.getValue().narrativeEntity());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -153,7 +150,7 @@ public class StreamChatForCampaignUseCaseTest {
|
|||||||
|
|
||||||
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
|
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
|
||||||
verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any(), any());
|
verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any(), any());
|
||||||
assertNull(captor.getValue().getNarrativeEntity());
|
assertNull(captor.getValue().narrativeEntity());
|
||||||
verifyNoInteractions(narrativeEntityContextBuilder);
|
verifyNoInteractions(narrativeEntityContextBuilder);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,10 +55,7 @@ public class StreamChatForLoreUseCaseTest {
|
|||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
void setUp() {
|
void setUp() {
|
||||||
loreCtx = LoreStructuralContext.builder()
|
loreCtx = new LoreStructuralContext("Aetheria", "d", Collections.emptyMap(), List.of());
|
||||||
.loreName("Aetheria").loreDescription("d")
|
|
||||||
.folders(Collections.emptyMap())
|
|
||||||
.build();
|
|
||||||
messages = List.of();
|
messages = List.of();
|
||||||
onUsage = mock(Consumer.class);
|
onUsage = mock(Consumer.class);
|
||||||
onToken = mock(Consumer.class);
|
onToken = mock(Consumer.class);
|
||||||
@@ -75,9 +72,9 @@ public class StreamChatForLoreUseCaseTest {
|
|||||||
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
|
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
|
||||||
verify(aiChatProvider).streamChat(captor.capture(), eq(onUsage), eq(onToken), eq(onComplete), eq(onError));
|
verify(aiChatProvider).streamChat(captor.capture(), eq(onUsage), eq(onToken), eq(onComplete), eq(onError));
|
||||||
ChatRequest req = captor.getValue();
|
ChatRequest req = captor.getValue();
|
||||||
assertSame(loreCtx, req.getLoreContext());
|
assertSame(loreCtx, req.loreContext());
|
||||||
assertNull(req.getPageContext());
|
assertNull(req.pageContext());
|
||||||
assertNull(req.getCampaignContext());
|
assertNull(req.campaignContext());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -88,7 +85,7 @@ public class StreamChatForLoreUseCaseTest {
|
|||||||
|
|
||||||
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
|
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
|
||||||
verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any(), any());
|
verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any(), any());
|
||||||
assertNull(captor.getValue().getPageContext());
|
assertNull(captor.getValue().pageContext());
|
||||||
verifyNoInteractions(pageRepository);
|
verifyNoInteractions(pageRepository);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,12 +113,12 @@ public class StreamChatForLoreUseCaseTest {
|
|||||||
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
|
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
|
||||||
verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any(), any());
|
verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any(), any());
|
||||||
ChatRequest req = captor.getValue();
|
ChatRequest req = captor.getValue();
|
||||||
assertNotNull(req.getPageContext());
|
assertNotNull(req.pageContext());
|
||||||
assertEquals("Alice", req.getPageContext().getTitle());
|
assertEquals("Alice", req.pageContext().title());
|
||||||
assertEquals("Personnage", req.getPageContext().getTemplateName());
|
assertEquals("Personnage", req.pageContext().templateName());
|
||||||
// Seuls les champs TEXT exposes
|
// Seuls les champs TEXT exposes
|
||||||
assertEquals(List.of("Histoire"), req.getPageContext().getTemplateFields());
|
assertEquals(List.of("Histoire"), req.pageContext().templateFields());
|
||||||
assertEquals(values, req.getPageContext().getValues());
|
assertEquals(values, req.pageContext().values());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -137,12 +134,12 @@ public class StreamChatForLoreUseCaseTest {
|
|||||||
|
|
||||||
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
|
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
|
||||||
verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any(), any());
|
verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any(), any());
|
||||||
var pageCtx = captor.getValue().getPageContext();
|
var pageCtx = captor.getValue().pageContext();
|
||||||
assertNotNull(pageCtx);
|
assertNotNull(pageCtx);
|
||||||
assertEquals("Orphan", pageCtx.getTitle());
|
assertEquals("Orphan", pageCtx.title());
|
||||||
assertEquals("?", pageCtx.getTemplateName());
|
assertEquals("?", pageCtx.templateName());
|
||||||
assertTrue(pageCtx.getTemplateFields().isEmpty());
|
assertTrue(pageCtx.templateFields().isEmpty());
|
||||||
assertTrue(pageCtx.getValues().isEmpty());
|
assertTrue(pageCtx.values().isEmpty());
|
||||||
verifyNoInteractions(templateRepository);
|
verifyNoInteractions(templateRepository);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,9 +157,9 @@ public class StreamChatForLoreUseCaseTest {
|
|||||||
|
|
||||||
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
|
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
|
||||||
verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any(), any());
|
verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any(), any());
|
||||||
var pageCtx = captor.getValue().getPageContext();
|
var pageCtx = captor.getValue().pageContext();
|
||||||
assertEquals("?", pageCtx.getTemplateName());
|
assertEquals("?", pageCtx.templateName());
|
||||||
assertTrue(pageCtx.getTemplateFields().isEmpty());
|
assertTrue(pageCtx.templateFields().isEmpty());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package com.loremind.application.lorecontext;
|
package com.loremind.application.lorecontext;
|
||||||
|
|
||||||
import com.loremind.domain.lorecontext.LoreNode;
|
import com.loremind.domain.lorecontext.LoreNode;
|
||||||
|
import com.loremind.domain.lorecontext.Page;
|
||||||
import com.loremind.domain.lorecontext.ports.LoreNodeRepository;
|
import com.loremind.domain.lorecontext.ports.LoreNodeRepository;
|
||||||
|
import com.loremind.domain.lorecontext.ports.PageRepository;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.junit.jupiter.api.extension.ExtendWith;
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
@@ -15,6 +17,7 @@ import java.util.Optional;
|
|||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.*;
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
import static org.mockito.Mockito.*;
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -26,6 +29,7 @@ import static org.mockito.Mockito.*;
|
|||||||
public class LoreNodeServiceTest {
|
public class LoreNodeServiceTest {
|
||||||
|
|
||||||
@Mock private LoreNodeRepository loreNodeRepository;
|
@Mock private LoreNodeRepository loreNodeRepository;
|
||||||
|
@Mock private PageRepository pageRepository;
|
||||||
|
|
||||||
@InjectMocks private LoreNodeService loreNodeService;
|
@InjectMocks private LoreNodeService loreNodeService;
|
||||||
|
|
||||||
@@ -118,8 +122,66 @@ public class LoreNodeServiceTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void testDelete() {
|
void testDelete_LeafFolder() {
|
||||||
|
// Aucun descendant, aucune page : seul le dossier est supprimé.
|
||||||
loreNodeService.deleteLoreNode("n-1");
|
loreNodeService.deleteLoreNode("n-1");
|
||||||
verify(loreNodeRepository).deleteById("n-1");
|
verify(loreNodeRepository).deleteById("n-1");
|
||||||
|
verify(pageRepository, never()).deleteById(anyString());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testDelete_CascadesPagesOfRoot() {
|
||||||
|
Page p1 = Page.builder().id("p-1").nodeId("n-1").title("P1").build();
|
||||||
|
Page p2 = Page.builder().id("p-2").nodeId("n-1").title("P2").build();
|
||||||
|
when(pageRepository.findByNodeId("n-1")).thenReturn(List.of(p1, p2));
|
||||||
|
|
||||||
|
loreNodeService.deleteLoreNode("n-1");
|
||||||
|
|
||||||
|
verify(pageRepository).deleteById("p-1");
|
||||||
|
verify(pageRepository).deleteById("p-2");
|
||||||
|
verify(loreNodeRepository).deleteById("n-1");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testDelete_CascadesSubfoldersRecursive() {
|
||||||
|
// n-1 → n-1a → n-1a1 ; chaque feuille a une page.
|
||||||
|
LoreNode mid = LoreNode.builder().id("n-1a").parentId("n-1").loreId("lore-1").name("mid").build();
|
||||||
|
LoreNode leaf = LoreNode.builder().id("n-1a1").parentId("n-1a").loreId("lore-1").name("leaf").build();
|
||||||
|
Page pageOnLeaf = Page.builder().id("p-leaf").nodeId("n-1a1").title("P").build();
|
||||||
|
|
||||||
|
when(loreNodeRepository.findByParentId("n-1")).thenReturn(List.of(mid));
|
||||||
|
when(loreNodeRepository.findByParentId("n-1a")).thenReturn(List.of(leaf));
|
||||||
|
when(pageRepository.findByNodeId("n-1a1")).thenReturn(List.of(pageOnLeaf));
|
||||||
|
|
||||||
|
loreNodeService.deleteLoreNode("n-1");
|
||||||
|
|
||||||
|
// Feuilles d'abord (pages puis dossier leaf), puis mid, puis la racine.
|
||||||
|
verify(pageRepository).deleteById("p-leaf");
|
||||||
|
verify(loreNodeRepository).deleteById("n-1a1");
|
||||||
|
verify(loreNodeRepository).deleteById("n-1a");
|
||||||
|
verify(loreNodeRepository).deleteById("n-1");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testGetDeletionImpact_CountsSubfoldersAndPages() {
|
||||||
|
LoreNode sub1 = LoreNode.builder().id("s-1").parentId("n-1").loreId("lore-1").name("s1").build();
|
||||||
|
LoreNode sub2 = LoreNode.builder().id("s-2").parentId("n-1").loreId("lore-1").name("s2").build();
|
||||||
|
LoreNode subsub = LoreNode.builder().id("s-1a").parentId("s-1").loreId("lore-1").name("s1a").build();
|
||||||
|
Page p1 = Page.builder().id("p-1").nodeId("n-1").title("P1").build();
|
||||||
|
Page p2 = Page.builder().id("p-2").nodeId("s-1").title("P2").build();
|
||||||
|
Page p3 = Page.builder().id("p-3").nodeId("s-1a").title("P3").build();
|
||||||
|
|
||||||
|
when(loreNodeRepository.findByParentId("n-1")).thenReturn(List.of(sub1, sub2));
|
||||||
|
when(loreNodeRepository.findByParentId("s-1")).thenReturn(List.of(subsub));
|
||||||
|
when(pageRepository.findByNodeId("n-1")).thenReturn(List.of(p1));
|
||||||
|
when(pageRepository.findByNodeId("s-1")).thenReturn(List.of(p2));
|
||||||
|
when(pageRepository.findByNodeId("s-2")).thenReturn(List.of());
|
||||||
|
when(pageRepository.findByNodeId("s-1a")).thenReturn(List.of(p3));
|
||||||
|
|
||||||
|
LoreNodeService.DeletionImpact impact = loreNodeService.getDeletionImpact("n-1");
|
||||||
|
|
||||||
|
// 3 sous-dossiers (sub1, sub2, subsub) — on ne compte pas la racine n-1.
|
||||||
|
assertEquals(3, impact.folders());
|
||||||
|
assertEquals(3, impact.pages());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
package com.loremind.application.lorecontext;
|
package com.loremind.application.lorecontext;
|
||||||
|
|
||||||
|
import com.loremind.domain.campaigncontext.Campaign;
|
||||||
|
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
|
||||||
import com.loremind.domain.lorecontext.Lore;
|
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.LoreNodeRepository;
|
||||||
import com.loremind.domain.lorecontext.ports.LoreRepository;
|
import com.loremind.domain.lorecontext.ports.LoreRepository;
|
||||||
import com.loremind.domain.lorecontext.ports.PageRepository;
|
import com.loremind.domain.lorecontext.ports.PageRepository;
|
||||||
|
import com.loremind.domain.lorecontext.ports.TemplateRepository;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.junit.jupiter.api.extension.ExtendWith;
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
@@ -17,6 +23,7 @@ import java.util.Optional;
|
|||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.*;
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
import static org.mockito.Mockito.*;
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -30,6 +37,8 @@ public class LoreServiceTest {
|
|||||||
@Mock private LoreRepository loreRepository;
|
@Mock private LoreRepository loreRepository;
|
||||||
@Mock private LoreNodeRepository loreNodeRepository;
|
@Mock private LoreNodeRepository loreNodeRepository;
|
||||||
@Mock private PageRepository pageRepository;
|
@Mock private PageRepository pageRepository;
|
||||||
|
@Mock private TemplateRepository templateRepository;
|
||||||
|
@Mock private CampaignRepository campaignRepository;
|
||||||
|
|
||||||
@InjectMocks private LoreService loreService;
|
@InjectMocks private LoreService loreService;
|
||||||
|
|
||||||
@@ -134,8 +143,67 @@ public class LoreServiceTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void testDeleteLore_DelegatesToRepository() {
|
void testDeleteLore_EmptyLore() {
|
||||||
|
// Aucun dossier / page / template / campagne : seul le Lore est supprimé.
|
||||||
loreService.deleteLore("lore-1");
|
loreService.deleteLore("lore-1");
|
||||||
verify(loreRepository).deleteById("lore-1");
|
verify(loreRepository).deleteById("lore-1");
|
||||||
|
verify(loreNodeRepository, never()).deleteById(anyString());
|
||||||
|
verify(pageRepository, never()).deleteById(anyString());
|
||||||
|
verify(templateRepository, never()).deleteById(anyString());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testDeleteLore_CascadesFoldersPagesTemplates() {
|
||||||
|
LoreNode node = LoreNode.builder().id("n-1").loreId("lore-1").name("F").build();
|
||||||
|
Page page = Page.builder().id("p-1").loreId("lore-1").nodeId("n-1").title("P").build();
|
||||||
|
Template template = Template.builder().id("t-1").loreId("lore-1").name("T").build();
|
||||||
|
|
||||||
|
when(pageRepository.findByLoreId("lore-1")).thenReturn(List.of(page));
|
||||||
|
when(loreNodeRepository.findByLoreId("lore-1")).thenReturn(List.of(node));
|
||||||
|
when(templateRepository.findByLoreId("lore-1")).thenReturn(List.of(template));
|
||||||
|
|
||||||
|
loreService.deleteLore("lore-1");
|
||||||
|
|
||||||
|
verify(pageRepository).deleteById("p-1");
|
||||||
|
verify(loreNodeRepository).deleteById("n-1");
|
||||||
|
verify(templateRepository).deleteById("t-1");
|
||||||
|
verify(loreRepository).deleteById("lore-1");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testDeleteLore_DetachesCampaignsInsteadOfDeleting() {
|
||||||
|
Campaign attached = Campaign.builder().id("c-1").loreId("lore-1").name("C1").build();
|
||||||
|
Campaign other = Campaign.builder().id("c-2").loreId("lore-other").name("C2").build();
|
||||||
|
Campaign orphan = Campaign.builder().id("c-3").loreId(null).name("C3").build();
|
||||||
|
when(campaignRepository.findAll()).thenReturn(List.of(attached, other, orphan));
|
||||||
|
when(campaignRepository.save(any(Campaign.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
loreService.deleteLore("lore-1");
|
||||||
|
|
||||||
|
// Seule la campagne attachée est re-sauvegardée (avec loreId=null).
|
||||||
|
ArgumentCaptor<Campaign> captor = ArgumentCaptor.forClass(Campaign.class);
|
||||||
|
verify(campaignRepository, times(1)).save(captor.capture());
|
||||||
|
assertEquals("c-1", captor.getValue().getId());
|
||||||
|
assertNull(captor.getValue().getLoreId());
|
||||||
|
// Aucune campagne n'est supprimée.
|
||||||
|
verify(campaignRepository, never()).deleteById(anyString());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testGetDeletionImpact() {
|
||||||
|
Template t1 = Template.builder().id("t-1").loreId("lore-1").name("T").build();
|
||||||
|
Campaign attached = Campaign.builder().id("c-1").loreId("lore-1").name("C").build();
|
||||||
|
Campaign unrelated = Campaign.builder().id("c-2").loreId("lore-other").name("C2").build();
|
||||||
|
when(loreNodeRepository.countByLoreId("lore-1")).thenReturn(4L);
|
||||||
|
when(pageRepository.countByLoreId("lore-1")).thenReturn(12L);
|
||||||
|
when(templateRepository.findByLoreId("lore-1")).thenReturn(List.of(t1));
|
||||||
|
when(campaignRepository.findAll()).thenReturn(List.of(attached, unrelated));
|
||||||
|
|
||||||
|
LoreService.DeletionImpact impact = loreService.getDeletionImpact("lore-1");
|
||||||
|
|
||||||
|
assertEquals(4, impact.folders());
|
||||||
|
assertEquals(12, impact.pages());
|
||||||
|
assertEquals(1, impact.templates());
|
||||||
|
assertEquals(1, impact.detachedCampaigns());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,48 +9,36 @@ import static org.junit.jupiter.api.Assertions.assertNull;
|
|||||||
/**
|
/**
|
||||||
* Tests unitaires pour SceneBranch (Value Object).
|
* Tests unitaires pour SceneBranch (Value Object).
|
||||||
* Verifie :
|
* Verifie :
|
||||||
* - l'immuabilite (pas de setters : seul le builder permet la construction),
|
* - l'immuabilite (record : aucun setter, constructeur canonique uniquement),
|
||||||
* - l'egalite structurelle generee par @Value (equals/hashCode sur tous les
|
* - l'egalite structurelle generee par record (equals/hashCode sur tous les
|
||||||
* champs) — deux branches aux memes champs sont strictement egales,
|
* champs) — deux branches aux memes champs sont strictement egales,
|
||||||
* - le support du champ optionnel {@code condition}.
|
* - le support du champ optionnel {@code condition}.
|
||||||
*/
|
*/
|
||||||
class SceneBranchTest {
|
class SceneBranchTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void builder_exposesAllFields() {
|
void constructor_exposesAllFields() {
|
||||||
SceneBranch branch = SceneBranch.builder()
|
SceneBranch branch = new SceneBranch(
|
||||||
.label("Si les joueurs attaquent le garde")
|
"Si les joueurs attaquent le garde",
|
||||||
.targetSceneId("sc-combat")
|
"sc-combat",
|
||||||
.condition("initiative > 15")
|
"initiative > 15");
|
||||||
.build();
|
|
||||||
|
|
||||||
assertEquals("Si les joueurs attaquent le garde", branch.getLabel());
|
assertEquals("Si les joueurs attaquent le garde", branch.label());
|
||||||
assertEquals("sc-combat", branch.getTargetSceneId());
|
assertEquals("sc-combat", branch.targetSceneId());
|
||||||
assertEquals("initiative > 15", branch.getCondition());
|
assertEquals("initiative > 15", branch.condition());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void condition_isOptional() {
|
void condition_isOptional() {
|
||||||
SceneBranch branch = SceneBranch.builder()
|
SceneBranch branch = SceneBranch.of("sortie par la porte", "sc-corridor");
|
||||||
.label("sortie par la porte")
|
|
||||||
.targetSceneId("sc-corridor")
|
|
||||||
.build();
|
|
||||||
|
|
||||||
assertNull(branch.getCondition());
|
assertNull(branch.condition());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void twoBranches_withSameFields_areEqual() {
|
void twoBranches_withSameFields_areEqual() {
|
||||||
SceneBranch a = SceneBranch.builder()
|
SceneBranch a = new SceneBranch("fuite", "sc-2", null);
|
||||||
.label("fuite")
|
SceneBranch b = new SceneBranch("fuite", "sc-2", null);
|
||||||
.targetSceneId("sc-2")
|
|
||||||
.condition(null)
|
|
||||||
.build();
|
|
||||||
SceneBranch b = SceneBranch.builder()
|
|
||||||
.label("fuite")
|
|
||||||
.targetSceneId("sc-2")
|
|
||||||
.condition(null)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
assertEquals(a, b);
|
assertEquals(a, b);
|
||||||
assertEquals(a.hashCode(), b.hashCode());
|
assertEquals(a.hashCode(), b.hashCode());
|
||||||
@@ -58,16 +46,16 @@ class SceneBranchTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void twoBranches_differingOnTargetSceneId_areNotEqual() {
|
void twoBranches_differingOnTargetSceneId_areNotEqual() {
|
||||||
SceneBranch a = SceneBranch.builder().label("X").targetSceneId("sc-1").build();
|
SceneBranch a = SceneBranch.of("X", "sc-1");
|
||||||
SceneBranch b = SceneBranch.builder().label("X").targetSceneId("sc-2").build();
|
SceneBranch b = SceneBranch.of("X", "sc-2");
|
||||||
|
|
||||||
assertNotEquals(a, b);
|
assertNotEquals(a, b);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void twoBranches_differingOnCondition_areNotEqual() {
|
void twoBranches_differingOnCondition_areNotEqual() {
|
||||||
SceneBranch a = SceneBranch.builder().label("X").targetSceneId("sc-1").condition("A").build();
|
SceneBranch a = new SceneBranch("X", "sc-1", "A");
|
||||||
SceneBranch b = SceneBranch.builder().label("X").targetSceneId("sc-1").condition("B").build();
|
SceneBranch b = new SceneBranch("X", "sc-1", "B");
|
||||||
|
|
||||||
assertNotEquals(a, b);
|
assertNotEquals(a, b);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,15 +60,15 @@ class SceneTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void builder_preservesBranches_whenProvided() {
|
void builder_preservesBranches_whenProvided() {
|
||||||
SceneBranch b1 = SceneBranch.builder().label("fuite").targetSceneId("sc-2").build();
|
SceneBranch b1 = SceneBranch.of("fuite", "sc-2");
|
||||||
SceneBranch b2 = SceneBranch.builder().label("combat").targetSceneId("sc-3").build();
|
SceneBranch b2 = SceneBranch.of("combat", "sc-3");
|
||||||
|
|
||||||
Scene scene = Scene.builder()
|
Scene scene = Scene.builder()
|
||||||
.branches(List.of(b1, b2))
|
.branches(List.of(b1, b2))
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
assertEquals(2, scene.getBranches().size());
|
assertEquals(2, scene.getBranches().size());
|
||||||
assertEquals("fuite", scene.getBranches().get(0).getLabel());
|
assertEquals("fuite", scene.getBranches().get(0).label());
|
||||||
assertEquals("sc-3", scene.getBranches().get(1).getTargetSceneId());
|
assertEquals("sc-3", scene.getBranches().get(1).targetSceneId());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,108 +6,98 @@ import com.loremind.domain.generationcontext.CampaignStructuralContext.ChapterSu
|
|||||||
import com.loremind.domain.generationcontext.CampaignStructuralContext.SceneSummary;
|
import com.loremind.domain.generationcontext.CampaignStructuralContext.SceneSummary;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tests unitaires pour CampaignStructuralContext et ses types imbriques.
|
* Tests unitaires pour CampaignStructuralContext et ses types imbriques.
|
||||||
* Focus sur les annotations @Singular (chapters/scenes/branches/arcs) qui
|
* Records purs : aucune dependance technique.
|
||||||
* permettent une construction incrementale du graphe narratif.
|
|
||||||
*/
|
*/
|
||||||
class CampaignStructuralContextTest {
|
class CampaignStructuralContextTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void builder_constructsFullNarrativeTree() {
|
void constructor_buildsFullNarrativeTree() {
|
||||||
BranchHint branch = BranchHint.builder()
|
BranchHint branch = new BranchHint("si les PJ fuient", "La poursuite", "PJ < moitie des HP");
|
||||||
.label("si les PJ fuient")
|
|
||||||
.targetSceneName("La poursuite")
|
|
||||||
.condition("PJ < moitie des HP")
|
|
||||||
.build();
|
|
||||||
|
|
||||||
SceneSummary scene = SceneSummary.builder()
|
SceneSummary scene = new SceneSummary(
|
||||||
.name("L'auberge")
|
"L'auberge",
|
||||||
.description("Rencontre tendue avec le tavernier")
|
"Rencontre tendue avec le tavernier",
|
||||||
.illustrationCount(2)
|
2,
|
||||||
.branch(branch)
|
List.of(branch));
|
||||||
.build();
|
|
||||||
|
|
||||||
ChapterSummary chapter = ChapterSummary.builder()
|
ChapterSummary chapter = new ChapterSummary(
|
||||||
.name("L'arrivee")
|
"L'arrivee",
|
||||||
.description("Les PJ decouvrent la ville")
|
"Les PJ decouvrent la ville",
|
||||||
.scene(scene)
|
0,
|
||||||
.build();
|
List.of(scene));
|
||||||
|
|
||||||
ArcSummary arc = ArcSummary.builder()
|
ArcSummary arc = new ArcSummary(
|
||||||
.name("Acte I")
|
"Acte I",
|
||||||
.description("Mise en place")
|
"Mise en place",
|
||||||
.illustrationCount(1)
|
1,
|
||||||
.chapter(chapter)
|
List.of(chapter));
|
||||||
.build();
|
|
||||||
|
|
||||||
CampaignStructuralContext ctx = CampaignStructuralContext.builder()
|
CampaignStructuralContext ctx = new CampaignStructuralContext(
|
||||||
.campaignName("Les Ombres")
|
"Les Ombres",
|
||||||
.campaignDescription("Une campagne dark fantasy")
|
"Une campagne dark fantasy",
|
||||||
.arc(arc)
|
List.of(arc),
|
||||||
.build();
|
List.of(),
|
||||||
|
List.of());
|
||||||
|
|
||||||
assertEquals("Les Ombres", ctx.getCampaignName());
|
assertEquals("Les Ombres", ctx.campaignName());
|
||||||
assertEquals(1, ctx.getArcs().size());
|
assertEquals(1, ctx.arcs().size());
|
||||||
assertEquals(1, ctx.getArcs().get(0).getChapters().size());
|
assertEquals(1, ctx.arcs().get(0).chapters().size());
|
||||||
assertEquals(1, ctx.getArcs().get(0).getChapters().get(0).getScenes().size());
|
assertEquals(1, ctx.arcs().get(0).chapters().get(0).scenes().size());
|
||||||
assertEquals(1, ctx.getArcs().get(0).getChapters().get(0).getScenes().get(0).getBranches().size());
|
assertEquals(1, ctx.arcs().get(0).chapters().get(0).scenes().get(0).branches().size());
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- BranchHint ---------------------------------------------------------
|
// --- BranchHint ---------------------------------------------------------
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void branchHint_preservesAllFields() {
|
void branchHint_preservesAllFields() {
|
||||||
BranchHint b = BranchHint.builder()
|
BranchHint b = new BranchHint("combat", "La confrontation", "initiative > 15");
|
||||||
.label("combat")
|
|
||||||
.targetSceneName("La confrontation")
|
|
||||||
.condition("initiative > 15")
|
|
||||||
.build();
|
|
||||||
|
|
||||||
assertEquals("combat", b.getLabel());
|
assertEquals("combat", b.label());
|
||||||
assertEquals("La confrontation", b.getTargetSceneName());
|
assertEquals("La confrontation", b.targetSceneName());
|
||||||
assertEquals("initiative > 15", b.getCondition());
|
assertEquals("initiative > 15", b.condition());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void branchHint_conditionIsOptional() {
|
void branchHint_conditionIsOptional() {
|
||||||
BranchHint b = BranchHint.builder()
|
BranchHint b = new BranchHint("suite normale", "Scene 2", null);
|
||||||
.label("suite normale")
|
|
||||||
.targetSceneName("Scene 2")
|
|
||||||
.build();
|
|
||||||
|
|
||||||
assertNull(b.getCondition());
|
assertNull(b.condition());
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- illustrationCount --------------------------------------------------
|
// --- illustrationCount --------------------------------------------------
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void illustrationCount_defaultsToZero_onAllSummaryTypes() {
|
void illustrationCount_defaultsToZero_onAllSummaryTypes() {
|
||||||
ArcSummary arc = ArcSummary.builder().name("X").build();
|
ArcSummary arc = new ArcSummary("X", null, 0, List.of());
|
||||||
ChapterSummary chapter = ChapterSummary.builder().name("X").build();
|
ChapterSummary chapter = new ChapterSummary("X", null, 0, List.of());
|
||||||
SceneSummary scene = SceneSummary.builder().name("X").build();
|
SceneSummary scene = new SceneSummary("X", null, 0, List.of());
|
||||||
|
|
||||||
assertEquals(0, arc.getIllustrationCount());
|
assertEquals(0, arc.illustrationCount());
|
||||||
assertEquals(0, chapter.getIllustrationCount());
|
assertEquals(0, chapter.illustrationCount());
|
||||||
assertEquals(0, scene.getIllustrationCount());
|
assertEquals(0, scene.illustrationCount());
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- @Singular : accumulation incrementale -----------------------------
|
// --- Construction incrementale (chapitres multiples) -------------------
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void singular_accumulatesMultipleCalls() {
|
void multipleChapters_arePreserved() {
|
||||||
ArcSummary arc = ArcSummary.builder()
|
ArcSummary arc = new ArcSummary(
|
||||||
.name("Acte I")
|
"Acte I",
|
||||||
.chapter(ChapterSummary.builder().name("Ch1").build())
|
null,
|
||||||
.chapter(ChapterSummary.builder().name("Ch2").build())
|
0,
|
||||||
.chapter(ChapterSummary.builder().name("Ch3").build())
|
List.of(
|
||||||
.build();
|
new ChapterSummary("Ch1", null, 0, List.of()),
|
||||||
|
new ChapterSummary("Ch2", null, 0, List.of()),
|
||||||
|
new ChapterSummary("Ch3", null, 0, List.of())));
|
||||||
|
|
||||||
assertEquals(3, arc.getChapters().size());
|
assertEquals(3, arc.chapters().size());
|
||||||
assertTrue(arc.getChapters().stream().anyMatch(c -> "Ch2".equals(c.getName())));
|
assertEquals("Ch2", arc.chapters().get(1).name());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package com.loremind.domain.generationcontext;
|
|||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
@@ -26,57 +27,45 @@ class ChatRequestTest {
|
|||||||
void buildLoreOnly_leavesCampaignAndEntityNull() {
|
void buildLoreOnly_leavesCampaignAndEntityNull() {
|
||||||
ChatRequest request = ChatRequest.builder()
|
ChatRequest request = ChatRequest.builder()
|
||||||
.messages(sampleMessages)
|
.messages(sampleMessages)
|
||||||
.loreContext(LoreStructuralContext.builder()
|
.loreContext(new LoreStructuralContext("Ithoril", "Royaume sombre", Map.of(), List.of()))
|
||||||
.loreName("Ithoril")
|
|
||||||
.loreDescription("Royaume sombre")
|
|
||||||
.folders(java.util.Map.of())
|
|
||||||
.build())
|
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
assertEquals(1, request.getMessages().size());
|
assertEquals(1, request.messages().size());
|
||||||
assertNotNull(request.getLoreContext());
|
assertNotNull(request.loreContext());
|
||||||
assertEquals("Ithoril", request.getLoreContext().getLoreName());
|
assertEquals("Ithoril", request.loreContext().loreName());
|
||||||
assertNull(request.getPageContext());
|
assertNull(request.pageContext());
|
||||||
assertNull(request.getCampaignContext());
|
assertNull(request.campaignContext());
|
||||||
assertNull(request.getNarrativeEntity());
|
assertNull(request.narrativeEntity());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void buildLoreWithPageFocus_hasBothContexts() {
|
void buildLoreWithPageFocus_hasBothContexts() {
|
||||||
ChatRequest request = ChatRequest.builder()
|
ChatRequest request = ChatRequest.builder()
|
||||||
.messages(sampleMessages)
|
.messages(sampleMessages)
|
||||||
.loreContext(LoreStructuralContext.builder().folders(java.util.Map.of()).build())
|
.loreContext(new LoreStructuralContext(null, null, Map.of(), List.of()))
|
||||||
.pageContext(PageContext.builder()
|
.pageContext(new PageContext("Thorin", "PNJ", null, null))
|
||||||
.title("Thorin")
|
|
||||||
.templateName("PNJ")
|
|
||||||
.build())
|
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
assertNotNull(request.getLoreContext());
|
assertNotNull(request.loreContext());
|
||||||
assertNotNull(request.getPageContext());
|
assertNotNull(request.pageContext());
|
||||||
assertEquals("Thorin", request.getPageContext().getTitle());
|
assertEquals("Thorin", request.pageContext().title());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void buildCampaignWithNarrativeEntity_hasBothContexts() {
|
void buildCampaignWithNarrativeEntity_hasBothContexts() {
|
||||||
ChatRequest request = ChatRequest.builder()
|
ChatRequest request = ChatRequest.builder()
|
||||||
.messages(sampleMessages)
|
.messages(sampleMessages)
|
||||||
.campaignContext(CampaignStructuralContext.builder()
|
.campaignContext(new CampaignStructuralContext(
|
||||||
.campaignName("Les Ombres")
|
"Les Ombres", "...", List.of(), List.of(), List.of()))
|
||||||
.campaignDescription("...")
|
.narrativeEntity(new NarrativeEntityContext(
|
||||||
.build())
|
"scene", "L'auberge", Map.of("location", "Taverne")))
|
||||||
.narrativeEntity(NarrativeEntityContext.builder()
|
|
||||||
.entityType("scene")
|
|
||||||
.title("L'auberge")
|
|
||||||
.fields(java.util.Map.of("location", "Taverne"))
|
|
||||||
.build())
|
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
assertNotNull(request.getCampaignContext());
|
assertNotNull(request.campaignContext());
|
||||||
assertNotNull(request.getNarrativeEntity());
|
assertNotNull(request.narrativeEntity());
|
||||||
assertEquals("scene", request.getNarrativeEntity().getEntityType());
|
assertEquals("scene", request.narrativeEntity().entityType());
|
||||||
assertNull(request.getLoreContext());
|
assertNull(request.loreContext());
|
||||||
assertNull(request.getPageContext());
|
assertNull(request.pageContext());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -86,10 +75,10 @@ class ChatRequestTest {
|
|||||||
.messages(sampleMessages)
|
.messages(sampleMessages)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
assertEquals(1, request.getMessages().size());
|
assertEquals(1, request.messages().size());
|
||||||
assertNull(request.getLoreContext());
|
assertNull(request.loreContext());
|
||||||
assertNull(request.getPageContext());
|
assertNull(request.pageContext());
|
||||||
assertNull(request.getCampaignContext());
|
assertNull(request.campaignContext());
|
||||||
assertNull(request.getNarrativeEntity());
|
assertNull(request.narrativeEntity());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,41 +9,38 @@ import static org.junit.jupiter.api.Assertions.assertNotEquals;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Tests unitaires pour GenerationContext (Value Object pour la generation one-shot).
|
* Tests unitaires pour GenerationContext (Value Object pour la generation one-shot).
|
||||||
* Verifie la construction via builder et l'egalite structurelle.
|
* Verifie la construction et l'egalite structurelle (record).
|
||||||
*/
|
*/
|
||||||
class GenerationContextTest {
|
class GenerationContextTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void builder_preservesAllFields() {
|
void constructor_preservesAllFields() {
|
||||||
GenerationContext ctx = GenerationContext.builder()
|
GenerationContext ctx = new GenerationContext(
|
||||||
.loreName("Ithoril")
|
"Ithoril",
|
||||||
.loreDescription("Royaume sombre")
|
"Royaume sombre",
|
||||||
.folderName("PNJ")
|
"PNJ",
|
||||||
.templateName("Fiche PNJ")
|
"Fiche PNJ",
|
||||||
.templateFields(List.of("histoire", "motto", "apparence"))
|
List.of("histoire", "motto", "apparence"),
|
||||||
.pageTitle("Thorin")
|
"Thorin");
|
||||||
.build();
|
|
||||||
|
|
||||||
assertEquals("Ithoril", ctx.getLoreName());
|
assertEquals("Ithoril", ctx.loreName());
|
||||||
assertEquals("PNJ", ctx.getFolderName());
|
assertEquals("PNJ", ctx.folderName());
|
||||||
assertEquals("Fiche PNJ", ctx.getTemplateName());
|
assertEquals("Fiche PNJ", ctx.templateName());
|
||||||
assertEquals(3, ctx.getTemplateFields().size());
|
assertEquals(3, ctx.templateFields().size());
|
||||||
assertEquals("Thorin", ctx.getPageTitle());
|
assertEquals("Thorin", ctx.pageTitle());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void twoContexts_withSameFields_areEqual() {
|
void twoContexts_withSameFields_areEqual() {
|
||||||
GenerationContext a = GenerationContext.builder()
|
GenerationContext a = new GenerationContext("X", null, null, null, List.of("f1"), "A");
|
||||||
.loreName("X").pageTitle("A").templateFields(List.of("f1")).build();
|
GenerationContext b = new GenerationContext("X", null, null, null, List.of("f1"), "A");
|
||||||
GenerationContext b = GenerationContext.builder()
|
|
||||||
.loreName("X").pageTitle("A").templateFields(List.of("f1")).build();
|
|
||||||
assertEquals(a, b);
|
assertEquals(a, b);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void twoContexts_differingOnPageTitle_areNotEqual() {
|
void twoContexts_differingOnPageTitle_areNotEqual() {
|
||||||
GenerationContext a = GenerationContext.builder().pageTitle("A").build();
|
GenerationContext a = new GenerationContext(null, null, null, null, null, "A");
|
||||||
GenerationContext b = GenerationContext.builder().pageTitle("B").build();
|
GenerationContext b = new GenerationContext(null, null, null, null, null, "B");
|
||||||
assertNotEquals(a, b);
|
assertNotEquals(a, b);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,66 +12,61 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Tests unitaires pour LoreStructuralContext et son type imbrique PageSummary.
|
* Tests unitaires pour LoreStructuralContext et son type imbrique PageSummary.
|
||||||
* Valide le @Singular de Lombok sur {@code tags} (alimentation incrementale via
|
* Records purs : aucune dependance technique.
|
||||||
* {@code tag(...)} vs initialisation groupee via {@code tags(...)}).
|
|
||||||
*/
|
*/
|
||||||
class LoreStructuralContextTest {
|
class LoreStructuralContextTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void builder_preservesFoldersAndTags() {
|
void constructor_preservesFoldersAndTags() {
|
||||||
PageSummary pnj = PageSummary.builder()
|
PageSummary pnj = new PageSummary(
|
||||||
.title("Thorin")
|
"Thorin",
|
||||||
.templateName("PNJ")
|
"PNJ",
|
||||||
.values(Map.of("histoire", "Nee sous une etoile rouge"))
|
Map.of("histoire", "Nee sous une etoile rouge"),
|
||||||
.tags(List.of("pnj", "allie"))
|
List.of("pnj", "allie"),
|
||||||
.relatedPageTitles(List.of("Taverne du Dragon d'Or"))
|
List.of("Taverne du Dragon d'Or"));
|
||||||
.build();
|
|
||||||
|
|
||||||
LoreStructuralContext ctx = LoreStructuralContext.builder()
|
LoreStructuralContext ctx = new LoreStructuralContext(
|
||||||
.loreName("Ithoril")
|
"Ithoril",
|
||||||
.loreDescription("Royaume sombre")
|
"Royaume sombre",
|
||||||
.folders(Map.of("PNJ", List.of(pnj)))
|
Map.of("PNJ", List.of(pnj)),
|
||||||
.tag("royaume")
|
List.of("royaume", "dark-fantasy"));
|
||||||
.tag("dark-fantasy")
|
|
||||||
.build();
|
|
||||||
|
|
||||||
assertEquals("Ithoril", ctx.getLoreName());
|
assertEquals("Ithoril", ctx.loreName());
|
||||||
assertEquals(1, ctx.getFolders().size());
|
assertEquals(1, ctx.folders().size());
|
||||||
assertEquals(1, ctx.getFolders().get("PNJ").size());
|
assertEquals(1, ctx.folders().get("PNJ").size());
|
||||||
assertEquals(2, ctx.getTags().size(), "@Singular doit accumuler les appels tag()");
|
assertEquals(2, ctx.tags().size());
|
||||||
assertTrue(ctx.getTags().contains("royaume"));
|
assertTrue(ctx.tags().contains("royaume"));
|
||||||
assertTrue(ctx.getTags().contains("dark-fantasy"));
|
assertTrue(ctx.tags().contains("dark-fantasy"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void emptyFolders_areAllowed() {
|
void emptyFolders_areAllowed() {
|
||||||
// Dossier vide : legitime (ex: dossier "Lieux" cree mais pas encore peuple).
|
// Dossier vide : legitime (ex: dossier "Lieux" cree mais pas encore peuple).
|
||||||
LoreStructuralContext ctx = LoreStructuralContext.builder()
|
LoreStructuralContext ctx = new LoreStructuralContext(
|
||||||
.loreName("Vide")
|
"Vide",
|
||||||
.loreDescription("")
|
"",
|
||||||
.folders(Map.of("Lieux", List.of()))
|
Map.of("Lieux", List.of()),
|
||||||
.build();
|
List.of());
|
||||||
|
|
||||||
assertNotNull(ctx.getFolders().get("Lieux"));
|
assertNotNull(ctx.folders().get("Lieux"));
|
||||||
assertTrue(ctx.getFolders().get("Lieux").isEmpty());
|
assertTrue(ctx.folders().get("Lieux").isEmpty());
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- PageSummary --------------------------------------------------------
|
// --- PageSummary --------------------------------------------------------
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void pageSummary_preservesAllFields() {
|
void pageSummary_preservesAllFields() {
|
||||||
PageSummary ps = PageSummary.builder()
|
PageSummary ps = new PageSummary(
|
||||||
.title("Le Donjon du Chaos")
|
"Le Donjon du Chaos",
|
||||||
.templateName("Lieu")
|
"Lieu",
|
||||||
.values(Map.of("histoire", "Bati il y a 1000 ans..."))
|
Map.of("histoire", "Bati il y a 1000 ans..."),
|
||||||
.tags(List.of("donjon", "ancien"))
|
List.of("donjon", "ancien"),
|
||||||
.relatedPageTitles(List.of("Thorin", "Garde royale"))
|
List.of("Thorin", "Garde royale"));
|
||||||
.build();
|
|
||||||
|
|
||||||
assertEquals("Le Donjon du Chaos", ps.getTitle());
|
assertEquals("Le Donjon du Chaos", ps.title());
|
||||||
assertEquals("Lieu", ps.getTemplateName());
|
assertEquals("Lieu", ps.templateName());
|
||||||
assertEquals(1, ps.getValues().size());
|
assertEquals(1, ps.values().size());
|
||||||
assertEquals(2, ps.getTags().size());
|
assertEquals(2, ps.tags().size());
|
||||||
assertEquals(2, ps.getRelatedPageTitles().size());
|
assertEquals(2, ps.relatedPageTitles().size());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,21 +16,17 @@ import static org.junit.jupiter.api.Assertions.assertNotEquals;
|
|||||||
class NarrativeEntityContextTest {
|
class NarrativeEntityContextTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void builder_preservesAllFields() {
|
void constructor_preservesAllFields() {
|
||||||
Map<String, String> fields = new LinkedHashMap<>();
|
Map<String, String> fields = new LinkedHashMap<>();
|
||||||
fields.put("themes", "trahison");
|
fields.put("themes", "trahison");
|
||||||
fields.put("stakes", "la survie du royaume");
|
fields.put("stakes", "la survie du royaume");
|
||||||
|
|
||||||
NarrativeEntityContext ctx = NarrativeEntityContext.builder()
|
NarrativeEntityContext ctx = new NarrativeEntityContext("arc", "Acte I", fields);
|
||||||
.entityType("arc")
|
|
||||||
.title("Acte I")
|
|
||||||
.fields(fields)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
assertEquals("arc", ctx.getEntityType());
|
assertEquals("arc", ctx.entityType());
|
||||||
assertEquals("Acte I", ctx.getTitle());
|
assertEquals("Acte I", ctx.title());
|
||||||
assertEquals(2, ctx.getFields().size());
|
assertEquals(2, ctx.fields().size());
|
||||||
assertEquals("trahison", ctx.getFields().get("themes"));
|
assertEquals("trahison", ctx.fields().get("themes"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -41,19 +37,15 @@ class NarrativeEntityContextTest {
|
|||||||
fields.put("timing", "Soir");
|
fields.put("timing", "Soir");
|
||||||
fields.put("atmosphere", "fumee");
|
fields.put("atmosphere", "fumee");
|
||||||
|
|
||||||
NarrativeEntityContext ctx = NarrativeEntityContext.builder()
|
NarrativeEntityContext ctx = new NarrativeEntityContext("scene", "L'auberge", fields);
|
||||||
.entityType("scene")
|
|
||||||
.title("L'auberge")
|
|
||||||
.fields(fields)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
assertEquals("[location, timing, atmosphere]", ctx.getFields().keySet().toString());
|
assertEquals("[location, timing, atmosphere]", ctx.fields().keySet().toString());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void twoContexts_differingOnEntityType_areNotEqual() {
|
void twoContexts_differingOnEntityType_areNotEqual() {
|
||||||
NarrativeEntityContext a = NarrativeEntityContext.builder().entityType("arc").title("X").build();
|
NarrativeEntityContext a = new NarrativeEntityContext("arc", "X", Map.of());
|
||||||
NarrativeEntityContext b = NarrativeEntityContext.builder().entityType("scene").title("X").build();
|
NarrativeEntityContext b = new NarrativeEntityContext("scene", "X", Map.of());
|
||||||
assertNotEquals(a, b);
|
assertNotEquals(a, b);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,31 +14,29 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
|
|||||||
class PageContextTest {
|
class PageContextTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void builder_preservesAllFields() {
|
void constructor_preservesAllFields() {
|
||||||
PageContext ctx = PageContext.builder()
|
PageContext ctx = new PageContext(
|
||||||
.title("Thorin")
|
"Thorin",
|
||||||
.templateName("PNJ")
|
"PNJ",
|
||||||
.templateFields(List.of("histoire", "apparence", "motto"))
|
List.of("histoire", "apparence", "motto"),
|
||||||
.values(Map.of("histoire", "Nee sous une etoile rouge"))
|
Map.of("histoire", "Nee sous une etoile rouge"));
|
||||||
.build();
|
|
||||||
|
|
||||||
assertEquals("Thorin", ctx.getTitle());
|
assertEquals("Thorin", ctx.title());
|
||||||
assertEquals("PNJ", ctx.getTemplateName());
|
assertEquals("PNJ", ctx.templateName());
|
||||||
assertEquals(3, ctx.getTemplateFields().size());
|
assertEquals(3, ctx.templateFields().size());
|
||||||
assertEquals(1, ctx.getValues().size());
|
assertEquals(1, ctx.values().size());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void emptyValues_areAllowed() {
|
void emptyValues_areAllowed() {
|
||||||
// Page vierge : template defini mais aucun champ rempli (cas generation ex-nihilo).
|
// Page vierge : template defini mais aucun champ rempli (cas generation ex-nihilo).
|
||||||
PageContext ctx = PageContext.builder()
|
PageContext ctx = new PageContext(
|
||||||
.title("Nouveau PNJ")
|
"Nouveau PNJ",
|
||||||
.templateName("PNJ")
|
"PNJ",
|
||||||
.templateFields(List.of("histoire", "apparence"))
|
List.of("histoire", "apparence"),
|
||||||
.values(Map.of())
|
Map.of());
|
||||||
.build();
|
|
||||||
|
|
||||||
assertTrue(ctx.getValues().isEmpty());
|
assertTrue(ctx.values().isEmpty());
|
||||||
assertEquals(2, ctx.getTemplateFields().size());
|
assertEquals(2, ctx.templateFields().size());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,12 +83,8 @@ class BrainChatPayloadBuilderTest {
|
|||||||
@Test
|
@Test
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
void build_loreContext_includesBasicFields() {
|
void build_loreContext_includesBasicFields() {
|
||||||
LoreStructuralContext lore = LoreStructuralContext.builder()
|
LoreStructuralContext lore = new LoreStructuralContext(
|
||||||
.loreName("Ithoril")
|
"Ithoril", "Royaume sombre", Map.of(), List.of("dark-fantasy"));
|
||||||
.loreDescription("Royaume sombre")
|
|
||||||
.folders(Map.of())
|
|
||||||
.tag("dark-fantasy")
|
|
||||||
.build();
|
|
||||||
ChatRequest req = ChatRequest.builder().messages(sampleMessages).loreContext(lore).build();
|
ChatRequest req = ChatRequest.builder().messages(sampleMessages).loreContext(lore).build();
|
||||||
|
|
||||||
Map<String, Object> payload = builder.build(req);
|
Map<String, Object> payload = builder.build(req);
|
||||||
@@ -103,17 +99,10 @@ class BrainChatPayloadBuilderTest {
|
|||||||
@Test
|
@Test
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
void build_pageSummary_omitsEmptyValuesTagsAndRelated() {
|
void build_pageSummary_omitsEmptyValuesTagsAndRelated() {
|
||||||
PageSummary minimal = PageSummary.builder()
|
PageSummary minimal = new PageSummary("Thorin", "PNJ",
|
||||||
.title("Thorin")
|
Map.of(), List.of(), List.of());
|
||||||
.templateName("PNJ")
|
LoreStructuralContext lore = new LoreStructuralContext(
|
||||||
.values(Map.of())
|
"X", "", Map.of("PNJ", List.of(minimal)), List.of());
|
||||||
.tags(List.of())
|
|
||||||
.relatedPageTitles(List.of())
|
|
||||||
.build();
|
|
||||||
LoreStructuralContext lore = LoreStructuralContext.builder()
|
|
||||||
.loreName("X").loreDescription("")
|
|
||||||
.folders(Map.of("PNJ", List.of(minimal)))
|
|
||||||
.build();
|
|
||||||
ChatRequest req = ChatRequest.builder().messages(sampleMessages).loreContext(lore).build();
|
ChatRequest req = ChatRequest.builder().messages(sampleMessages).loreContext(lore).build();
|
||||||
|
|
||||||
Map<String, Object> payload = builder.build(req);
|
Map<String, Object> payload = builder.build(req);
|
||||||
@@ -132,17 +121,12 @@ class BrainChatPayloadBuilderTest {
|
|||||||
@Test
|
@Test
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
void build_pageSummary_includesNonEmptyValuesTagsAndRelated() {
|
void build_pageSummary_includesNonEmptyValuesTagsAndRelated() {
|
||||||
PageSummary full = PageSummary.builder()
|
PageSummary full = new PageSummary("Thorin", "PNJ",
|
||||||
.title("Thorin")
|
Map.of("histoire", "Nee sous une etoile rouge"),
|
||||||
.templateName("PNJ")
|
List.of("pnj", "allie"),
|
||||||
.values(Map.of("histoire", "Nee sous une etoile rouge"))
|
List.of("Taverne du Dragon d'Or"));
|
||||||
.tags(List.of("pnj", "allie"))
|
LoreStructuralContext lore = new LoreStructuralContext(
|
||||||
.relatedPageTitles(List.of("Taverne du Dragon d'Or"))
|
"X", "", Map.of("PNJ", List.of(full)), List.of());
|
||||||
.build();
|
|
||||||
LoreStructuralContext lore = LoreStructuralContext.builder()
|
|
||||||
.loreName("X").loreDescription("")
|
|
||||||
.folders(Map.of("PNJ", List.of(full)))
|
|
||||||
.build();
|
|
||||||
ChatRequest req = ChatRequest.builder().messages(sampleMessages).loreContext(lore).build();
|
ChatRequest req = ChatRequest.builder().messages(sampleMessages).loreContext(lore).build();
|
||||||
|
|
||||||
Map<String, Object> payload = builder.build(req);
|
Map<String, Object> payload = builder.build(req);
|
||||||
@@ -161,12 +145,8 @@ class BrainChatPayloadBuilderTest {
|
|||||||
@Test
|
@Test
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
void build_pageContext_includesAllFields() {
|
void build_pageContext_includesAllFields() {
|
||||||
PageContext pc = PageContext.builder()
|
PageContext pc = new PageContext("Thorin", "PNJ",
|
||||||
.title("Thorin")
|
List.of("histoire", "motto"), Map.of("histoire", "..."));
|
||||||
.templateName("PNJ")
|
|
||||||
.templateFields(List.of("histoire", "motto"))
|
|
||||||
.values(Map.of("histoire", "..."))
|
|
||||||
.build();
|
|
||||||
ChatRequest req = ChatRequest.builder().messages(sampleMessages).pageContext(pc).build();
|
ChatRequest req = ChatRequest.builder().messages(sampleMessages).pageContext(pc).build();
|
||||||
|
|
||||||
Map<String, Object> payload = builder.build(req);
|
Map<String, Object> payload = builder.build(req);
|
||||||
@@ -182,17 +162,12 @@ class BrainChatPayloadBuilderTest {
|
|||||||
@Test
|
@Test
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
void build_campaignContext_serializesFullNarrativeTree() {
|
void build_campaignContext_serializesFullNarrativeTree() {
|
||||||
BranchHint branch = BranchHint.builder()
|
BranchHint branch = new BranchHint("fuite", "La poursuite", "HP < 50%");
|
||||||
.label("fuite").targetSceneName("La poursuite").condition("HP < 50%").build();
|
SceneSummary scene = new SceneSummary("L'auberge", "Rencontre tendue", 3, List.of(branch));
|
||||||
SceneSummary scene = SceneSummary.builder()
|
ChapterSummary chapter = new ChapterSummary("L'arrivee", "...", 0, List.of(scene));
|
||||||
.name("L'auberge").description("Rencontre tendue")
|
ArcSummary arc = new ArcSummary("Acte I", "Mise en place", 1, List.of(chapter));
|
||||||
.illustrationCount(3).branch(branch).build();
|
CampaignStructuralContext camp = new CampaignStructuralContext(
|
||||||
ChapterSummary chapter = ChapterSummary.builder()
|
"Les Ombres", "dark fantasy", List.of(arc), List.of(), List.of());
|
||||||
.name("L'arrivee").description("...").scene(scene).build();
|
|
||||||
ArcSummary arc = ArcSummary.builder()
|
|
||||||
.name("Acte I").description("Mise en place").illustrationCount(1).chapter(chapter).build();
|
|
||||||
CampaignStructuralContext camp = CampaignStructuralContext.builder()
|
|
||||||
.campaignName("Les Ombres").campaignDescription("dark fantasy").arc(arc).build();
|
|
||||||
ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build();
|
ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build();
|
||||||
|
|
||||||
Map<String, Object> payload = builder.build(req);
|
Map<String, Object> payload = builder.build(req);
|
||||||
@@ -223,9 +198,9 @@ class BrainChatPayloadBuilderTest {
|
|||||||
@Test
|
@Test
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
void build_arcSummary_omitsIllustrationCount_whenZero() {
|
void build_arcSummary_omitsIllustrationCount_whenZero() {
|
||||||
ArcSummary arc = ArcSummary.builder().name("A").description("").illustrationCount(0).build();
|
ArcSummary arc = new ArcSummary("A", "", 0, List.of());
|
||||||
CampaignStructuralContext camp = CampaignStructuralContext.builder()
|
CampaignStructuralContext camp = new CampaignStructuralContext(
|
||||||
.campaignName("X").campaignDescription("").arc(arc).build();
|
"X", "", List.of(arc), List.of(), List.of());
|
||||||
ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build();
|
ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build();
|
||||||
|
|
||||||
Map<String, Object> payload = builder.build(req);
|
Map<String, Object> payload = builder.build(req);
|
||||||
@@ -238,11 +213,11 @@ class BrainChatPayloadBuilderTest {
|
|||||||
@Test
|
@Test
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
void build_sceneSummary_omitsBranches_whenEmpty() {
|
void build_sceneSummary_omitsBranches_whenEmpty() {
|
||||||
SceneSummary scene = SceneSummary.builder().name("S").description("").build();
|
SceneSummary scene = new SceneSummary("S", "", 0, List.of());
|
||||||
ChapterSummary chapter = ChapterSummary.builder().name("Ch").description("").scene(scene).build();
|
ChapterSummary chapter = new ChapterSummary("Ch", "", 0, List.of(scene));
|
||||||
ArcSummary arc = ArcSummary.builder().name("A").description("").chapter(chapter).build();
|
ArcSummary arc = new ArcSummary("A", "", 0, List.of(chapter));
|
||||||
CampaignStructuralContext camp = CampaignStructuralContext.builder()
|
CampaignStructuralContext camp = new CampaignStructuralContext(
|
||||||
.campaignName("X").campaignDescription("").arc(arc).build();
|
"X", "", List.of(arc), List.of(), List.of());
|
||||||
ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build();
|
ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build();
|
||||||
|
|
||||||
Map<String, Object> payload = builder.build(req);
|
Map<String, Object> payload = builder.build(req);
|
||||||
@@ -256,12 +231,12 @@ class BrainChatPayloadBuilderTest {
|
|||||||
@Test
|
@Test
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
void build_branchHint_omitsCondition_whenBlank() {
|
void build_branchHint_omitsCondition_whenBlank() {
|
||||||
BranchHint branch = BranchHint.builder().label("X").targetSceneName("Y").condition(" ").build();
|
BranchHint branch = new BranchHint("X", "Y", " ");
|
||||||
SceneSummary scene = SceneSummary.builder().name("S").description("").branch(branch).build();
|
SceneSummary scene = new SceneSummary("S", "", 0, List.of(branch));
|
||||||
ChapterSummary chapter = ChapterSummary.builder().name("Ch").description("").scene(scene).build();
|
ChapterSummary chapter = new ChapterSummary("Ch", "", 0, List.of(scene));
|
||||||
ArcSummary arc = ArcSummary.builder().name("A").description("").chapter(chapter).build();
|
ArcSummary arc = new ArcSummary("A", "", 0, List.of(chapter));
|
||||||
CampaignStructuralContext camp = CampaignStructuralContext.builder()
|
CampaignStructuralContext camp = new CampaignStructuralContext(
|
||||||
.campaignName("X").campaignDescription("").arc(arc).build();
|
"X", "", List.of(arc), List.of(), List.of());
|
||||||
ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build();
|
ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build();
|
||||||
|
|
||||||
Map<String, Object> payload = builder.build(req);
|
Map<String, Object> payload = builder.build(req);
|
||||||
@@ -278,10 +253,8 @@ class BrainChatPayloadBuilderTest {
|
|||||||
@Test
|
@Test
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
void build_narrativeEntity_includesAllFields() {
|
void build_narrativeEntity_includesAllFields() {
|
||||||
NarrativeEntityContext entity = NarrativeEntityContext.builder()
|
NarrativeEntityContext entity = new NarrativeEntityContext("scene", "L'auberge",
|
||||||
.entityType("scene").title("L'auberge")
|
Map.of("location", "Taverne", "timing", "Soir"));
|
||||||
.fields(Map.of("location", "Taverne", "timing", "Soir"))
|
|
||||||
.build();
|
|
||||||
ChatRequest req = ChatRequest.builder().messages(sampleMessages).narrativeEntity(entity).build();
|
ChatRequest req = ChatRequest.builder().messages(sampleMessages).narrativeEntity(entity).build();
|
||||||
|
|
||||||
Map<String, Object> payload = builder.build(req);
|
Map<String, Object> payload = builder.build(req);
|
||||||
@@ -295,10 +268,9 @@ class BrainChatPayloadBuilderTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void build_campaignScenario_includesBothContextsAndEntity() {
|
void build_campaignScenario_includesBothContextsAndEntity() {
|
||||||
CampaignStructuralContext camp = CampaignStructuralContext.builder()
|
CampaignStructuralContext camp = new CampaignStructuralContext(
|
||||||
.campaignName("X").campaignDescription("").build();
|
"X", "", List.of(), List.of(), List.of());
|
||||||
NarrativeEntityContext entity = NarrativeEntityContext.builder()
|
NarrativeEntityContext entity = new NarrativeEntityContext("arc", "T", Map.of());
|
||||||
.entityType("arc").title("T").fields(Map.of()).build();
|
|
||||||
ChatRequest req = ChatRequest.builder()
|
ChatRequest req = ChatRequest.builder()
|
||||||
.messages(sampleMessages)
|
.messages(sampleMessages)
|
||||||
.campaignContext(camp)
|
.campaignContext(camp)
|
||||||
|
|||||||
@@ -48,27 +48,21 @@ class SceneBranchListJsonConverterTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void roundTrip_preservesAllBranchFields() {
|
void roundTrip_preservesAllBranchFields() {
|
||||||
// Test critique : depend de @Jacksonized sur SceneBranch.
|
// Test critique : Jackson doit reconstruire SceneBranch (record) via
|
||||||
|
// son constructeur canonique sans aucune annotation.
|
||||||
List<SceneBranch> source = List.of(
|
List<SceneBranch> source = List.of(
|
||||||
SceneBranch.builder()
|
new SceneBranch("si les joueurs attaquent", "sc-combat", "initiative > 15"),
|
||||||
.label("si les joueurs attaquent")
|
SceneBranch.of("si les joueurs fuient", "sc-poursuite")
|
||||||
.targetSceneId("sc-combat")
|
|
||||||
.condition("initiative > 15")
|
|
||||||
.build(),
|
|
||||||
SceneBranch.builder()
|
|
||||||
.label("si les joueurs fuient")
|
|
||||||
.targetSceneId("sc-poursuite")
|
|
||||||
.build()
|
|
||||||
);
|
);
|
||||||
|
|
||||||
String json = converter.convertToDatabaseColumn(source);
|
String json = converter.convertToDatabaseColumn(source);
|
||||||
List<SceneBranch> back = converter.convertToEntityAttribute(json);
|
List<SceneBranch> back = converter.convertToEntityAttribute(json);
|
||||||
|
|
||||||
assertEquals(2, back.size());
|
assertEquals(2, back.size());
|
||||||
assertEquals("si les joueurs attaquent", back.get(0).getLabel());
|
assertEquals("si les joueurs attaquent", back.get(0).label());
|
||||||
assertEquals("sc-combat", back.get(0).getTargetSceneId());
|
assertEquals("sc-combat", back.get(0).targetSceneId());
|
||||||
assertEquals("initiative > 15", back.get(0).getCondition());
|
assertEquals("initiative > 15", back.get(0).condition());
|
||||||
assertEquals("sc-poursuite", back.get(1).getTargetSceneId());
|
assertEquals("sc-poursuite", back.get(1).targetSceneId());
|
||||||
assertNull(back.get(1).getCondition(), "condition absente doit rester null apres round-trip");
|
assertNull(back.get(1).condition(), "condition absente doit rester null apres round-trip");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,13 +70,13 @@ class PostgresSceneRepositoryTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void save_scenePreservesBranches_viaJsonbRoundTrip() {
|
void save_scenePreservesBranches_viaJsonbRoundTrip() {
|
||||||
// Le critique : le @Jacksonized de SceneBranch doit permettre la
|
// Le critique : SceneBranch (record) doit etre reconstructible par
|
||||||
// reconstruction via builder apres serialisation Jackson.
|
// Jackson via le constructeur canonique apres serialisation JSON.
|
||||||
Scene scene = Scene.builder()
|
Scene scene = Scene.builder()
|
||||||
.chapterId(chapterId).name("Decision").order(0)
|
.chapterId(chapterId).name("Decision").order(0)
|
||||||
.branches(List.of(
|
.branches(List.of(
|
||||||
SceneBranch.builder().label("fuite").targetSceneId("sc-2").condition("HP bas").build(),
|
new SceneBranch("fuite", "sc-2", "HP bas"),
|
||||||
SceneBranch.builder().label("combat").targetSceneId("sc-3").build()
|
SceneBranch.of("combat", "sc-3")
|
||||||
))
|
))
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
@@ -84,10 +84,10 @@ class PostgresSceneRepositoryTest {
|
|||||||
Scene r = repository.findById(saved.getId()).orElseThrow();
|
Scene r = repository.findById(saved.getId()).orElseThrow();
|
||||||
|
|
||||||
assertEquals(2, r.getBranches().size());
|
assertEquals(2, r.getBranches().size());
|
||||||
assertEquals("fuite", r.getBranches().get(0).getLabel());
|
assertEquals("fuite", r.getBranches().get(0).label());
|
||||||
assertEquals("sc-2", r.getBranches().get(0).getTargetSceneId());
|
assertEquals("sc-2", r.getBranches().get(0).targetSceneId());
|
||||||
assertEquals("HP bas", r.getBranches().get(0).getCondition());
|
assertEquals("HP bas", r.getBranches().get(0).condition());
|
||||||
assertEquals("combat", r.getBranches().get(1).getLabel());
|
assertEquals("combat", r.getBranches().get(1).label());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ class ArcControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
void getByCampaign_pathVariant() throws Exception {
|
void getByCampaign_pathVariant() throws Exception {
|
||||||
arcRepository.save(Arc.builder().campaignId(campaignId).name("A").order(0).build());
|
arcRepository.save(Arc.builder().campaignId(campaignId).name("A").order(0).build());
|
||||||
mockMvc.perform(get("/api/arcs/campaign/{id}", campaignId))
|
mockMvc.perform(get("/api/arcs").param("campaignId", campaignId))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(jsonPath("$").isArray());
|
.andExpect(jsonPath("$").isArray());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ class ChapterControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
void getByArc_pathVariant() throws Exception {
|
void getByArc_pathVariant() throws Exception {
|
||||||
chapterRepository.save(Chapter.builder().arcId(arcId).name("A").order(0).build());
|
chapterRepository.save(Chapter.builder().arcId(arcId).name("A").order(0).build());
|
||||||
mockMvc.perform(get("/api/chapters/arc/{id}", arcId))
|
mockMvc.perform(get("/api/chapters").param("arcId", arcId))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(jsonPath("$").isArray());
|
.andExpect(jsonPath("$").isArray());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ class SceneControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
void getByChapter_pathVariant() throws Exception {
|
void getByChapter_pathVariant() throws Exception {
|
||||||
sceneRepository.save(Scene.builder().chapterId(chapterId).name("A").order(0).build());
|
sceneRepository.save(Scene.builder().chapterId(chapterId).name("A").order(0).build());
|
||||||
mockMvc.perform(get("/api/scenes/chapter/{id}", chapterId))
|
mockMvc.perform(get("/api/scenes").param("chapterId", chapterId))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(jsonPath("$").isArray());
|
.andExpect(jsonPath("$").isArray());
|
||||||
}
|
}
|
||||||
|
|||||||
27
demo/.env.example
Normal file
27
demo/.env.example
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# Copie en .env sur le serveur (jamais commite).
|
||||||
|
|
||||||
|
# Registre et tag des images core / brain a spawner par session.
|
||||||
|
REGISTRY=git.igmlcreation.fr
|
||||||
|
TAG=latest
|
||||||
|
|
||||||
|
# Secret partage entre core et brain (genere aleatoirement au build de chaque
|
||||||
|
# session, mais un defaut est utile pour les checks de sante au boot).
|
||||||
|
BRAIN_INTERNAL_SECRET_DEFAULT=change-me-on-server
|
||||||
|
|
||||||
|
# Capacite
|
||||||
|
MAX_SESSIONS=10
|
||||||
|
SESSION_TTL_MINUTES=20
|
||||||
|
|
||||||
|
# Rate limiting : 1 creation de session par IP par fenetre (secondes).
|
||||||
|
RATE_LIMIT_WINDOW_SECONDS=60
|
||||||
|
|
||||||
|
# Limites par conteneur de session (Docker API)
|
||||||
|
CORE_MEMORY_MB=700
|
||||||
|
BRAIN_MEMORY_MB=300
|
||||||
|
POSTGRES_MEMORY_MB=200
|
||||||
|
|
||||||
|
# Nom du reseau Docker externe Traefik (doit exister avant docker compose up)
|
||||||
|
TRAEFIK_NETWORK=traefik
|
||||||
|
|
||||||
|
# Domaine expose par Traefik
|
||||||
|
DEMO_HOST=loremind-demo.igmlcreation.fr
|
||||||
2
demo/.gitignore
vendored
Normal file
2
demo/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
.env
|
||||||
|
*.log
|
||||||
46
demo/README.md
Normal file
46
demo/README.md
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
# Demo publique LoreMind
|
||||||
|
|
||||||
|
Deploiement d'une instance de demo ephemere pour `loremind-demo.igmlcreation.fr`.
|
||||||
|
|
||||||
|
## Principe
|
||||||
|
|
||||||
|
Chaque visiteur recoit un environnement isole spawne a la volee, detruit apres
|
||||||
|
un court delai d'inactivite. Les donnees ne sont jamais persistees.
|
||||||
|
|
||||||
|
Le mode demo (variable d'env `DEMO_MODE=true` sur le core) masque les ecrans
|
||||||
|
de configuration qui n'ont pas de sens en vitrine.
|
||||||
|
|
||||||
|
## Deploiement
|
||||||
|
|
||||||
|
Prerequis :
|
||||||
|
- Reseau Traefik existant cote host
|
||||||
|
- Images `core` et `brain` pushees au registre
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
# Ajuster .env
|
||||||
|
docker compose -f docker-compose.infra.yml up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Premier build : 5-10 min. Suivants : incrementaux.
|
||||||
|
|
||||||
|
## Mise a jour
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.infra.yml pull
|
||||||
|
docker compose -f docker-compose.infra.yml up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Les sessions en cours sont tuees au redemarrage.
|
||||||
|
|
||||||
|
## Observations
|
||||||
|
|
||||||
|
- `docker logs loremind-demo-orchestrator -f`
|
||||||
|
- `docker ps --filter "name=demo-"`
|
||||||
|
|
||||||
|
## Desactiver
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.infra.yml down
|
||||||
|
docker ps -q --filter "name=demo-" | xargs -r docker rm -f
|
||||||
|
```
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user