Compare commits
51 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0f2d1b1efe | |||
| 5ff05242a8 | |||
| b06c77a1eb | |||
| 03bc669efe | |||
| c3873ddd84 | |||
| d7ceeac1b0 | |||
| cdbd3cd9b4 | |||
| a708c74425 | |||
| 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 | |||
| e510f64336 | |||
| f189f67aaf | |||
| 8efa148739 | |||
| 8f4dd3e9d6 | |||
| bf38b6695f | |||
| 49a82d05f7 |
12
.env.example
12
.env.example
@@ -38,3 +38,15 @@ LLM_MODEL=gemma4:26b
|
||||
# 1min.ai (si LLM_PROVIDER=onemin)
|
||||
ONEMIN_API_KEY=
|
||||
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*'
|
||||
|
||||
env:
|
||||
REGISTRY: git.igmlcreation.fr
|
||||
REGISTRY_USER: ietm64
|
||||
GITEA_REGISTRY: git.igmlcreation.fr
|
||||
GITEA_REGISTRY_USER: ietm64
|
||||
GHCR_REGISTRY: ghcr.io
|
||||
GHCR_NAMESPACE: igmlcreation
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -26,19 +28,39 @@ jobs:
|
||||
- name: Login to Gitea Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ env.REGISTRY_USER }}
|
||||
registry: ${{ env.GITEA_REGISTRY }}
|
||||
username: ${{ env.GITEA_REGISTRY_USER }}
|
||||
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
|
||||
id: meta
|
||||
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 }}
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ./${{ matrix.component }}
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/${{ env.REGISTRY_USER }}/${{ matrix.component }}:latest
|
||||
${{ env.REGISTRY }}/${{ env.REGISTRY_USER }}/${{ matrix.component }}:${{ steps.meta.outputs.version }}
|
||||
${{ env.GITEA_REGISTRY }}/${{ env.GITEA_REGISTRY_USER }}/${{ matrix.component }}:latest
|
||||
${{ 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 }}
|
||||
|
||||
12
.gitignore
vendored
12
.gitignore
vendored
@@ -53,6 +53,12 @@ yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
coverage/
|
||||
|
||||
# Playwright (E2E)
|
||||
web/test-results/
|
||||
web/playwright-report/
|
||||
web/blob-report/
|
||||
web/playwright/.cache/
|
||||
|
||||
# ============================================================================
|
||||
# IDE / Editeurs
|
||||
# ============================================================================
|
||||
@@ -85,8 +91,14 @@ Thumbs.db
|
||||
# Documentation hors-code (conservee hors du repo)
|
||||
# ============================================================================
|
||||
docs/
|
||||
loremind-docs/
|
||||
|
||||
# ============================================================================
|
||||
# Docker Compose override (dev uniquement, non-distribue aux end users)
|
||||
# ============================================================================
|
||||
docker-compose.override.yml
|
||||
|
||||
# ============================================================================
|
||||
# Relais OAuth Patreon (repo Gitea separe, clone localement pour facilite)
|
||||
# ============================================================================
|
||||
relay/
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
- Gestion centralisée du Lore : Lieux, Factions, PNJ, et tous les éléments de votre univers
|
||||
- Suivi de campagnes : Sessions, actions des joueurs, chronologie
|
||||
- Moteur IA intégré : Génération automatique de contenu (PNJ, Villes, Quêtes) à partir de templates
|
||||
- Export vers FoundryVTT : Transfert structuré des données vers votre VTT préféré (en développement)
|
||||
|
||||
## Captures d'écran
|
||||
## 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
|
||||
|
||||
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
|
||||
```
|
||||
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.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -20,6 +20,9 @@ from app.domain.models import (
|
||||
CampaignStructuralContext,
|
||||
ChatMessage,
|
||||
ChapterSummary,
|
||||
CharacterSummary,
|
||||
NpcSummary,
|
||||
GameSystemContext,
|
||||
LoreStructuralContext,
|
||||
NarrativeEntityContext,
|
||||
PageContext,
|
||||
@@ -63,16 +66,17 @@ class ChatUseCase:
|
||||
page_context: PageContext | None = None,
|
||||
campaign_context: CampaignStructuralContext | None = None,
|
||||
narrative_entity: NarrativeEntityContext | None = None,
|
||||
game_system_context: GameSystemContext | None = None,
|
||||
) -> AsyncIterator[str]:
|
||||
"""Streame les tokens de la réponse assistant pour le dernier message user.
|
||||
|
||||
Les 4 contextes sont tous optionnels, mais au moins l'un des deux
|
||||
Les contextes sont tous optionnels, mais au moins l'un des deux
|
||||
"niveaux haut" (lore_context ou campaign_context) doit être fourni
|
||||
pour que le prompt ait du sens. Le controller (main.py) applique
|
||||
cette règle à la frontière HTTP.
|
||||
"""
|
||||
system_prompt = self._build_system_prompt(
|
||||
lore_context, page_context, campaign_context, narrative_entity
|
||||
lore_context, page_context, campaign_context, narrative_entity, game_system_context
|
||||
)
|
||||
async for token in self._llm.stream_chat(
|
||||
messages,
|
||||
@@ -81,6 +85,21 @@ class ChatUseCase:
|
||||
):
|
||||
yield token
|
||||
|
||||
def build_system_prompt(
|
||||
self,
|
||||
lore_context: LoreStructuralContext | None = None,
|
||||
page_context: PageContext | None = None,
|
||||
campaign_context: CampaignStructuralContext | None = None,
|
||||
narrative_entity: NarrativeEntityContext | None = None,
|
||||
game_system_context: GameSystemContext | None = None,
|
||||
) -> str:
|
||||
"""Version publique — utilisée par le controller HTTP pour compter
|
||||
les tokens du system prompt avant de streamer (jauge de contexte).
|
||||
"""
|
||||
return self._build_system_prompt(
|
||||
lore_context, page_context, campaign_context, narrative_entity, game_system_context
|
||||
)
|
||||
|
||||
# --- Construction du system prompt --------------------------------------
|
||||
|
||||
def _build_system_prompt(
|
||||
@@ -89,12 +108,15 @@ class ChatUseCase:
|
||||
page: PageContext | None,
|
||||
campaign: CampaignStructuralContext | None,
|
||||
narrative: NarrativeEntityContext | None,
|
||||
game_system: GameSystemContext | None = None,
|
||||
) -> str:
|
||||
sections = [_BASE_SYSTEM]
|
||||
if lore is not None:
|
||||
sections.append(self._format_lore(lore))
|
||||
if campaign is not None:
|
||||
sections.append(self._format_campaign(campaign, lore_present=lore is not None))
|
||||
if game_system is not None:
|
||||
sections.append(self._format_game_system(game_system))
|
||||
if page is not None:
|
||||
sections.append(self._format_page(page))
|
||||
if narrative is not None:
|
||||
@@ -176,14 +198,69 @@ class ChatUseCase:
|
||||
if lore_present
|
||||
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)
|
||||
npcs_block = ChatUseCase._format_npcs(ctx.npcs)
|
||||
return (
|
||||
"--- CAMPAGNE COURANTE ---\n"
|
||||
f"Nom : {ctx.campaign_name}{desc}{lore_note}\n\n"
|
||||
f"Nom : {ctx.campaign_name}{desc}{lore_note}\n"
|
||||
f"{characters_block}"
|
||||
f"{npcs_block}\n"
|
||||
"Structure narrative (les flèches → indiquent des transitions de scène "
|
||||
"déclenchées par un choix des joueurs) :\n"
|
||||
f"{arcs_block}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _format_characters(characters: list[CharacterSummary]) -> str:
|
||||
"""Bloc PJ — liste nom + snippet. Rappel anti-hallucination IA.
|
||||
|
||||
Si la campagne n'a aucun PJ, on le signale explicitement : l'IA ne
|
||||
doit pas inventer "les héros" ou leurs noms dans ses suggestions.
|
||||
"""
|
||||
if not characters:
|
||||
return (
|
||||
"\nPersonnages joueurs : aucune fiche pour l'instant. Ne suppose "
|
||||
"ni noms ni classes pour les PJ tant que le MJ ne les a pas créés.\n"
|
||||
)
|
||||
lines = ["\nPersonnages joueurs (PJ) :"]
|
||||
for c in characters:
|
||||
if c.snippet:
|
||||
lines.append(f"- **{c.name}** — {c.snippet}")
|
||||
else:
|
||||
lines.append(f"- **{c.name}** (fiche vide)")
|
||||
lines.append(
|
||||
"Pour une fiche complète (stats, backstory), n'invente rien : "
|
||||
"demande au MJ d'ouvrir l'éditeur du PJ pour te donner les détails."
|
||||
)
|
||||
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
|
||||
def _format_arcs(arcs: list[ArcSummary]) -> str:
|
||||
if not arcs:
|
||||
@@ -234,12 +311,47 @@ class ChatUseCase:
|
||||
noun = "illustration" if count == 1 else "illustrations"
|
||||
return f" [{count} {noun}]"
|
||||
|
||||
# --- Bloc Système de JDR ------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _format_game_system(gs: GameSystemContext) -> str:
|
||||
"""Bloc des règles du système de JDR de la campagne.
|
||||
|
||||
Les sections ont été filtrées côté Core selon l'intent (combat,
|
||||
classes, lore...). Si aucune section n'a matché, on affiche juste
|
||||
le nom du système comme rappel de cadre.
|
||||
"""
|
||||
desc = f"\nDescription : {gs.system_description}" if gs.system_description else ""
|
||||
if not gs.sections:
|
||||
return (
|
||||
"--- SYSTÈME DE JDR ---\n"
|
||||
f"Nom : {gs.system_name}{desc}\n"
|
||||
"(Aucune section de règles pertinente pour ce type de génération — "
|
||||
"reste cohérent avec l'univers et les conventions du système.)"
|
||||
)
|
||||
sections_block = "\n\n".join(
|
||||
f"### {title}\n{content}" for title, content in gs.sections.items()
|
||||
)
|
||||
return (
|
||||
"--- SYSTÈME DE JDR ---\n"
|
||||
f"Nom : {gs.system_name}{desc}\n\n"
|
||||
"Respecte scrupuleusement les règles et conventions ci-dessous quand "
|
||||
"tu proposes des stats, classes, rencontres, mécaniques ou éléments "
|
||||
"d'ambiance. Les noms propres (classes, sorts, monstres) doivent "
|
||||
"venir de ces règles — n'en invente pas d'autres.\n\n"
|
||||
f"{sections_block}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _format_narrative_entity(ne: NarrativeEntityContext) -> str:
|
||||
"""Bloc équivalent à _format_page mais pour Arc/Chapter/Scene."""
|
||||
type_label = {"arc": "ARC", "chapter": "CHAPITRE", "scene": "SCÈNE"}.get(
|
||||
ne.entity_type.lower(), ne.entity_type.upper()
|
||||
)
|
||||
type_label = {
|
||||
"arc": "ARC",
|
||||
"chapter": "CHAPITRE",
|
||||
"scene": "SCÈNE",
|
||||
"character": "FICHE DE PERSONNAGE (PJ)",
|
||||
"npc": "FICHE DE PNJ",
|
||||
}.get(ne.entity_type.lower(), ne.entity_type.upper())
|
||||
if ne.fields:
|
||||
fields_block = "\n".join(
|
||||
f'- "{key}" : {value or "(vide)"}'
|
||||
|
||||
@@ -169,6 +169,34 @@ class CampaignStructuralContext:
|
||||
campaign_name: str
|
||||
campaign_description: str | None
|
||||
arcs: list[ArcSummary]
|
||||
characters: list["CharacterSummary"] = field(default_factory=list)
|
||||
npcs: list["NpcSummary"] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CharacterSummary:
|
||||
"""Résumé d'un PJ : nom + snippet court extrait du markdown de la fiche.
|
||||
|
||||
La fiche complète n'est JAMAIS dans ce résumé — elle n'arrive que si le PJ
|
||||
est l'entité focus (via NarrativeEntityContext entity_type="character").
|
||||
Ça plafonne le coût token à ~40 tokens/PJ quel que soit le détail des fiches.
|
||||
"""
|
||||
|
||||
name: 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)
|
||||
@@ -184,3 +212,20 @@ class NarrativeEntityContext:
|
||||
entity_type: str
|
||||
title: str
|
||||
fields: dict[str, str]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GameSystemContext:
|
||||
"""Règles d'un système de JDR (D&D, Nimble, homebrew...) injectées
|
||||
dans le system prompt pour que l'IA respecte les mécaniques du jeu.
|
||||
|
||||
Les sections ont été présélectionnées côté Core selon l'intent
|
||||
(SCENE → combat/PNJ, CHAPTER → combat/classes, ARC → lore/factions,
|
||||
GENERIC → toutes). Indexées par titre H2 original.
|
||||
|
||||
Campagne uniquement au MVP : jamais présent sur un chat Lore.
|
||||
"""
|
||||
|
||||
system_name: str
|
||||
system_description: str | None
|
||||
sections: dict[str, str]
|
||||
|
||||
@@ -61,7 +61,16 @@ class OllamaLLMProvider:
|
||||
async with httpx.AsyncClient(timeout=self._timeout) as client:
|
||||
try:
|
||||
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:
|
||||
raise LLMProviderError(
|
||||
f"Erreur lors de l'appel à Ollama : {exc}"
|
||||
@@ -105,7 +114,20 @@ class OllamaLLMProvider:
|
||||
async with httpx.AsyncClient(timeout=self._timeout) as client:
|
||||
try:
|
||||
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():
|
||||
if not line.strip():
|
||||
continue
|
||||
|
||||
@@ -9,6 +9,7 @@ from typing import Annotated, AsyncIterator, Literal
|
||||
|
||||
import hmac
|
||||
import httpx
|
||||
import tiktoken
|
||||
from fastapi import Depends, FastAPI, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
from pydantic import BaseModel, Field
|
||||
@@ -21,7 +22,10 @@ from app.domain.models import (
|
||||
ArcSummary,
|
||||
CampaignStructuralContext,
|
||||
ChapterSummary,
|
||||
CharacterSummary,
|
||||
NpcSummary,
|
||||
ChatMessage,
|
||||
GameSystemContext,
|
||||
LoreStructuralContext,
|
||||
NarrativeEntityContext,
|
||||
PageContext,
|
||||
@@ -37,10 +41,27 @@ from app.infrastructure.onemin_adapter import OneMinAiLLMProvider
|
||||
app = FastAPI(
|
||||
title="LoreMind Brain",
|
||||
description="Backend IA pour la génération de contenu narratif.",
|
||||
version="0.3.0",
|
||||
version="0.6.6",
|
||||
)
|
||||
|
||||
|
||||
# Encodeur tiktoken partagé — chargé une fois pour éviter le coût de lookup
|
||||
# à chaque requête. On utilise cl100k_base (GPT-3.5/4) comme tokenizer
|
||||
# universel approximatif : ±10% d'écart avec Llama/Gemma mais largement
|
||||
# suffisant pour une jauge visuelle à l'utilisateur.
|
||||
_TOKEN_ENCODER: tiktoken.Encoding | None = None
|
||||
|
||||
|
||||
def _count_tokens(text: str | None) -> int:
|
||||
"""Compte les tokens d'un texte via tiktoken. Null/empty → 0."""
|
||||
if not text:
|
||||
return 0
|
||||
global _TOKEN_ENCODER
|
||||
if _TOKEN_ENCODER is None:
|
||||
_TOKEN_ENCODER = tiktoken.get_encoding("cl100k_base")
|
||||
return len(_TOKEN_ENCODER.encode(text))
|
||||
|
||||
|
||||
# Chemins exemptes d'auth inter-service : healthcheck docker + introspection
|
||||
# FastAPI (docs uniquement utiles en dev ; en prod docker-compose, le Brain
|
||||
# n'est pas expose en dehors du reseau interne donc pas un risque).
|
||||
@@ -178,22 +199,50 @@ class ArcSummaryDTO(BaseModel):
|
||||
illustration_count: int = 0
|
||||
|
||||
|
||||
class CharacterSummaryDTO(BaseModel):
|
||||
"""Résumé d'un PJ : nom + snippet. Pas de fiche complète au niveau résumé."""
|
||||
|
||||
name: str
|
||||
snippet: str = ""
|
||||
|
||||
|
||||
class NpcSummaryDTO(BaseModel):
|
||||
"""Résumé d'un PNJ : symétrique à CharacterSummaryDTO."""
|
||||
|
||||
name: str
|
||||
snippet: str = ""
|
||||
|
||||
|
||||
class CampaignContextDTO(BaseModel):
|
||||
"""Carte narrative enrichie : arcs → chapitres → scènes avec synopsis."""
|
||||
|
||||
campaign_name: str
|
||||
campaign_description: str | None = None
|
||||
arcs: list[ArcSummaryDTO] = Field(default_factory=list)
|
||||
characters: list[CharacterSummaryDTO] = Field(default_factory=list)
|
||||
npcs: list[NpcSummaryDTO] = Field(default_factory=list)
|
||||
|
||||
|
||||
class NarrativeEntityDTO(BaseModel):
|
||||
"""Entité narrative (arc/chapter/scene) 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)$")
|
||||
entity_type: str = Field(pattern="^(arc|chapter|scene|character|npc)$")
|
||||
title: str
|
||||
fields: dict[str, str] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class GameSystemContextDTO(BaseModel):
|
||||
"""Règles de JDR présélectionnées par le Core (filtrées par intent).
|
||||
|
||||
Les sections sont un dict titre_H2 → contenu_markdown. Peuvent être
|
||||
vides si aucune section ne matchait l'intent de génération courant.
|
||||
"""
|
||||
|
||||
system_name: str
|
||||
system_description: str | None = None
|
||||
sections: dict[str, str] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class ChatStreamRequestDTO(BaseModel):
|
||||
"""Requête de chat streamé : historique + contextes structurels.
|
||||
|
||||
@@ -208,6 +257,7 @@ class ChatStreamRequestDTO(BaseModel):
|
||||
page_context: PageContextDTO | None = None
|
||||
campaign_context: CampaignContextDTO | None = None
|
||||
narrative_entity: NarrativeEntityDTO | None = None
|
||||
game_system_context: GameSystemContextDTO | None = None
|
||||
|
||||
def has_scope(self) -> bool:
|
||||
"""Vrai si au moins un contexte racine (Lore ou Campagne) est fourni."""
|
||||
@@ -334,8 +384,35 @@ async def chat_stream(
|
||||
page_context = _to_page_context(body.page_context)
|
||||
campaign_context = _to_campaign_context(body.campaign_context)
|
||||
narrative_entity = _to_narrative_entity(body.narrative_entity)
|
||||
game_system_context = _to_game_system_context(body.game_system_context)
|
||||
|
||||
# --- Comptage tokens pour la jauge de contexte frontend ---
|
||||
# On construit le system prompt une fois ici pour le compter — le use case
|
||||
# le reconstruira à l'identique en interne (coût négligeable : concat de str).
|
||||
# Cette duplication évite de complexifier le contrat stream() avec un
|
||||
# paramètre optionnel system_prompt précalculé.
|
||||
system_prompt_preview = use_case.build_system_prompt(
|
||||
lore_context=lore_context,
|
||||
page_context=page_context,
|
||||
campaign_context=campaign_context,
|
||||
narrative_entity=narrative_entity,
|
||||
game_system_context=game_system_context,
|
||||
)
|
||||
# Dernier message = "current" (souvent user), le reste = historique accumulé.
|
||||
current_msg = messages[-1] if messages else None
|
||||
history_msgs = messages[:-1] if messages else []
|
||||
settings = get_settings()
|
||||
usage_payload = {
|
||||
"system": _count_tokens(system_prompt_preview),
|
||||
"history": sum(_count_tokens(m.content) for m in history_msgs),
|
||||
"current": _count_tokens(current_msg.content) if current_msg else 0,
|
||||
"max": settings.llm_num_ctx,
|
||||
}
|
||||
|
||||
async def event_stream() -> AsyncIterator[str]:
|
||||
# Event 'usage' émis en tout premier : le frontend peut afficher la
|
||||
# jauge avant même le premier token de réponse.
|
||||
yield f"event: usage\ndata: {json.dumps(usage_payload, ensure_ascii=False)}\n\n"
|
||||
try:
|
||||
async for token in use_case.stream(
|
||||
messages,
|
||||
@@ -343,6 +420,7 @@ async def chat_stream(
|
||||
page_context=page_context,
|
||||
campaign_context=campaign_context,
|
||||
narrative_entity=narrative_entity,
|
||||
game_system_context=game_system_context,
|
||||
):
|
||||
# json.dumps avec ensure_ascii=False pour préserver les accents
|
||||
yield f"data: {json.dumps({'token': token}, ensure_ascii=False)}\n\n"
|
||||
@@ -353,6 +431,60 @@ async def chat_stream(
|
||||
return StreamingResponse(event_stream(), media_type="text/event-stream")
|
||||
|
||||
|
||||
# --- Auto-titre d'une conversation persistee --------------------------------
|
||||
|
||||
|
||||
class SummarizeTitleMessageDTO(BaseModel):
|
||||
role: Literal["user", "assistant", "system"]
|
||||
content: str
|
||||
|
||||
|
||||
class SummarizeTitleRequestDTO(BaseModel):
|
||||
"""Premiers messages d'une conversation pour auto-generer un titre court."""
|
||||
|
||||
messages: list[SummarizeTitleMessageDTO] = Field(default_factory=list)
|
||||
|
||||
|
||||
class SummarizeTitleResponseDTO(BaseModel):
|
||||
title: str
|
||||
|
||||
|
||||
_TITLE_SYSTEM_PROMPT = (
|
||||
"Tu generes un titre court (4 a 7 mots max) qui resume le sujet de la "
|
||||
"conversation ci-dessous. Reponds UNIQUEMENT par le titre, sans guillemets, "
|
||||
"sans ponctuation finale, sans prefixe type 'Titre :'. Le titre doit etre "
|
||||
"en francais et capturer le sujet metier (pas 'Conversation IA')."
|
||||
)
|
||||
|
||||
|
||||
@app.post("/summarize/conversation-title", response_model=SummarizeTitleResponseDTO)
|
||||
async def summarize_conversation_title(
|
||||
body: SummarizeTitleRequestDTO,
|
||||
llm: Annotated[LLMProvider, Depends(get_llm_provider)],
|
||||
) -> SummarizeTitleResponseDTO:
|
||||
"""Genere un titre court a partir des premiers echanges de la conversation.
|
||||
|
||||
Appele par le core apres le 1er couple user/assistant, pour remplacer le
|
||||
titre provisoire "Nouvelle conversation" par quelque chose de parlant.
|
||||
"""
|
||||
if not body.messages:
|
||||
raise HTTPException(status_code=422, detail="Au moins un message requis")
|
||||
|
||||
transcript = "\n".join(f"{m.role.upper()}: {m.content}" for m in body.messages[:6])
|
||||
prompt = f"{_TITLE_SYSTEM_PROMPT}\n\nConversation :\n{transcript}\n\nTitre :"
|
||||
try:
|
||||
raw = await llm.generate(prompt)
|
||||
except LLMProviderError as exc:
|
||||
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||
|
||||
title = raw.strip().splitlines()[0].strip().strip('"').strip("'").rstrip(".")
|
||||
if len(title) > 80:
|
||||
title = title[:80].rstrip()
|
||||
if not title:
|
||||
title = "Nouvelle conversation"
|
||||
return SummarizeTitleResponseDTO(title=title)
|
||||
|
||||
|
||||
# --- Mapping DTO → domaine (frontière HTTP) ---------------------------------
|
||||
|
||||
|
||||
@@ -426,10 +558,20 @@ def _to_campaign_context(dto: CampaignContextDTO | None) -> CampaignStructuralCo
|
||||
)
|
||||
for arc in dto.arcs
|
||||
]
|
||||
characters = [
|
||||
CharacterSummary(name=c.name, snippet=c.snippet)
|
||||
for c in dto.characters
|
||||
]
|
||||
npcs = [
|
||||
NpcSummary(name=n.name, snippet=n.snippet)
|
||||
for n in dto.npcs
|
||||
]
|
||||
return CampaignStructuralContext(
|
||||
campaign_name=dto.campaign_name,
|
||||
campaign_description=dto.campaign_description,
|
||||
arcs=arcs,
|
||||
characters=characters,
|
||||
npcs=npcs,
|
||||
)
|
||||
|
||||
|
||||
@@ -449,6 +591,9 @@ class SettingsDTO(BaseModel):
|
||||
onemin_model: str
|
||||
# True si une cle 1min.ai est deja configuree — pas de leak de la cle elle-meme.
|
||||
onemin_api_key_set: bool
|
||||
# Fenetre de contexte effective passee au modele (num_ctx Ollama) — sert
|
||||
# aussi de plafond a la jauge de contexte UI.
|
||||
llm_num_ctx: int
|
||||
|
||||
|
||||
class SettingsUpdateDTO(BaseModel):
|
||||
@@ -460,6 +605,7 @@ class SettingsUpdateDTO(BaseModel):
|
||||
onemin_model: str | None = None
|
||||
# Chaine vide => on efface la cle. None => pas de changement.
|
||||
onemin_api_key: str | None = None
|
||||
llm_num_ctx: int | None = None
|
||||
|
||||
|
||||
def _to_settings_dto(s: Settings) -> SettingsDTO:
|
||||
@@ -469,6 +615,7 @@ def _to_settings_dto(s: Settings) -> SettingsDTO:
|
||||
llm_model=s.llm_model,
|
||||
onemin_model=s.onemin_model,
|
||||
onemin_api_key_set=bool(s.onemin_api_key),
|
||||
llm_num_ctx=s.llm_num_ctx,
|
||||
)
|
||||
|
||||
|
||||
@@ -512,6 +659,120 @@ async def list_ollama_models(
|
||||
return {"models": sorted(models)}
|
||||
|
||||
|
||||
class OllamaModelInfoDTO(BaseModel):
|
||||
"""Info utile extraite de /api/show pour un modele Ollama donne.
|
||||
|
||||
`context_length` = fenetre de contexte max supportee par le modele
|
||||
(extraite des metadonnees GGUF). 0 si inconnue. Le frontend s'en sert
|
||||
pour borner le slider de num_ctx dans les Parametres.
|
||||
"""
|
||||
|
||||
context_length: int = 0
|
||||
|
||||
|
||||
@app.post("/models/ollama/info", response_model=OllamaModelInfoDTO)
|
||||
async def get_ollama_model_info(
|
||||
body: dict[str, str],
|
||||
settings: Annotated[Settings, Depends(get_settings)],
|
||||
) -> OllamaModelInfoDTO:
|
||||
"""Retourne les metadonnees d'un modele Ollama via /api/show.
|
||||
|
||||
On passe par POST (et pas GET /models/ollama/{name}) parce que les noms
|
||||
Ollama contiennent souvent un `:` (ex: `gemma3:e2b`) qui se segmente
|
||||
mal dans une URL — le body JSON evite le probleme d'escaping.
|
||||
|
||||
Le champ qui nous interesse est `model_info["<arch>.context_length"]`
|
||||
(ex: `gemma3.context_length: 131072`). L'arch varie selon le modele, on
|
||||
scanne donc tous les champs finissant par `.context_length`.
|
||||
"""
|
||||
name = (body.get("name") or "").strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="name requis")
|
||||
url = f"{settings.ollama_base_url}/api/show"
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5) as client:
|
||||
response = await client.post(url, json={"model": name})
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
except httpx.HTTPError:
|
||||
return OllamaModelInfoDTO(context_length=0)
|
||||
model_info = data.get("model_info") or {}
|
||||
for key, value in model_info.items():
|
||||
if key.endswith(".context_length") and isinstance(value, int):
|
||||
return OllamaModelInfoDTO(context_length=value)
|
||||
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")
|
||||
def list_onemin_models() -> dict[str, list[dict[str, object]]]:
|
||||
"""Catalogue statique des modeles 1min.ai, groupes par fournisseur.
|
||||
@@ -596,3 +857,13 @@ def _to_narrative_entity(dto: NarrativeEntityDTO | None) -> NarrativeEntityConte
|
||||
title=dto.title,
|
||||
fields=dict(dto.fields),
|
||||
)
|
||||
|
||||
|
||||
def _to_game_system_context(dto: GameSystemContextDTO | None) -> GameSystemContext | None:
|
||||
if dto is None:
|
||||
return None
|
||||
return GameSystemContext(
|
||||
system_name=dto.system_name,
|
||||
system_description=dto.system_description,
|
||||
sections=dict(dto.sections),
|
||||
)
|
||||
|
||||
@@ -4,3 +4,9 @@ httpx==0.27.*
|
||||
pydantic-settings==2.6.*
|
||||
|
||||
pydantic
|
||||
|
||||
# Comptage de tokens pour la jauge de contexte (UI chat drawer).
|
||||
# L'encodage cl100k_base (GPT-4/3.5) donne une approximation correcte pour
|
||||
# la plupart des modeles Llama/Gemma/Mistral (~5-10% d'ecart) — suffisant
|
||||
# pour une jauge visuelle.
|
||||
tiktoken==0.8.*
|
||||
|
||||
11
core/lombok.config
Normal file
11
core/lombok.config
Normal file
@@ -0,0 +1,11 @@
|
||||
## LoreMind Core - Configuration Lombok
|
||||
#
|
||||
# addLombokGeneratedAnnotation : ajoute @lombok.Generated sur toutes les
|
||||
# methodes generees par Lombok (equals, hashCode, toString, builders,
|
||||
# getters/setters, etc.). JaCoCo 0.8.2+ reconnait cette annotation et
|
||||
# exclut automatiquement ces methodes du rapport de couverture.
|
||||
#
|
||||
# Objectif : mesurer la couverture UNIQUEMENT sur le code que nous ecrivons,
|
||||
# pas sur le bytecode auto-genere (qui fausse les metriques : branches et
|
||||
# instructions gonflees par les equals/hashCode).
|
||||
lombok.addLombokGeneratedAnnotation = true
|
||||
15
core/pom.xml
15
core/pom.xml
@@ -14,7 +14,7 @@
|
||||
|
||||
<groupId>com.loremind</groupId>
|
||||
<artifactId>loremind-core</artifactId>
|
||||
<version>0.3.0</version>
|
||||
<version>0.8.0</version>
|
||||
<name>LoreMind Core</name>
|
||||
<description>Backend Core - Architecture Hexagonale</description>
|
||||
|
||||
@@ -83,6 +83,19 @@
|
||||
<artifactId>minio</artifactId>
|
||||
<version>8.5.11</version>
|
||||
</dependency>
|
||||
|
||||
<!-- Nimbus JOSE+JWT — verification des JWT Ed25519 (EdDSA) emis par le relais
|
||||
Patreon. Supporte nativement les cles Ed25519 via BouncyCastle. -->
|
||||
<dependency>
|
||||
<groupId>com.nimbusds</groupId>
|
||||
<artifactId>nimbus-jose-jwt</artifactId>
|
||||
<version>9.40</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.bouncycastle</groupId>
|
||||
<artifactId>bcprov-jdk18on</artifactId>
|
||||
<version>1.78.1</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
|
||||
@@ -2,12 +2,14 @@ package com.loremind;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
|
||||
/**
|
||||
* Classe principale de l'application LoreMind.
|
||||
* Point d'entrée Spring Boot qui démarre l'application.
|
||||
*/
|
||||
@SpringBootApplication
|
||||
@EnableScheduling
|
||||
public class LoreMindApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
package com.loremind.application.campaigncontext;
|
||||
|
||||
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.ChapterRepository;
|
||||
import com.loremind.domain.campaigncontext.ports.SceneRepository;
|
||||
import org.springframework.beans.BeanUtils;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
@@ -17,17 +21,31 @@ import java.util.Optional;
|
||||
public class ArcService {
|
||||
|
||||
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.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) {
|
||||
return createArc(name, description, campaignId, order, null);
|
||||
}
|
||||
|
||||
public Arc createArc(String name, String description, String campaignId, int order, String icon) {
|
||||
Arc arc = Arc.builder()
|
||||
.name(name)
|
||||
.description(description)
|
||||
.campaignId(campaignId)
|
||||
.order(order)
|
||||
.icon(icon)
|
||||
.build();
|
||||
return arcRepository.save(arc);
|
||||
}
|
||||
@@ -59,7 +77,31 @@ public class ArcService {
|
||||
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) {
|
||||
for (Chapter chapter : chapterRepository.findByArcId(id)) {
|
||||
for (var scene : sceneRepository.findByChapterId(chapter.getId())) {
|
||||
sceneRepository.deleteById(scene.getId());
|
||||
}
|
||||
chapterRepository.deleteById(chapter.getId());
|
||||
}
|
||||
arcRepository.deleteById(id);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
package com.loremind.application.campaigncontext;
|
||||
|
||||
import com.loremind.domain.campaigncontext.Arc;
|
||||
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.ChapterRepository;
|
||||
import com.loremind.domain.campaigncontext.ports.CharacterRepository;
|
||||
import com.loremind.domain.campaigncontext.ports.SceneRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
@@ -16,9 +23,22 @@ import java.util.Optional;
|
||||
public class CampaignService {
|
||||
|
||||
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.arcRepository = arcRepository;
|
||||
this.chapterRepository = chapterRepository;
|
||||
this.sceneRepository = sceneRepository;
|
||||
this.characterRepository = characterRepository;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -28,13 +48,20 @@ public class CampaignService {
|
||||
*
|
||||
* <p>{@code loreId} est nullable : une campagne peut exister sans univers associé.</p>
|
||||
*/
|
||||
public record CampaignData(String name, String description, String loreId) {}
|
||||
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) {
|
||||
Campaign campaign = Campaign.builder()
|
||||
.name(data.name())
|
||||
.description(data.description())
|
||||
.loreId(normalizeLoreId(data.loreId()))
|
||||
.loreId(normalizeId(data.loreId()))
|
||||
.gameSystemId(normalizeId(data.gameSystemId()))
|
||||
.arcsCount(0)
|
||||
.build();
|
||||
return campaignRepository.save(campaign);
|
||||
@@ -57,19 +84,61 @@ public class CampaignService {
|
||||
Campaign campaign = existingCampaign.get();
|
||||
campaign.setName(data.name());
|
||||
campaign.setDescription(data.description());
|
||||
campaign.setLoreId(normalizeLoreId(data.loreId()));
|
||||
campaign.setLoreId(normalizeId(data.loreId()));
|
||||
campaign.setGameSystemId(normalizeId(data.gameSystemId()));
|
||||
return campaignRepository.save(campaign);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise un loreId entrant : une chaîne vide/blanche est traitée comme "pas de lien".
|
||||
* Normalise un ID entrant : une chaîne vide/blanche est traitée comme "pas de lien".
|
||||
* Utile car les payloads JSON peuvent envoyer "" au lieu de null.
|
||||
*/
|
||||
private String normalizeLoreId(String loreId) {
|
||||
return (loreId == null || loreId.isBlank()) ? null : loreId;
|
||||
private String normalizeId(String 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) {
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,8 +2,10 @@ package com.loremind.application.campaigncontext;
|
||||
|
||||
import com.loremind.domain.campaigncontext.Chapter;
|
||||
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
|
||||
import com.loremind.domain.campaigncontext.ports.SceneRepository;
|
||||
import org.springframework.beans.BeanUtils;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
@@ -17,17 +19,27 @@ import java.util.Optional;
|
||||
public class ChapterService {
|
||||
|
||||
private final ChapterRepository chapterRepository;
|
||||
private final SceneRepository sceneRepository;
|
||||
|
||||
public ChapterService(ChapterRepository chapterRepository) {
|
||||
public ChapterService(ChapterRepository chapterRepository, SceneRepository sceneRepository) {
|
||||
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) {
|
||||
return createChapter(name, description, arcId, order, null);
|
||||
}
|
||||
|
||||
public Chapter createChapter(String name, String description, String arcId, int order, String icon) {
|
||||
Chapter chapter = Chapter.builder()
|
||||
.name(name)
|
||||
.description(description)
|
||||
.arcId(arcId)
|
||||
.order(order)
|
||||
.icon(icon)
|
||||
.build();
|
||||
return chapterRepository.save(chapter);
|
||||
}
|
||||
@@ -58,7 +70,17 @@ public class ChapterService {
|
||||
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) {
|
||||
for (var scene : sceneRepository.findByChapterId(id)) {
|
||||
sceneRepository.deleteById(scene.getId());
|
||||
}
|
||||
chapterRepository.deleteById(id);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.loremind.application.campaigncontext;
|
||||
|
||||
import com.loremind.domain.campaigncontext.Character;
|
||||
import com.loremind.domain.campaigncontext.ports.CharacterRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Service d'application pour les fiches de personnages (PJ).
|
||||
*/
|
||||
@Service
|
||||
public class CharacterService {
|
||||
|
||||
private final CharacterRepository characterRepository;
|
||||
|
||||
public CharacterService(CharacterRepository characterRepository) {
|
||||
this.characterRepository = characterRepository;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parameter Object pour la création / mise à jour d'un Character.
|
||||
* `order` est fourni par le controller ; si absent, le service le calcule.
|
||||
*/
|
||||
public record CharacterData(String name, String markdownContent, String campaignId, Integer order) {}
|
||||
|
||||
public Character createCharacter(CharacterData data) {
|
||||
int order = data.order() != null
|
||||
? data.order()
|
||||
: nextOrderFor(data.campaignId());
|
||||
Character character = Character.builder()
|
||||
.name(data.name())
|
||||
.markdownContent(data.markdownContent())
|
||||
.campaignId(data.campaignId())
|
||||
.order(order)
|
||||
.build();
|
||||
return characterRepository.save(character);
|
||||
}
|
||||
|
||||
public Optional<Character> getCharacterById(String id) {
|
||||
return characterRepository.findById(id);
|
||||
}
|
||||
|
||||
public List<Character> getCharactersByCampaignId(String campaignId) {
|
||||
return characterRepository.findByCampaignId(campaignId);
|
||||
}
|
||||
|
||||
public Character updateCharacter(String id, CharacterData data) {
|
||||
Character existing = characterRepository.findById(id)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Character non trouvé avec l'ID: " + id));
|
||||
existing.setName(data.name());
|
||||
existing.setMarkdownContent(data.markdownContent());
|
||||
if (data.order() != null) {
|
||||
existing.setOrder(data.order());
|
||||
}
|
||||
// campaignId n'est pas modifiable après création (cross-campagne move hors scope MVP).
|
||||
return characterRepository.save(existing);
|
||||
}
|
||||
|
||||
public void deleteCharacter(String id) {
|
||||
characterRepository.deleteById(id);
|
||||
}
|
||||
|
||||
/** Renvoie la prochaine position libre — append en fin de liste. */
|
||||
private int nextOrderFor(String campaignId) {
|
||||
return characterRepository.findByCampaignId(campaignId).stream()
|
||||
.mapToInt(Character::getOrder)
|
||||
.max()
|
||||
.orElse(-1) + 1;
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
return createScene(name, description, chapterId, order, null);
|
||||
}
|
||||
|
||||
public Scene createScene(String name, String description, String chapterId, int order, String icon) {
|
||||
Scene scene = Scene.builder()
|
||||
.name(name)
|
||||
.description(description)
|
||||
.chapterId(chapterId)
|
||||
.order(order)
|
||||
.icon(icon)
|
||||
.build();
|
||||
return sceneRepository.save(scene);
|
||||
}
|
||||
@@ -93,7 +98,7 @@ public class SceneService {
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
for (SceneBranch b : branches) {
|
||||
String target = b.getTargetSceneId();
|
||||
String target = b.targetSceneId();
|
||||
if (target == null || target.isBlank()) {
|
||||
throw new IllegalArgumentException("Une branche doit avoir une scène de destination");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
package com.loremind.application.conversationcontext;
|
||||
|
||||
import com.loremind.domain.conversationcontext.Conversation;
|
||||
import com.loremind.domain.conversationcontext.ConversationMessage;
|
||||
import com.loremind.domain.conversationcontext.ports.ConversationRepository;
|
||||
import com.loremind.domain.conversationcontext.ports.ConversationTitleGenerator;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Service d'application du contexte Conversation.
|
||||
*
|
||||
* Regroupe les cas d'usage CRUD + append message + rename. Un seul
|
||||
* service suffit — le contexte est simple et les operations fortement
|
||||
* liees (meme aggregat).
|
||||
*
|
||||
* Regles metier :
|
||||
* - exactement un ancrage parent (loreId XOR campaignId) ;
|
||||
* - entityType et entityId vont ensemble (tous deux null = niveau racine,
|
||||
* tous deux non-null = niveau entite precise).
|
||||
*/
|
||||
@Service
|
||||
public class ConversationService {
|
||||
|
||||
private final ConversationRepository repository;
|
||||
private final ConversationTitleGenerator titleGenerator;
|
||||
|
||||
public ConversationService(ConversationRepository repository,
|
||||
ConversationTitleGenerator titleGenerator) {
|
||||
this.repository = repository;
|
||||
this.titleGenerator = titleGenerator;
|
||||
}
|
||||
|
||||
/** Donnees de creation d'une conversation. Titre optionnel — sera auto-genere si absent. */
|
||||
public record CreateData(
|
||||
String title,
|
||||
String loreId,
|
||||
String campaignId,
|
||||
String entityType,
|
||||
String entityId) {}
|
||||
|
||||
public Conversation create(CreateData data) {
|
||||
validateAnchor(data.loreId(), data.campaignId(), data.entityType(), data.entityId());
|
||||
|
||||
String title = (data.title() == null || data.title().isBlank())
|
||||
? "Nouvelle conversation"
|
||||
: data.title().trim();
|
||||
|
||||
Conversation conv = Conversation.builder()
|
||||
.title(title)
|
||||
.loreId(data.loreId())
|
||||
.campaignId(data.campaignId())
|
||||
.entityType(data.entityType())
|
||||
.entityId(data.entityId())
|
||||
.build();
|
||||
return repository.save(conv);
|
||||
}
|
||||
|
||||
public Optional<Conversation> getById(String id) {
|
||||
return repository.findById(id);
|
||||
}
|
||||
|
||||
public List<Conversation> listByContext(String loreId, String campaignId, String entityType, String entityId) {
|
||||
validateAnchor(loreId, campaignId, entityType, entityId);
|
||||
return repository.findByContext(loreId, campaignId, entityType, entityId);
|
||||
}
|
||||
|
||||
public void rename(String id, String title) {
|
||||
if (title == null || title.isBlank()) {
|
||||
throw new IllegalArgumentException("Le titre ne peut pas etre vide");
|
||||
}
|
||||
if (repository.findById(id).isEmpty()) {
|
||||
throw new IllegalArgumentException("Conversation introuvable : " + id);
|
||||
}
|
||||
repository.updateTitle(id, title.trim());
|
||||
}
|
||||
|
||||
public void delete(String id) {
|
||||
repository.deleteById(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-genere un titre a partir des premiers messages et le persiste.
|
||||
* Appele typiquement apres le 1er couple user/assistant pour remplacer
|
||||
* le titre provisoire. Echec silencieux (fallback dans l'adaptateur) —
|
||||
* on n'empeche pas la conversation de fonctionner si le Brain est down.
|
||||
*/
|
||||
public String autoGenerateTitle(String conversationId) {
|
||||
Conversation conv = repository.findById(conversationId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Conversation introuvable : " + conversationId));
|
||||
List<ConversationMessage> seeds = conv.getMessages();
|
||||
if (seeds == null || seeds.isEmpty()) {
|
||||
return conv.getTitle();
|
||||
}
|
||||
String title = titleGenerator.generate(seeds);
|
||||
repository.updateTitle(conversationId, title);
|
||||
return title;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ajoute un message (user ou assistant) a une conversation existante.
|
||||
* L'horodatage et l'id sont assignes par la couche persistance.
|
||||
*/
|
||||
public ConversationMessage appendMessage(String conversationId, String role, String content) {
|
||||
if (role == null || (!role.equals("user") && !role.equals("assistant") && !role.equals("system"))) {
|
||||
throw new IllegalArgumentException("Role invalide : " + role);
|
||||
}
|
||||
if (content == null || content.isEmpty()) {
|
||||
throw new IllegalArgumentException("Contenu vide interdit");
|
||||
}
|
||||
ConversationMessage msg = ConversationMessage.builder()
|
||||
.role(role)
|
||||
.content(content)
|
||||
.build();
|
||||
return repository.appendMessage(conversationId, msg);
|
||||
}
|
||||
|
||||
// ---------- Validation ----------
|
||||
|
||||
private void validateAnchor(String loreId, String campaignId, String entityType, String entityId) {
|
||||
boolean hasLore = loreId != null && !loreId.isBlank();
|
||||
boolean hasCamp = campaignId != null && !campaignId.isBlank();
|
||||
if (hasLore == hasCamp) {
|
||||
throw new IllegalArgumentException("Exactement un parent attendu : loreId XOR campaignId");
|
||||
}
|
||||
boolean hasType = entityType != null && !entityType.isBlank();
|
||||
boolean hasEntId = entityId != null && !entityId.isBlank();
|
||||
if (hasType != hasEntId) {
|
||||
throw new IllegalArgumentException("entityType et entityId doivent etre tous deux null ou tous deux non-null");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package com.loremind.application.gamesystemcontext;
|
||||
|
||||
import com.loremind.domain.gamesystemcontext.GameSystem;
|
||||
import com.loremind.domain.gamesystemcontext.GenerationIntent;
|
||||
import com.loremind.domain.gamesystemcontext.ports.GameSystemRepository;
|
||||
import com.loremind.domain.generationcontext.GameSystemContext;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Construit un {@link GameSystemContext} à partir d'un gameSystemId et d'un intent.
|
||||
* <p>
|
||||
* Pipeline :
|
||||
* 1. Charge le GameSystem (retourne Optional.empty si introuvable — dégradation gracieuse).
|
||||
* 2. Parse le markdown par titres H2 (## Section) → Map<Titre, Contenu>.
|
||||
* 3. Filtre les sections selon l'intent via les alias {@link GenerationIntent#getSectionAliases()}.
|
||||
* GENERIC = pas de filtre.
|
||||
* <p>
|
||||
* Parsing à la volée (pas de cache) : les règles d'un système font
|
||||
* typiquement 5-20kB, le coût de parsing est négligeable devant l'appel LLM.
|
||||
*/
|
||||
@Service
|
||||
public class GameSystemContextBuilder {
|
||||
|
||||
/** Matche "## Titre" en début de ligne (multiline). Capture le titre en groupe 1. */
|
||||
private static final Pattern H2_HEADER = Pattern.compile("(?m)^##\\s+(.+?)\\s*$");
|
||||
|
||||
private final GameSystemRepository gameSystemRepository;
|
||||
|
||||
public GameSystemContextBuilder(GameSystemRepository gameSystemRepository) {
|
||||
this.gameSystemRepository = gameSystemRepository;
|
||||
}
|
||||
|
||||
public Optional<GameSystemContext> buildOptional(String gameSystemId, GenerationIntent intent) {
|
||||
if (gameSystemId == null || gameSystemId.isBlank()) return Optional.empty();
|
||||
return gameSystemRepository.findById(gameSystemId)
|
||||
.map(gs -> build(gs, intent));
|
||||
}
|
||||
|
||||
private GameSystemContext build(GameSystem gs, GenerationIntent intent) {
|
||||
Map<String, String> allSections = parseH2Sections(gs.getRulesMarkdown());
|
||||
Map<String, String> filtered = filterByIntent(allSections, intent);
|
||||
return new GameSystemContext(gs.getName(), gs.getDescription(), filtered);
|
||||
}
|
||||
|
||||
/**
|
||||
* Découpe le markdown par titres H2. Préserve l'ordre d'apparition (LinkedHashMap).
|
||||
* Le contenu avant le premier H2 est ignoré (préambule libre).
|
||||
*/
|
||||
Map<String, String> parseH2Sections(String markdown) {
|
||||
Map<String, String> sections = new LinkedHashMap<>();
|
||||
if (markdown == null || markdown.isBlank()) return sections;
|
||||
|
||||
Matcher m = H2_HEADER.matcher(markdown);
|
||||
String currentTitle = null;
|
||||
int currentContentStart = -1;
|
||||
|
||||
while (m.find()) {
|
||||
if (currentTitle != null) {
|
||||
sections.put(currentTitle, markdown.substring(currentContentStart, m.start()).strip());
|
||||
}
|
||||
currentTitle = m.group(1).trim();
|
||||
currentContentStart = m.end();
|
||||
}
|
||||
if (currentTitle != null) {
|
||||
sections.put(currentTitle, markdown.substring(currentContentStart).strip());
|
||||
}
|
||||
return sections;
|
||||
}
|
||||
|
||||
private Map<String, String> filterByIntent(Map<String, String> sections, GenerationIntent intent) {
|
||||
if (intent.matchesAllSections()) return sections;
|
||||
Map<String, String> filtered = new LinkedHashMap<>();
|
||||
for (Map.Entry<String, String> e : sections.entrySet()) {
|
||||
String titleLower = e.getKey().toLowerCase();
|
||||
boolean match = intent.getSectionAliases().stream().anyMatch(titleLower::contains);
|
||||
if (match) {
|
||||
filtered.put(e.getKey(), e.getValue());
|
||||
}
|
||||
}
|
||||
return filtered;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package com.loremind.application.gamesystemcontext;
|
||||
|
||||
import com.loremind.domain.gamesystemcontext.GameSystem;
|
||||
import com.loremind.domain.gamesystemcontext.ports.GameSystemRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
@Service
|
||||
public class GameSystemService {
|
||||
|
||||
private final GameSystemRepository gameSystemRepository;
|
||||
|
||||
public GameSystemService(GameSystemRepository gameSystemRepository) {
|
||||
this.gameSystemRepository = gameSystemRepository;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parameter Object pour la création / mise à jour d'un GameSystem.
|
||||
*/
|
||||
public record GameSystemData(
|
||||
String name,
|
||||
String description,
|
||||
String rulesMarkdown,
|
||||
String author,
|
||||
boolean isPublic
|
||||
) {}
|
||||
|
||||
public GameSystem createGameSystem(GameSystemData data) {
|
||||
GameSystem gameSystem = GameSystem.builder()
|
||||
.name(data.name())
|
||||
.description(data.description())
|
||||
.rulesMarkdown(data.rulesMarkdown())
|
||||
.author(normalize(data.author()))
|
||||
.isPublic(data.isPublic())
|
||||
.build();
|
||||
return gameSystemRepository.save(gameSystem);
|
||||
}
|
||||
|
||||
public Optional<GameSystem> getGameSystemById(String id) {
|
||||
return gameSystemRepository.findById(id);
|
||||
}
|
||||
|
||||
public List<GameSystem> getAllGameSystems() {
|
||||
return gameSystemRepository.findAll();
|
||||
}
|
||||
|
||||
public GameSystem updateGameSystem(String id, GameSystemData data) {
|
||||
GameSystem existing = gameSystemRepository.findById(id)
|
||||
.orElseThrow(() -> new IllegalArgumentException("GameSystem non trouvé avec l'ID: " + id));
|
||||
existing.setName(data.name());
|
||||
existing.setDescription(data.description());
|
||||
existing.setRulesMarkdown(data.rulesMarkdown());
|
||||
existing.setAuthor(normalize(data.author()));
|
||||
existing.setPublic(data.isPublic());
|
||||
return gameSystemRepository.save(existing);
|
||||
}
|
||||
|
||||
public void deleteGameSystem(String id) {
|
||||
gameSystemRepository.deleteById(id);
|
||||
}
|
||||
|
||||
public boolean gameSystemExists(String id) {
|
||||
return gameSystemRepository.existsById(id);
|
||||
}
|
||||
|
||||
public List<GameSystem> searchGameSystems(String query) {
|
||||
if (query == null || query.isBlank()) return List.of();
|
||||
return gameSystemRepository.searchByName(query.trim());
|
||||
}
|
||||
|
||||
private String normalize(String value) {
|
||||
return (value == null || value.isBlank()) ? null : value;
|
||||
}
|
||||
}
|
||||
@@ -3,15 +3,21 @@ package com.loremind.application.generationcontext;
|
||||
import com.loremind.domain.campaigncontext.Arc;
|
||||
import com.loremind.domain.campaigncontext.Campaign;
|
||||
import com.loremind.domain.campaigncontext.Chapter;
|
||||
import com.loremind.domain.campaigncontext.Character;
|
||||
import com.loremind.domain.campaigncontext.Npc;
|
||||
import com.loremind.domain.campaigncontext.Scene;
|
||||
import com.loremind.domain.campaigncontext.ports.ArcRepository;
|
||||
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
|
||||
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
|
||||
import com.loremind.domain.campaigncontext.ports.CharacterRepository;
|
||||
import com.loremind.domain.campaigncontext.ports.NpcRepository;
|
||||
import com.loremind.domain.campaigncontext.ports.SceneRepository;
|
||||
import com.loremind.domain.generationcontext.CampaignStructuralContext;
|
||||
import com.loremind.domain.generationcontext.CampaignStructuralContext.ArcSummary;
|
||||
import com.loremind.domain.generationcontext.CampaignStructuralContext.BranchHint;
|
||||
import com.loremind.domain.generationcontext.CampaignStructuralContext.ChapterSummary;
|
||||
import com.loremind.domain.generationcontext.CampaignStructuralContext.CharacterSummary;
|
||||
import com.loremind.domain.generationcontext.CampaignStructuralContext.NpcSummary;
|
||||
import com.loremind.domain.generationcontext.CampaignStructuralContext.SceneSummary;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@@ -38,18 +44,27 @@ public class CampaignStructuralContextBuilder {
|
||||
private final ArcRepository arcRepository;
|
||||
private final ChapterRepository chapterRepository;
|
||||
private final SceneRepository sceneRepository;
|
||||
private final CharacterRepository characterRepository;
|
||||
private final NpcRepository npcRepository;
|
||||
|
||||
public CampaignStructuralContextBuilder(
|
||||
CampaignRepository campaignRepository,
|
||||
ArcRepository arcRepository,
|
||||
ChapterRepository chapterRepository,
|
||||
SceneRepository sceneRepository) {
|
||||
SceneRepository sceneRepository,
|
||||
CharacterRepository characterRepository,
|
||||
NpcRepository npcRepository) {
|
||||
this.campaignRepository = campaignRepository;
|
||||
this.arcRepository = arcRepository;
|
||||
this.chapterRepository = chapterRepository;
|
||||
this.sceneRepository = sceneRepository;
|
||||
this.characterRepository = characterRepository;
|
||||
this.npcRepository = npcRepository;
|
||||
}
|
||||
|
||||
/** 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;
|
||||
|
||||
/**
|
||||
* Construit la carte narrative d'une Campagne (arcs → chapitres → scènes,
|
||||
* nom + description courte à chaque niveau).
|
||||
@@ -65,11 +80,47 @@ public class CampaignStructuralContextBuilder {
|
||||
.map(this::toArcSummary)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
return CampaignStructuralContext.builder()
|
||||
.campaignName(campaign.getName())
|
||||
.campaignDescription(campaign.getDescription())
|
||||
.arcs(arcs)
|
||||
.build();
|
||||
List<CharacterSummary> characters = characterRepository.findByCampaignId(campaignId).stream()
|
||||
.sorted(Comparator.comparingInt(Character::getOrder))
|
||||
.map(this::toCharacterSummary)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
List<NpcSummary> npcs = npcRepository.findByCampaignId(campaignId).stream()
|
||||
.sorted(Comparator.comparingInt(Npc::getOrder))
|
||||
.map(this::toNpcSummary)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
return new CampaignStructuralContext(
|
||||
campaign.getName(),
|
||||
campaign.getDescription(),
|
||||
arcs,
|
||||
characters,
|
||||
npcs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Projette un PJ vers un résumé court : nom + 1re ligne "signifiante" du
|
||||
* markdown (ni vide, ni un titre). Permet à l'IA de savoir "qui est Thorin"
|
||||
* sans injecter toute sa fiche.
|
||||
*/
|
||||
private CharacterSummary toCharacterSummary(Character c) {
|
||||
return new CharacterSummary(c.getName(), extractSnippet(c.getMarkdownContent()));
|
||||
}
|
||||
|
||||
/** 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) {
|
||||
if (markdown == null || markdown.isBlank()) return "";
|
||||
String firstLine = markdown.lines()
|
||||
.map(String::strip)
|
||||
.filter(l -> !l.isEmpty() && !l.startsWith("#"))
|
||||
.findFirst()
|
||||
.orElse("");
|
||||
if (firstLine.length() <= CHARACTER_SNIPPET_MAX_LEN) return firstLine;
|
||||
return firstLine.substring(0, CHARACTER_SNIPPET_MAX_LEN - 1).stripTrailing() + "…";
|
||||
}
|
||||
|
||||
private ArcSummary toArcSummary(Arc arc) {
|
||||
@@ -77,12 +128,11 @@ public class CampaignStructuralContextBuilder {
|
||||
.sorted(Comparator.comparingInt(Chapter::getOrder))
|
||||
.map(this::toChapterSummary)
|
||||
.collect(Collectors.toList());
|
||||
return ArcSummary.builder()
|
||||
.name(arc.getName())
|
||||
.description(arc.getDescription())
|
||||
.illustrationCount(countImages(arc.getIllustrationImageIds()))
|
||||
.chapters(chapters)
|
||||
.build();
|
||||
return new ArcSummary(
|
||||
arc.getName(),
|
||||
arc.getDescription(),
|
||||
countImages(arc.getIllustrationImageIds()),
|
||||
chapters);
|
||||
}
|
||||
|
||||
private ChapterSummary toChapterSummary(Chapter chapter) {
|
||||
@@ -99,32 +149,28 @@ public class CampaignStructuralContextBuilder {
|
||||
.map(s -> toSceneSummary(s, nameById))
|
||||
.collect(Collectors.toList());
|
||||
|
||||
return ChapterSummary.builder()
|
||||
.name(chapter.getName())
|
||||
.description(chapter.getDescription())
|
||||
.illustrationCount(countImages(chapter.getIllustrationImageIds()))
|
||||
.scenes(summaries)
|
||||
.build();
|
||||
return new ChapterSummary(
|
||||
chapter.getName(),
|
||||
chapter.getDescription(),
|
||||
countImages(chapter.getIllustrationImageIds()),
|
||||
summaries);
|
||||
}
|
||||
|
||||
private SceneSummary toSceneSummary(Scene scene, Map<String, String> nameById) {
|
||||
List<BranchHint> hints = scene.getBranches() == null
|
||||
? List.of()
|
||||
: scene.getBranches().stream()
|
||||
.map(b -> BranchHint.builder()
|
||||
.label(b.getLabel())
|
||||
.targetSceneName(nameById.getOrDefault(
|
||||
b.getTargetSceneId(), "(scène inconnue)"))
|
||||
.condition(b.getCondition())
|
||||
.build())
|
||||
.map(b -> new BranchHint(
|
||||
b.label(),
|
||||
nameById.getOrDefault(b.targetSceneId(), "(scène inconnue)"),
|
||||
b.condition()))
|
||||
.collect(Collectors.toList());
|
||||
|
||||
return SceneSummary.builder()
|
||||
.name(scene.getName())
|
||||
.description(scene.getDescription())
|
||||
.illustrationCount(countImages(scene.getIllustrationImageIds()))
|
||||
.branches(hints)
|
||||
.build();
|
||||
return new SceneSummary(
|
||||
scene.getName(),
|
||||
scene.getDescription(),
|
||||
countImages(scene.getIllustrationImageIds()),
|
||||
hints);
|
||||
}
|
||||
|
||||
/** Helper defensif : compte les illustrations attachees (null-safe). */
|
||||
|
||||
@@ -67,16 +67,15 @@ public class GeneratePageValuesUseCase {
|
||||
|
||||
requireNonEmptyFields(template);
|
||||
|
||||
GenerationContext context = GenerationContext.builder()
|
||||
.loreName(lore.getName())
|
||||
.loreDescription(lore.getDescription())
|
||||
.folderName(folder.getName())
|
||||
.templateName(template.getName())
|
||||
// Seuls les champs TEXT sont envoyes a l'IA : les champs IMAGE
|
||||
// necessitent un workflow different (pas de generation LLM texte).
|
||||
.templateFields(template.textFieldNames())
|
||||
.pageTitle(page.getTitle())
|
||||
.build();
|
||||
// Seuls les champs TEXT sont envoyes a l'IA : les champs IMAGE
|
||||
// necessitent un workflow different (pas de generation LLM texte).
|
||||
GenerationContext context = new GenerationContext(
|
||||
lore.getName(),
|
||||
lore.getDescription(),
|
||||
folder.getName(),
|
||||
template.getName(),
|
||||
template.textFieldNames(),
|
||||
page.getTitle());
|
||||
|
||||
GenerationResult result = aiProvider.generatePage(context);
|
||||
return result.values();
|
||||
|
||||
@@ -82,12 +82,11 @@ public class LoreStructuralContextBuilder {
|
||||
Map<String, String> pageTitleById = pages.stream()
|
||||
.collect(Collectors.toMap(Page::getId, Page::getTitle, (a, b) -> a));
|
||||
|
||||
return LoreStructuralContext.builder()
|
||||
.loreName(lore.getName())
|
||||
.loreDescription(lore.getDescription())
|
||||
.folders(buildFoldersMap(nodes, pages, templateNameById, pageTitleById))
|
||||
.tags(extractUniqueTags(pages))
|
||||
.build();
|
||||
return new LoreStructuralContext(
|
||||
lore.getName(),
|
||||
lore.getDescription(),
|
||||
buildFoldersMap(nodes, pages, templateNameById, pageTitleById),
|
||||
extractUniqueTags(pages));
|
||||
}
|
||||
|
||||
private Map<String, List<PageSummary>> buildFoldersMap(
|
||||
@@ -118,13 +117,12 @@ public class LoreStructuralContextBuilder {
|
||||
Page page,
|
||||
Map<String, String> templateNameById,
|
||||
Map<String, String> pageTitleById) {
|
||||
return PageSummary.builder()
|
||||
.title(page.getTitle())
|
||||
.templateName(templateNameById.getOrDefault(page.getTemplateId(), "?"))
|
||||
.values(truncatedValues(page.getValues()))
|
||||
.tags(page.getTags() != null ? List.copyOf(page.getTags()) : Collections.emptyList())
|
||||
.relatedPageTitles(resolveRelatedTitles(page.getRelatedPageIds(), pageTitleById))
|
||||
.build();
|
||||
return new PageSummary(
|
||||
page.getTitle(),
|
||||
templateNameById.getOrDefault(page.getTemplateId(), "?"),
|
||||
truncatedValues(page.getValues()),
|
||||
page.getTags() != null ? List.copyOf(page.getTags()) : Collections.emptyList(),
|
||||
resolveRelatedTitles(page.getRelatedPageIds(), pageTitleById));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,9 +2,13 @@ package com.loremind.application.generationcontext;
|
||||
|
||||
import com.loremind.domain.campaigncontext.Arc;
|
||||
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.ports.ArcRepository;
|
||||
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.generationcontext.NarrativeEntityContext;
|
||||
import org.springframework.stereotype.Component;
|
||||
@@ -26,20 +30,26 @@ public class NarrativeEntityContextBuilder {
|
||||
private final ArcRepository arcRepository;
|
||||
private final ChapterRepository chapterRepository;
|
||||
private final SceneRepository sceneRepository;
|
||||
private final CharacterRepository characterRepository;
|
||||
private final NpcRepository npcRepository;
|
||||
|
||||
public NarrativeEntityContextBuilder(
|
||||
ArcRepository arcRepository,
|
||||
ChapterRepository chapterRepository,
|
||||
SceneRepository sceneRepository) {
|
||||
SceneRepository sceneRepository,
|
||||
CharacterRepository characterRepository,
|
||||
NpcRepository npcRepository) {
|
||||
this.arcRepository = arcRepository;
|
||||
this.chapterRepository = chapterRepository;
|
||||
this.sceneRepository = sceneRepository;
|
||||
this.characterRepository = characterRepository;
|
||||
this.npcRepository = npcRepository;
|
||||
}
|
||||
|
||||
/**
|
||||
* Charge l'entité narrative ciblée et la projette vers un VO du GenerationContext.
|
||||
*
|
||||
* @param entityType "arc", "chapter" ou "scene" (insensible à la casse)
|
||||
* @param entityType "arc", "chapter", "scene", "character" ou "npc" (insensible à la casse)
|
||||
* @param entityId l'ID de l'entité
|
||||
* @throws IllegalArgumentException si le type est inconnu ou l'entité introuvable
|
||||
*/
|
||||
@@ -49,6 +59,8 @@ public class NarrativeEntityContextBuilder {
|
||||
case "arc" -> fromArc(loadArc(entityId));
|
||||
case "chapter" -> fromChapter(loadChapter(entityId));
|
||||
case "scene" -> fromScene(loadScene(entityId));
|
||||
case "character" -> fromCharacter(loadCharacter(entityId));
|
||||
case "npc" -> fromNpc(loadNpc(entityId));
|
||||
default -> throw new IllegalArgumentException("Type d'entité narrative inconnu: " + entityType);
|
||||
};
|
||||
}
|
||||
@@ -70,6 +82,16 @@ public class NarrativeEntityContextBuilder {
|
||||
.orElseThrow(() -> new IllegalArgumentException("Scène non trouvée: " + id));
|
||||
}
|
||||
|
||||
private Character loadCharacter(String id) {
|
||||
return characterRepository.findById(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 ------------------------------------------------
|
||||
|
||||
private NarrativeEntityContext fromArc(Arc a) {
|
||||
@@ -80,11 +102,7 @@ public class NarrativeEntityContextBuilder {
|
||||
putField(fields, "rewards", a.getRewards());
|
||||
putField(fields, "resolution", a.getResolution());
|
||||
putField(fields, "gmNotes", a.getGmNotes());
|
||||
return NarrativeEntityContext.builder()
|
||||
.entityType("arc")
|
||||
.title(a.getName())
|
||||
.fields(fields)
|
||||
.build();
|
||||
return new NarrativeEntityContext("arc", a.getName(), fields);
|
||||
}
|
||||
|
||||
private NarrativeEntityContext fromChapter(Chapter c) {
|
||||
@@ -93,11 +111,7 @@ public class NarrativeEntityContextBuilder {
|
||||
putField(fields, "playerObjectives", c.getPlayerObjectives());
|
||||
putField(fields, "narrativeStakes", c.getNarrativeStakes());
|
||||
putField(fields, "gmNotes", c.getGmNotes());
|
||||
return NarrativeEntityContext.builder()
|
||||
.entityType("chapter")
|
||||
.title(c.getName())
|
||||
.fields(fields)
|
||||
.build();
|
||||
return new NarrativeEntityContext("chapter", c.getName(), fields);
|
||||
}
|
||||
|
||||
private NarrativeEntityContext fromScene(Scene s) {
|
||||
@@ -111,11 +125,19 @@ public class NarrativeEntityContextBuilder {
|
||||
putField(fields, "combatDifficulty", s.getCombatDifficulty());
|
||||
putField(fields, "enemies", s.getEnemies());
|
||||
putField(fields, "gmSecretNotes", s.getGmSecretNotes());
|
||||
return NarrativeEntityContext.builder()
|
||||
.entityType("scene")
|
||||
.title(s.getName())
|
||||
.fields(fields)
|
||||
.build();
|
||||
return new NarrativeEntityContext("scene", s.getName(), fields);
|
||||
}
|
||||
|
||||
private NarrativeEntityContext fromCharacter(Character c) {
|
||||
Map<String, String> fields = new LinkedHashMap<>();
|
||||
putField(fields, "fiche complète (markdown)", c.getMarkdownContent());
|
||||
return new NarrativeEntityContext("character", c.getName(), fields);
|
||||
}
|
||||
|
||||
private NarrativeEntityContext fromNpc(Npc n) {
|
||||
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. */
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
package com.loremind.application.generationcontext;
|
||||
|
||||
import com.loremind.application.gamesystemcontext.GameSystemContextBuilder;
|
||||
import com.loremind.domain.campaigncontext.Campaign;
|
||||
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
|
||||
import com.loremind.domain.gamesystemcontext.GenerationIntent;
|
||||
import com.loremind.domain.generationcontext.CampaignStructuralContext;
|
||||
import com.loremind.domain.generationcontext.ChatMessage;
|
||||
import com.loremind.domain.generationcontext.ChatRequest;
|
||||
import com.loremind.domain.generationcontext.ChatUsage;
|
||||
import com.loremind.domain.generationcontext.GameSystemContext;
|
||||
import com.loremind.domain.generationcontext.LoreStructuralContext;
|
||||
import com.loremind.domain.generationcontext.NarrativeEntityContext;
|
||||
import com.loremind.domain.generationcontext.ports.AiChatProvider;
|
||||
@@ -33,6 +37,7 @@ public class StreamChatForCampaignUseCase {
|
||||
private final CampaignStructuralContextBuilder campaignContextBuilder;
|
||||
private final LoreStructuralContextBuilder loreContextBuilder;
|
||||
private final NarrativeEntityContextBuilder narrativeEntityContextBuilder;
|
||||
private final GameSystemContextBuilder gameSystemContextBuilder;
|
||||
private final AiChatProvider aiChatProvider;
|
||||
|
||||
public StreamChatForCampaignUseCase(
|
||||
@@ -40,11 +45,13 @@ public class StreamChatForCampaignUseCase {
|
||||
CampaignStructuralContextBuilder campaignContextBuilder,
|
||||
LoreStructuralContextBuilder loreContextBuilder,
|
||||
NarrativeEntityContextBuilder narrativeEntityContextBuilder,
|
||||
GameSystemContextBuilder gameSystemContextBuilder,
|
||||
AiChatProvider aiChatProvider) {
|
||||
this.campaignRepository = campaignRepository;
|
||||
this.campaignContextBuilder = campaignContextBuilder;
|
||||
this.loreContextBuilder = loreContextBuilder;
|
||||
this.narrativeEntityContextBuilder = narrativeEntityContextBuilder;
|
||||
this.gameSystemContextBuilder = gameSystemContextBuilder;
|
||||
this.aiChatProvider = aiChatProvider;
|
||||
}
|
||||
|
||||
@@ -65,6 +72,7 @@ public class StreamChatForCampaignUseCase {
|
||||
String entityType,
|
||||
String entityId,
|
||||
List<ChatMessage> messages,
|
||||
Consumer<ChatUsage> onUsage,
|
||||
Consumer<String> onToken,
|
||||
Runnable onComplete,
|
||||
Consumer<Throwable> onError) {
|
||||
@@ -76,15 +84,17 @@ public class StreamChatForCampaignUseCase {
|
||||
CampaignStructuralContext campaignContext = campaignContextBuilder.build(campaignId);
|
||||
LoreStructuralContext loreContext = loadLinkedLoreContextOrNull(campaign);
|
||||
NarrativeEntityContext narrativeEntity = buildNarrativeEntityOrNull(entityType, entityId);
|
||||
GameSystemContext gameSystemContext = loadGameSystemContextOrNull(campaign, entityType);
|
||||
|
||||
ChatRequest request = ChatRequest.builder()
|
||||
.messages(messages)
|
||||
.loreContext(loreContext)
|
||||
.campaignContext(campaignContext)
|
||||
.narrativeEntity(narrativeEntity)
|
||||
.gameSystemContext(gameSystemContext)
|
||||
.build();
|
||||
|
||||
aiChatProvider.streamChat(request, onToken, onComplete, onError);
|
||||
aiChatProvider.streamChat(request, onUsage, onToken, onComplete, onError);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -102,4 +112,16 @@ public class StreamChatForCampaignUseCase {
|
||||
if (entityId == null || entityId.isBlank()) return null;
|
||||
return narrativeEntityContextBuilder.build(entityType, entityId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Charge le GameSystemContext si la campagne est liée à un GameSystem.
|
||||
* L'entityType détermine quelles sections de règles sont injectées
|
||||
* (SCENE → combat/PNJ, CHAPTER → combat/classes, ARC → lore/factions, autre → toutes).
|
||||
* Retourne null en cas de GameSystem introuvable (dégradation gracieuse).
|
||||
*/
|
||||
private GameSystemContext loadGameSystemContextOrNull(Campaign campaign, String entityType) {
|
||||
if (!campaign.isLinkedToGameSystem()) return null;
|
||||
GenerationIntent intent = GenerationIntent.fromNarrativeEntityType(entityType);
|
||||
return gameSystemContextBuilder.buildOptional(campaign.getGameSystemId(), intent).orElse(null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.loremind.application.generationcontext;
|
||||
|
||||
import com.loremind.domain.generationcontext.ChatMessage;
|
||||
import com.loremind.domain.generationcontext.ChatRequest;
|
||||
import com.loremind.domain.generationcontext.ChatUsage;
|
||||
import com.loremind.domain.generationcontext.LoreStructuralContext;
|
||||
import com.loremind.domain.generationcontext.PageContext;
|
||||
import com.loremind.domain.generationcontext.ports.AiChatProvider;
|
||||
@@ -60,6 +61,7 @@ public class StreamChatForLoreUseCase {
|
||||
String loreId,
|
||||
String pageId,
|
||||
List<ChatMessage> messages,
|
||||
Consumer<ChatUsage> onUsage,
|
||||
Consumer<String> onToken,
|
||||
Runnable onComplete,
|
||||
Consumer<Throwable> onError) {
|
||||
@@ -75,7 +77,7 @@ public class StreamChatForLoreUseCase {
|
||||
.pageContext(pageContext)
|
||||
.build();
|
||||
|
||||
aiChatProvider.streamChat(request, onToken, onComplete, onError);
|
||||
aiChatProvider.streamChat(request, onUsage, onToken, onComplete, onError);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -105,11 +107,6 @@ public class StreamChatForLoreUseCase {
|
||||
? page.getValues()
|
||||
: Collections.emptyMap();
|
||||
|
||||
return PageContext.builder()
|
||||
.title(page.getTitle())
|
||||
.templateName(templateName)
|
||||
.templateFields(templateFields)
|
||||
.values(values)
|
||||
.build();
|
||||
return new PageContext(page.getTitle(), templateName, templateFields, values);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
package com.loremind.application.licensing;
|
||||
|
||||
import com.loremind.domain.licensing.License;
|
||||
import com.loremind.domain.licensing.LicenseClaims;
|
||||
import com.loremind.domain.licensing.LicenseSnapshot;
|
||||
import com.loremind.domain.licensing.LicenseStatus;
|
||||
import com.loremind.domain.licensing.RegistryCredentials;
|
||||
import com.loremind.domain.licensing.ports.JwtVerifier;
|
||||
import com.loremind.domain.licensing.ports.LicenseRelay;
|
||||
import com.loremind.domain.licensing.ports.LicenseRepository;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Service application pour la gestion de la licence Patreon.
|
||||
* <p>
|
||||
* Responsabilites :
|
||||
* <ul>
|
||||
* <li>Installer un nouveau JWT recu du relais (apres OAuth utilisateur)</li>
|
||||
* <li>Calculer le {@link LicenseStatus} courant en respectant la grace period</li>
|
||||
* <li>Renouveler le JWT avant expiration en appelant le relais</li>
|
||||
* <li>Activer/desactiver le toggle "canal beta" cote utilisateur</li>
|
||||
* <li>Distribuer les credentials registry pour le pull beta</li>
|
||||
* </ul>
|
||||
*/
|
||||
@Service
|
||||
public class LicenseService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(LicenseService.class);
|
||||
|
||||
private final LicenseRepository repository;
|
||||
private final JwtVerifier jwtVerifier;
|
||||
private final LicenseRelay relay;
|
||||
private final long gracePeriodSeconds;
|
||||
private final long refreshBeforeExpirySeconds;
|
||||
|
||||
public LicenseService(
|
||||
LicenseRepository repository,
|
||||
JwtVerifier jwtVerifier,
|
||||
LicenseRelay relay,
|
||||
@Value("${licensing.grace-period-days:14}") int gracePeriodDays,
|
||||
@Value("${licensing.refresh-before-expiry-days:2}") int refreshBeforeExpiryDays) {
|
||||
this.repository = repository;
|
||||
this.jwtVerifier = jwtVerifier;
|
||||
this.relay = relay;
|
||||
this.gracePeriodSeconds = (long) gracePeriodDays * 86_400L;
|
||||
this.refreshBeforeExpirySeconds = (long) refreshBeforeExpiryDays * 86_400L;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return true si le verifier est configure (cle publique presente).
|
||||
* L'UI peut masquer toute la section Patreon si false.
|
||||
*/
|
||||
public boolean isLicensingEnabled() {
|
||||
return jwtVerifier.isConfigured();
|
||||
}
|
||||
|
||||
/**
|
||||
* Genere ou retourne l'instance_id stable de cette installation.
|
||||
* Stocke dans la licence elle-meme. Si pas de licence, en cree un volatil
|
||||
* (sera persiste a la prochaine connexion).
|
||||
*/
|
||||
public String getOrCreateInstanceId() {
|
||||
return repository.findCurrent()
|
||||
.map(License::getInstanceId)
|
||||
.orElseGet(() -> "li-" + UUID.randomUUID());
|
||||
}
|
||||
|
||||
/**
|
||||
* Construit l'URL OAuth pour ouvrir dans le navigateur de l'utilisateur.
|
||||
*/
|
||||
public String buildConnectUrl() {
|
||||
return relay.buildConnectUrl(getOrCreateInstanceId());
|
||||
}
|
||||
|
||||
/**
|
||||
* Installe un JWT recu du relais (l'utilisateur l'a colle dans l'UI ou
|
||||
* recu via deep-link). Verifie la signature, extrait les claims, persiste.
|
||||
*/
|
||||
public LicenseSnapshot installToken(String rawJwt) throws InstallException {
|
||||
if (!jwtVerifier.isConfigured()) {
|
||||
throw new InstallException("Licensing feature not enabled (no public key configured)");
|
||||
}
|
||||
LicenseClaims claims;
|
||||
try {
|
||||
claims = jwtVerifier.verify(rawJwt);
|
||||
} catch (JwtVerifier.JwtVerificationException e) {
|
||||
throw new InstallException("Invalid JWT: " + e.getMessage());
|
||||
}
|
||||
|
||||
Instant now = Instant.now();
|
||||
if (claims.expiresAt().isBefore(now)) {
|
||||
throw new InstallException("JWT already expired");
|
||||
}
|
||||
|
||||
Optional<License> existing = repository.findCurrent();
|
||||
License toSave = License.builder()
|
||||
.id("current")
|
||||
.rawJwt(rawJwt)
|
||||
.patreonUserId(claims.subject())
|
||||
.tierId(claims.tierId())
|
||||
.instanceId(claims.instanceId())
|
||||
.issuedAt(claims.issuedAt())
|
||||
.expiresAt(claims.expiresAt())
|
||||
.lastRefreshAttemptAt(now)
|
||||
.lastRefreshSucceeded(true)
|
||||
// Au premier install, on active le canal beta par defaut.
|
||||
// Sur reinstall apres deconnexion, on respecte la valeur precedente.
|
||||
.betaChannelEnabled(existing.map(License::isBetaChannelEnabled).orElse(true))
|
||||
.createdAt(existing.map(License::getCreatedAt).orElse(now))
|
||||
.build();
|
||||
|
||||
License saved = repository.save(toSave);
|
||||
log.info("Patreon license installed for user={} tier={} expires={}",
|
||||
saved.getPatreonUserId(), saved.getTierId(), saved.getExpiresAt());
|
||||
return snapshotOf(saved, now);
|
||||
}
|
||||
|
||||
/**
|
||||
* Etat courant de la licence pour exposition UI / decision technique.
|
||||
*/
|
||||
public LicenseSnapshot getCurrentSnapshot() {
|
||||
Optional<License> opt = repository.findCurrent();
|
||||
if (opt.isEmpty()) return LicenseSnapshot.none();
|
||||
return snapshotOf(opt.get(), Instant.now());
|
||||
}
|
||||
|
||||
/**
|
||||
* Supprime la licence (deconnexion volontaire de Patreon par l'utilisateur).
|
||||
*/
|
||||
public void disconnect() {
|
||||
repository.deleteCurrent();
|
||||
log.info("Patreon license removed (user disconnect)");
|
||||
}
|
||||
|
||||
/**
|
||||
* Active ou desactive le canal beta. Necessite une licence valide ou en grace.
|
||||
*/
|
||||
public LicenseSnapshot setBetaChannelEnabled(boolean enabled) {
|
||||
License current = repository.findCurrent()
|
||||
.orElseThrow(() -> new IllegalStateException("No license installed"));
|
||||
current.setBetaChannelEnabled(enabled);
|
||||
License saved = repository.save(current);
|
||||
return snapshotOf(saved, Instant.now());
|
||||
}
|
||||
|
||||
/**
|
||||
* Tente un refresh si la licence est proche de l'expiration. Idempotent.
|
||||
* Appele par le daemon planifie + manuellement via l'UI ("Reessayer").
|
||||
*
|
||||
* @return true si un refresh a ete tente (avec ou sans succes)
|
||||
*/
|
||||
public boolean refreshIfNeeded() {
|
||||
Optional<License> opt = repository.findCurrent();
|
||||
if (opt.isEmpty()) return false;
|
||||
License current = opt.get();
|
||||
Instant now = Instant.now();
|
||||
long secondsUntilExpiry = Duration.between(now, current.getExpiresAt()).getSeconds();
|
||||
if (secondsUntilExpiry > refreshBeforeExpirySeconds) {
|
||||
return false;
|
||||
}
|
||||
return doRefresh(current, now);
|
||||
}
|
||||
|
||||
/**
|
||||
* Force un refresh immediat (bouton UI "Reessayer maintenant").
|
||||
*/
|
||||
public boolean forceRefresh() {
|
||||
return repository.findCurrent()
|
||||
.map(license -> doRefresh(license, Instant.now()))
|
||||
.orElse(false);
|
||||
}
|
||||
|
||||
private boolean doRefresh(License current, Instant now) {
|
||||
log.info("Refreshing Patreon license (current expires {})", current.getExpiresAt());
|
||||
try {
|
||||
String newJwt = relay.refreshToken(current.getRawJwt());
|
||||
LicenseClaims claims = jwtVerifier.verify(newJwt);
|
||||
|
||||
current.setRawJwt(newJwt);
|
||||
current.setIssuedAt(claims.issuedAt());
|
||||
current.setExpiresAt(claims.expiresAt());
|
||||
current.setTierId(claims.tierId());
|
||||
current.setLastRefreshAttemptAt(now);
|
||||
current.setLastRefreshSucceeded(true);
|
||||
repository.save(current);
|
||||
log.info("License refreshed successfully (new expiry {})", claims.expiresAt());
|
||||
return true;
|
||||
} catch (LicenseRelay.RelayException e) {
|
||||
current.setLastRefreshAttemptAt(now);
|
||||
current.setLastRefreshSucceeded(false);
|
||||
repository.save(current);
|
||||
if (e.getKind() == LicenseRelay.RelayErrorKind.REJECTED) {
|
||||
log.warn("Relay rejected refresh ({}): tier may have been cancelled", e.getMessage());
|
||||
} else {
|
||||
log.warn("Relay refresh transient failure ({}): {}", e.getKind(), e.getMessage());
|
||||
}
|
||||
return true;
|
||||
} catch (JwtVerifier.JwtVerificationException e) {
|
||||
current.setLastRefreshAttemptAt(now);
|
||||
current.setLastRefreshSucceeded(false);
|
||||
repository.save(current);
|
||||
log.error("Relay returned a JWT that fails verification: {}", e.getMessage());
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recupere les credentials registry pour pull du canal beta.
|
||||
* @return empty si pas de licence valide ou relais en echec
|
||||
*/
|
||||
public Optional<RegistryCredentials> fetchRegistryCredentials() {
|
||||
LicenseSnapshot snap = getCurrentSnapshot();
|
||||
if (snap.status() != LicenseStatus.VALID && snap.status() != LicenseStatus.GRACE) {
|
||||
return Optional.empty();
|
||||
}
|
||||
License current = repository.findCurrent().orElse(null);
|
||||
if (current == null) return Optional.empty();
|
||||
try {
|
||||
return Optional.of(relay.fetchRegistryCredentials(current.getRawJwt()));
|
||||
} catch (LicenseRelay.RelayException e) {
|
||||
log.warn("Cannot fetch registry credentials ({}): {}", e.getKind(), e.getMessage());
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
private LicenseSnapshot snapshotOf(License l, Instant now) {
|
||||
LicenseStatus status = computeStatus(l, now);
|
||||
return new LicenseSnapshot(
|
||||
status,
|
||||
l.getPatreonUserId(),
|
||||
l.getTierId(),
|
||||
l.getInstanceId(),
|
||||
l.getExpiresAt(),
|
||||
l.getLastRefreshAttemptAt(),
|
||||
l.isLastRefreshSucceeded(),
|
||||
l.isBetaChannelEnabled()
|
||||
);
|
||||
}
|
||||
|
||||
private LicenseStatus computeStatus(License l, Instant now) {
|
||||
if (l.getExpiresAt() == null) return LicenseStatus.NONE;
|
||||
if (now.isBefore(l.getExpiresAt())) return LicenseStatus.VALID;
|
||||
long secondsPastExpiry = Duration.between(l.getExpiresAt(), now).getSeconds();
|
||||
if (secondsPastExpiry <= gracePeriodSeconds) return LicenseStatus.GRACE;
|
||||
return LicenseStatus.EXPIRED;
|
||||
}
|
||||
|
||||
public static class InstallException extends Exception {
|
||||
public InstallException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,13 @@
|
||||
package com.loremind.application.lorecontext;
|
||||
|
||||
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.PageRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
@@ -16,11 +20,20 @@ import java.util.Optional;
|
||||
public class LoreNodeService {
|
||||
|
||||
private final LoreNodeRepository loreNodeRepository;
|
||||
private final PageRepository pageRepository;
|
||||
|
||||
public LoreNodeService(LoreNodeRepository loreNodeRepository) {
|
||||
public LoreNodeService(LoreNodeRepository loreNodeRepository, PageRepository pageRepository) {
|
||||
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
|
||||
* souhaitées (pattern Parameter Object) : évite les signatures qui gonflent
|
||||
@@ -68,7 +81,64 @@ public class LoreNodeService {
|
||||
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) {
|
||||
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);
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
import com.loremind.domain.campaigncontext.Campaign;
|
||||
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
|
||||
import com.loremind.domain.lorecontext.Lore;
|
||||
import com.loremind.domain.lorecontext.LoreNode;
|
||||
import com.loremind.domain.lorecontext.Page;
|
||||
import com.loremind.domain.lorecontext.Template;
|
||||
import com.loremind.domain.lorecontext.ports.LoreNodeRepository;
|
||||
import com.loremind.domain.lorecontext.ports.LoreRepository;
|
||||
import com.loremind.domain.lorecontext.ports.PageRepository;
|
||||
import com.loremind.domain.lorecontext.ports.TemplateRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
@@ -26,15 +33,28 @@ public class LoreService {
|
||||
private final LoreRepository loreRepository;
|
||||
private final LoreNodeRepository loreNodeRepository;
|
||||
private final PageRepository pageRepository;
|
||||
private final TemplateRepository templateRepository;
|
||||
private final CampaignRepository campaignRepository;
|
||||
|
||||
public LoreService(LoreRepository loreRepository,
|
||||
LoreNodeRepository loreNodeRepository,
|
||||
PageRepository pageRepository) {
|
||||
PageRepository pageRepository,
|
||||
TemplateRepository templateRepository,
|
||||
CampaignRepository campaignRepository) {
|
||||
this.loreRepository = loreRepository;
|
||||
this.loreNodeRepository = loreNodeRepository;
|
||||
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) {
|
||||
Lore lore = Lore.builder()
|
||||
.name(name)
|
||||
@@ -83,7 +103,54 @@ public class LoreService {
|
||||
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) {
|
||||
// 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);
|
||||
}
|
||||
|
||||
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 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/)
|
||||
private String themes; // Thèmes principaux explorés dans cet arc
|
||||
private String stakes; // Enjeux globaux pour les personnages
|
||||
|
||||
@@ -28,7 +28,18 @@ public class Campaign {
|
||||
*/
|
||||
private String loreId;
|
||||
|
||||
/**
|
||||
* Référence faible (weak reference) vers un GameSystem.
|
||||
* Nullable : une campagne peut être "générique" (pas de système de JDR déclaré).
|
||||
* Weak reference pour respecter la séparation des Bounded Contexts.
|
||||
*/
|
||||
private String gameSystemId;
|
||||
|
||||
public boolean isLinkedToLore() {
|
||||
return this.loreId != null && !this.loreId.isBlank();
|
||||
}
|
||||
|
||||
public boolean isLinkedToGameSystem() {
|
||||
return this.gameSystemId != null && !this.gameSystemId.isBlank();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,9 @@ public class Chapter {
|
||||
private String arcId; // Référence vers l'Arc parent
|
||||
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/)
|
||||
private String gmNotes; // Notes privées du MJ (non exportées vers FoundryVTT)
|
||||
private String playerObjectives; // Objectifs des joueurs dans ce chapitre
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.loremind.domain.campaigncontext;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* Fiche de personnage joueur (PJ) d'une campagne.
|
||||
* <p>
|
||||
* MVP : contenu markdown libre, l'utilisateur met ce qu'il veut (stats,
|
||||
* 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).
|
||||
* <p>
|
||||
* Scope strict PJ : les PNJ sont gérés par l'entité {@link Npc} dédiée
|
||||
* (entité distincte plutôt qu'enum PJ/PNJ — invariants métier divergents).
|
||||
* É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
|
||||
@Builder
|
||||
public class Character {
|
||||
|
||||
private String id;
|
||||
private String name;
|
||||
|
||||
/**
|
||||
* Contenu libre en markdown — stats + backstory + notes. Nullable à la création,
|
||||
* renseigné progressivement par le MJ.
|
||||
*/
|
||||
private String markdownContent;
|
||||
|
||||
/** Référence vers la Campaign parente. */
|
||||
private String campaignId;
|
||||
|
||||
/** Ordre d'affichage dans la liste des PJ de la campagne. */
|
||||
private int order;
|
||||
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
@@ -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 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 ===
|
||||
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)
|
||||
|
||||
@@ -1,31 +1,25 @@
|
||||
package com.loremind.domain.campaigncontext;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Value;
|
||||
import lombok.extern.jackson.Jacksonized;
|
||||
|
||||
/**
|
||||
* Value Object représentant une "sortie" narrative depuis une Scene.
|
||||
* Décrit un choix offert aux joueurs et la scène de destination associée.
|
||||
* <p>
|
||||
* Immuable (@Value) : pour "modifier" une branche on la remplace.
|
||||
* @Jacksonized : permet à Jackson (sérialisation JSON via le converter JPA)
|
||||
* de reconstruire l'objet en passant par le builder malgré l'absence de setters.
|
||||
* Record Java : immuable par construction, sans aucune dépendance technique
|
||||
* (pas de Lombok, pas de Jackson). Jackson 2.12+ sait sérialiser/désérialiser
|
||||
* les records nativement via le constructeur canonique — c'est ce dont
|
||||
* dépend le SceneBranchListJsonConverter pour le stockage JSONB.
|
||||
* <p>
|
||||
* Règle métier : targetSceneId DOIT pointer vers une Scene du MÊME Chapter
|
||||
* (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
|
||||
@Builder
|
||||
@Jacksonized
|
||||
public class SceneBranch {
|
||||
public record SceneBranch(String label, String targetSceneId, String condition) {
|
||||
|
||||
/** Libellé du choix (ex: "Si les joueurs attaquent le garde"). */
|
||||
String label;
|
||||
|
||||
/** Id de la Scene de destination, intra-chapitre uniquement. */
|
||||
String targetSceneId;
|
||||
|
||||
/** Notes MJ privées sur la condition de déclenchement (optionnel). */
|
||||
String condition;
|
||||
/** Raccourci pour construire une branche sans condition (cas le plus courant). */
|
||||
public static SceneBranch of(String label, String targetSceneId) {
|
||||
return new SceneBranch(label, targetSceneId, null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.loremind.domain.campaigncontext.ports;
|
||||
|
||||
import com.loremind.domain.campaigncontext.Character;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Port de sortie pour la persistance des fiches de personnages (PJ).
|
||||
*/
|
||||
public interface CharacterRepository {
|
||||
|
||||
Character save(Character character);
|
||||
|
||||
Optional<Character> findById(String id);
|
||||
|
||||
List<Character> findByCampaignId(String campaignId);
|
||||
|
||||
void deleteById(String id);
|
||||
|
||||
boolean existsById(String id);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.loremind.domain.conversationcontext;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Agregat d'une conversation de chat IA persistee.
|
||||
*
|
||||
* Une conversation est ancree sur exactement un niveau de contexte :
|
||||
* - un Lore (optionnellement une page precise)
|
||||
* - une Campagne (optionnellement une entite narrative : arc/chapitre/scene)
|
||||
*
|
||||
* C'est cet ancrage qui permet au drawer de filtrer les conversations
|
||||
* a afficher dans la sidebar selon l'ecran en cours.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class Conversation {
|
||||
|
||||
private String id;
|
||||
private String title;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
/** Un seul des deux est non-null. */
|
||||
private String loreId;
|
||||
private String campaignId;
|
||||
|
||||
/**
|
||||
* Type d'entite focus, null si la conversation est ancree au niveau
|
||||
* Lore/Campagne racine (pas sur une page/scene precise).
|
||||
* Valeurs : "page", "arc", "chapter", "scene", "character".
|
||||
*/
|
||||
private String entityType;
|
||||
private String entityId;
|
||||
|
||||
@Builder.Default
|
||||
private List<ConversationMessage> messages = new ArrayList<>();
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.loremind.domain.conversationcontext;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* Un message persiste d'une conversation.
|
||||
*
|
||||
* Distinct de {@link com.loremind.domain.generationcontext.ChatMessage}
|
||||
* qui reste un simple record role+content pour le streaming LLM. Ici
|
||||
* on ajoute id et horodatage, necessaires pour l'affichage / l'ordre.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class ConversationMessage {
|
||||
|
||||
private String id;
|
||||
/** "user" | "assistant" | "system". */
|
||||
private String role;
|
||||
private String content;
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.loremind.domain.conversationcontext.ports;
|
||||
|
||||
import com.loremind.domain.conversationcontext.Conversation;
|
||||
import com.loremind.domain.conversationcontext.ConversationMessage;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Port de persistance des conversations de chat IA.
|
||||
*
|
||||
* Les methodes de lecture par contexte acceptent des filtres nullables :
|
||||
* - `loreId` OU `campaignId` doit etre non-null (mais pas les deux)
|
||||
* - `entityType` + `entityId` : soit tous les deux null (niveau racine),
|
||||
* soit tous les deux non-null (niveau entite precise).
|
||||
*/
|
||||
public interface ConversationRepository {
|
||||
|
||||
Conversation save(Conversation conversation);
|
||||
|
||||
Optional<Conversation> findById(String id);
|
||||
|
||||
/**
|
||||
* Liste les conversations filtrees par contexte strict, triees par
|
||||
* updatedAt desc. Les messages ne sont PAS chargees (liste vide) pour
|
||||
* garder la payload legere — la sidebar n'affiche que les titres.
|
||||
*/
|
||||
List<Conversation> findByContext(
|
||||
String loreId,
|
||||
String campaignId,
|
||||
String entityType,
|
||||
String entityId);
|
||||
|
||||
void deleteById(String id);
|
||||
|
||||
/**
|
||||
* Ajoute un message a une conversation existante. Met a jour updatedAt
|
||||
* de la conversation parent. Renvoie le message persiste (avec id + ts).
|
||||
*/
|
||||
ConversationMessage appendMessage(String conversationId, ConversationMessage message);
|
||||
|
||||
/** Rename atomique — ne touche pas aux messages. */
|
||||
void updateTitle(String conversationId, String title);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.loremind.domain.conversationcontext.ports;
|
||||
|
||||
import com.loremind.domain.conversationcontext.ConversationMessage;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Port : generation d'un titre court a partir des premiers echanges d'une
|
||||
* conversation. Implemente via un appel Brain /summarize/conversation-title.
|
||||
*/
|
||||
public interface ConversationTitleGenerator {
|
||||
|
||||
/** Renvoie un titre en francais (4-7 mots max). Jamais null ni vide. */
|
||||
String generate(List<ConversationMessage> firstMessages);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.loremind.domain.gamesystemcontext;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* Entité de domaine représentant un GameSystem (système de JDR).
|
||||
* <p>
|
||||
* Porte les règles d'un système (D&D, Nimble, Pathfinder, homebrew...) sous forme
|
||||
* d'un markdown monolithique structuré par titres H2. Les sections sont extraites
|
||||
* à la volée lors de l'injection dans les prompts IA (cf. GameSystemContextSelector).
|
||||
* <p>
|
||||
* {@code author} et {@code isPublic} sont des champs pensés pour un futur marketplace
|
||||
* de rulesets partagés — non exploités au MVP mais persistés dès maintenant pour
|
||||
* éviter une migration ultérieure.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
public class GameSystem {
|
||||
|
||||
private String id;
|
||||
private String name;
|
||||
private String description;
|
||||
|
||||
/** Markdown monolithique. Sections découpées par titres H2 (## Combat, ## Classes, etc.). */
|
||||
private String rulesMarkdown;
|
||||
|
||||
/** Auteur déclaré — futur marketplace. Nullable. */
|
||||
private String author;
|
||||
|
||||
/** Flag de partage — futur marketplace. False par défaut. */
|
||||
private boolean isPublic;
|
||||
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.loremind.domain.gamesystemcontext;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Intent de génération utilisé pour sélectionner les sections d'un GameSystem
|
||||
* à injecter dans le prompt IA.
|
||||
* <p>
|
||||
* Chaque intent porte une liste d'alias (case-insensitive, comparaison par
|
||||
* {@code contains}) utilisée pour matcher les titres H2 du markdown de règles.
|
||||
* <p>
|
||||
* MVP : mapping codé en dur. Évoluera vers un mapping configurable par
|
||||
* l'utilisateur dans l'éditeur de GameSystem (futur marketplace).
|
||||
*/
|
||||
public enum GenerationIntent {
|
||||
|
||||
/** Scène (combat / rencontre) : règles de résolution + format de stat block. */
|
||||
SCENE(Set.of("combat", "monstre", "monster")),
|
||||
|
||||
/** Chapitre (segment narratif) : règles de combat + archétypes pour PNJ. */
|
||||
CHAPTER(Set.of("combat", "classe", "class")),
|
||||
|
||||
/** Arc (structure narrative longue) : pas de règles spécifiques — toutes. */
|
||||
ARC(Set.of()),
|
||||
|
||||
/** Fallback : toutes les sections (intent inconnu). */
|
||||
GENERIC(Set.of());
|
||||
|
||||
private final Set<String> sectionAliases;
|
||||
|
||||
GenerationIntent(Set<String> sectionAliases) {
|
||||
this.sectionAliases = sectionAliases;
|
||||
}
|
||||
|
||||
public Set<String> getSectionAliases() {
|
||||
return sectionAliases;
|
||||
}
|
||||
|
||||
/** True si l'intent veut toutes les sections (pas de filtre). */
|
||||
public boolean matchesAllSections() {
|
||||
return sectionAliases.isEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* Mappe un entityType de NarrativeEntityContext ("arc"/"chapter"/"scene")
|
||||
* vers l'intent correspondant. Tout le reste (null, inconnu) tombe sur GENERIC.
|
||||
*/
|
||||
public static GenerationIntent fromNarrativeEntityType(String entityType) {
|
||||
if (entityType == null) return GENERIC;
|
||||
return switch (entityType.toLowerCase()) {
|
||||
case "scene" -> SCENE;
|
||||
case "chapter" -> CHAPTER;
|
||||
case "arc" -> ARC;
|
||||
default -> GENERIC;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.loremind.domain.gamesystemcontext.ports;
|
||||
|
||||
import com.loremind.domain.gamesystemcontext.GameSystem;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Port de sortie pour la persistance des GameSystems.
|
||||
*/
|
||||
public interface GameSystemRepository {
|
||||
|
||||
GameSystem save(GameSystem gameSystem);
|
||||
|
||||
Optional<GameSystem> findById(String id);
|
||||
|
||||
List<GameSystem> findAll();
|
||||
|
||||
void deleteById(String id);
|
||||
|
||||
boolean existsById(String id);
|
||||
|
||||
List<GameSystem> searchByName(String query);
|
||||
}
|
||||
@@ -1,9 +1,5 @@
|
||||
package com.loremind.domain.generationcontext;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Singular;
|
||||
import lombok.Value;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
@@ -22,55 +18,72 @@ import java.util.List;
|
||||
* <p>
|
||||
* La liste `arcs` préserve l'ordre narratif (tri sur `order` ascendant
|
||||
* 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
|
||||
@Builder
|
||||
public class CampaignStructuralContext {
|
||||
public record CampaignStructuralContext(
|
||||
String campaignName,
|
||||
String campaignDescription,
|
||||
List<ArcSummary> arcs,
|
||||
List<CharacterSummary> characters,
|
||||
List<NpcSummary> npcs) {
|
||||
|
||||
String campaignName;
|
||||
String campaignDescription;
|
||||
@Singular List<ArcSummary> arcs;
|
||||
/**
|
||||
* Résumé d'un PJ : nom + snippet court du markdown.
|
||||
* Pas le markdown complet pour maîtriser le coût token (chaque campagne
|
||||
* peut avoir 4-6 PJ × potentiellement 1-2k tokens/fiche = trop lourd).
|
||||
* La fiche complète n'est injectée que si le PJ est l'entité focus
|
||||
* (via NarrativeEntityContext, entity_type="character").
|
||||
*/
|
||||
public record CharacterSummary(String name, String snippet) {
|
||||
}
|
||||
|
||||
/** Résumé d'un arc : nom + description courte + ses chapitres. */
|
||||
@Value
|
||||
@Builder
|
||||
public static class ArcSummary {
|
||||
String name;
|
||||
String description;
|
||||
/** Nombre d'illustrations attachees a cet arc (pour hint dans le prompt IA). */
|
||||
int illustrationCount;
|
||||
@Singular List<ChapterSummary> chapters;
|
||||
/**
|
||||
* Résumé d'un PNJ : symétrique à {@link CharacterSummary}.
|
||||
* Snippet court extrait du markdown — la fiche complète est réservée
|
||||
* à un usage focus (à venir, entity_type="npc").
|
||||
*/
|
||||
public record NpcSummary(String name, String snippet) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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. */
|
||||
@Value
|
||||
@Builder
|
||||
public static class ChapterSummary {
|
||||
String name;
|
||||
String description;
|
||||
int illustrationCount;
|
||||
@Singular List<SceneSummary> scenes;
|
||||
public record ChapterSummary(
|
||||
String name,
|
||||
String description,
|
||||
int illustrationCount,
|
||||
List<SceneSummary> scenes) {
|
||||
}
|
||||
|
||||
/** Résumé d'une scène : nom + description courte + branches narratives. */
|
||||
@Value
|
||||
@Builder
|
||||
public static class SceneSummary {
|
||||
String name;
|
||||
String description;
|
||||
int illustrationCount;
|
||||
@Singular List<BranchHint> branches;
|
||||
public record SceneSummary(
|
||||
String name,
|
||||
String description,
|
||||
int illustrationCount,
|
||||
List<BranchHint> branches) {
|
||||
}
|
||||
|
||||
/** Indice d'une branche narrative vers une autre scène du même chapitre. */
|
||||
@Value
|
||||
@Builder
|
||||
public static class BranchHint {
|
||||
/** Libellé du choix joueur (ex: "Si les joueurs attaquent le garde"). */
|
||||
String label;
|
||||
/** Nom de la scène cible (résolu depuis targetSceneId côté builder). */
|
||||
String targetSceneName;
|
||||
/** Condition MJ privée (optionnel). */
|
||||
String condition;
|
||||
/**
|
||||
* Indice d'une branche narrative vers une autre scène du même chapitre.
|
||||
*
|
||||
* @param label 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).
|
||||
* @param condition Condition MJ privée (optionnel).
|
||||
*/
|
||||
public record BranchHint(String label, String targetSceneName, String condition) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
package com.loremind.domain.generationcontext;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Value;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
@@ -21,22 +18,74 @@ import java.util.List;
|
||||
* Un chat Lore ne reçoit JAMAIS de campaignContext : un Lore ne voit pas
|
||||
* ses campagnes (asymétrie métier : la campagne est l'emprunteur du Lore,
|
||||
* pas l'inverse).
|
||||
* <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
|
||||
@Builder
|
||||
public class ChatRequest {
|
||||
public record ChatRequest(
|
||||
List<ChatMessage> messages,
|
||||
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. */
|
||||
LoreStructuralContext loreContext;
|
||||
/** Builder fluide : permet d'omettre les contextes non pertinents. */
|
||||
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). */
|
||||
PageContext pageContext;
|
||||
private Builder() {}
|
||||
|
||||
/** Optionnel : carte narrative d'une Campagne (chat Campagne uniquement). */
|
||||
CampaignStructuralContext campaignContext;
|
||||
public Builder messages(List<ChatMessage> messages) {
|
||||
this.messages = messages;
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Optionnel : entité narrative en cours d'édition (arc/chapter/scene). */
|
||||
NarrativeEntityContext narrativeEntity;
|
||||
public Builder loreContext(LoreStructuralContext loreContext) {
|
||||
this.loreContext = loreContext;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder pageContext(PageContext pageContext) {
|
||||
this.pageContext = pageContext;
|
||||
return this;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.loremind.domain.generationcontext;
|
||||
|
||||
/**
|
||||
* Instantané d'occupation de la fenêtre de contexte à l'instant t du chat.
|
||||
* <p>
|
||||
* Émis une fois par tour de chat (juste avant le streaming des tokens) pour
|
||||
* alimenter la jauge de contexte côté frontend. Les unités sont des tokens
|
||||
* (approximés via tiktoken côté Brain — ±10% vs le tokenizer réel du modèle).
|
||||
*
|
||||
* @param system tokens consommés par le system prompt (contextes Lore/campagne injectés)
|
||||
* @param history tokens consommés par l'historique de la conversation (hors dernier message)
|
||||
* @param current tokens du dernier message utilisateur en attente de réponse
|
||||
* @param max taille maximale configurée de la fenêtre de contexte
|
||||
*/
|
||||
public record ChatUsage(int system, int history, int current, int max) {
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.loremind.domain.generationcontext;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Value Object représentant les règles de JDR injectées dans un prompt IA.
|
||||
* <p>
|
||||
* Contient uniquement les sections pertinentes pour l'intent de génération
|
||||
* en cours (sélection effectuée par GameSystemContextBuilder). Les sections
|
||||
* 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.
|
||||
*/
|
||||
public record GameSystemContext(
|
||||
String systemName,
|
||||
String systemDescription,
|
||||
Map<String, String> sections) {
|
||||
}
|
||||
@@ -1,8 +1,5 @@
|
||||
package com.loremind.domain.generationcontext;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Value;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
@@ -10,19 +7,16 @@ import java.util.List;
|
||||
* pour remplir une Page à partir d'un Template.
|
||||
* <p>
|
||||
* Équivalent Java du PageGenerationContext Python (brain/app/domain/models.py).
|
||||
* Entité pure du domaine : aucune dépendance technique.
|
||||
* <p>
|
||||
* Immuable via @Value (Lombok) : pas de setters, tous les champs final.
|
||||
* C'est un DTO de domaine entrant dans le port AiProvider.
|
||||
* Record Java : pur domaine, aucune dépendance technique.
|
||||
*
|
||||
* @param templateFields Champs à générer (clés attendues dans la réponse).
|
||||
* @param folderName Nom du LoreNode parent (ex: "PNJ", "Lieux").
|
||||
*/
|
||||
@Value
|
||||
@Builder
|
||||
public class GenerationContext {
|
||||
|
||||
String loreName;
|
||||
String loreDescription;
|
||||
String folderName; // Nom du LoreNode parent (ex: "PNJ", "Lieux")
|
||||
String templateName;
|
||||
List<String> templateFields; // Champs à générer (clés attendues dans la réponse)
|
||||
String pageTitle;
|
||||
public record GenerationContext(
|
||||
String loreName,
|
||||
String loreDescription,
|
||||
String folderName,
|
||||
String templateName,
|
||||
List<String> templateFields,
|
||||
String pageTitle) {
|
||||
}
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
package com.loremind.domain.generationcontext;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Singular;
|
||||
import lombok.Value;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@@ -16,15 +12,14 @@ import java.util.Map;
|
||||
* <p>
|
||||
* 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).
|
||||
* <p>
|
||||
* Record Java : pur domaine, aucune dépendance technique.
|
||||
*/
|
||||
@Value
|
||||
@Builder
|
||||
public class LoreStructuralContext {
|
||||
|
||||
String loreName;
|
||||
String loreDescription;
|
||||
Map<String, List<PageSummary>> folders;
|
||||
@Singular List<String> tags;
|
||||
public record LoreStructuralContext(
|
||||
String loreName,
|
||||
String loreDescription,
|
||||
Map<String, List<PageSummary>> folders,
|
||||
List<String> tags) {
|
||||
|
||||
/**
|
||||
* 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
|
||||
* restent confinés à leur page d'édition).
|
||||
*/
|
||||
@Value
|
||||
@Builder
|
||||
public static class PageSummary {
|
||||
String title;
|
||||
String templateName;
|
||||
Map<String, String> values;
|
||||
List<String> tags;
|
||||
List<String> relatedPageTitles;
|
||||
public record PageSummary(
|
||||
String title,
|
||||
String templateName,
|
||||
Map<String, String> values,
|
||||
List<String> tags,
|
||||
List<String> relatedPageTitles) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
package com.loremind.domain.generationcontext;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Value;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
@@ -17,13 +14,11 @@ import java.util.Map;
|
||||
* `fields` associe le nom d'un champ (ex: "themes", "playerNarration")
|
||||
* à sa valeur actuelle (chaîne vide si non renseigné). Utiliser une
|
||||
* LinkedHashMap à la construction pour un prompt lisible (ordre préservé).
|
||||
*
|
||||
* @param entityType "arc", "chapter" ou "scene" — utilisé pour libeller le bloc du prompt.
|
||||
*/
|
||||
@Value
|
||||
@Builder
|
||||
public class NarrativeEntityContext {
|
||||
|
||||
/** "arc", "chapter" ou "scene" — utilisé pour libeller le bloc du prompt. */
|
||||
String entityType;
|
||||
String title;
|
||||
Map<String, String> fields;
|
||||
public record NarrativeEntityContext(
|
||||
String entityType,
|
||||
String title,
|
||||
Map<String, String> fields) {
|
||||
}
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
package com.loremind.domain.generationcontext;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Value;
|
||||
|
||||
import java.util.List;
|
||||
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
|
||||
* sur d'autres pages/templates.
|
||||
* <p>
|
||||
* Object de valeur immuable, pur domaine — aucune dépendance technique.
|
||||
* Record Java : immuable, pur domaine, aucune dépendance technique.
|
||||
*/
|
||||
@Value
|
||||
@Builder
|
||||
public class PageContext {
|
||||
|
||||
String title;
|
||||
String templateName;
|
||||
List<String> templateFields;
|
||||
Map<String, String> values;
|
||||
public record PageContext(
|
||||
String title,
|
||||
String templateName,
|
||||
List<String> templateFields,
|
||||
Map<String, String> values) {
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.loremind.domain.generationcontext.ports;
|
||||
|
||||
import com.loremind.domain.generationcontext.ChatRequest;
|
||||
import com.loremind.domain.generationcontext.ChatUsage;
|
||||
|
||||
import java.util.function.Consumer;
|
||||
|
||||
@@ -26,6 +27,10 @@ public interface AiChatProvider {
|
||||
* HTTP côté controller SSE).
|
||||
*
|
||||
* @param request messages + contexte Lore
|
||||
* @param onUsage invoqué une fois au début du stream avec le bilan
|
||||
* d'occupation de la fenêtre de contexte (tokens system /
|
||||
* history / current / max). Peut ne jamais être invoqué
|
||||
* si le provider ne supporte pas le comptage.
|
||||
* @param onToken invoqué à chaque token reçu du LLM (peut être appelé
|
||||
* de nombreuses fois)
|
||||
* @param onComplete invoqué une fois le stream terminé avec succès
|
||||
@@ -34,6 +39,7 @@ public interface AiChatProvider {
|
||||
*/
|
||||
void streamChat(
|
||||
ChatRequest request,
|
||||
Consumer<ChatUsage> onUsage,
|
||||
Consumer<String> onToken,
|
||||
Runnable onComplete,
|
||||
Consumer<Throwable> onError
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.loremind.domain.licensing;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/**
|
||||
* Licence Patreon installee dans cette instance LoreMind.
|
||||
* <p>
|
||||
* Singleton (une seule licence par instance, identifiee logiquement par
|
||||
* {@code id = "current"}). Contient le JWT brut emis par le relais OAuth
|
||||
* + les claims extraits a la verification, plus l'etat operationnel
|
||||
* (derniere tentative de refresh, succes/echec).
|
||||
* <p>
|
||||
* <b>Note securite :</b> {@link #rawJwt} est stocke tel quel ; sa signature
|
||||
* Ed25519 est verifiee a chaque lecture. Pas besoin de chiffrement au repos
|
||||
* supplementaire — un attaquant qui a acces a la base a deja l'instance,
|
||||
* et le JWT ne donne aucun pouvoir au-dela du canal beta de cette instance.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
public class License {
|
||||
|
||||
private String id;
|
||||
|
||||
private String rawJwt;
|
||||
|
||||
private String patreonUserId;
|
||||
|
||||
private String tierId;
|
||||
|
||||
private String instanceId;
|
||||
|
||||
private Instant issuedAt;
|
||||
|
||||
private Instant expiresAt;
|
||||
|
||||
private Instant lastRefreshAttemptAt;
|
||||
|
||||
private boolean lastRefreshSucceeded;
|
||||
|
||||
private boolean betaChannelEnabled;
|
||||
|
||||
private Instant createdAt;
|
||||
|
||||
private Instant updatedAt;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.loremind.domain.licensing;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/**
|
||||
* Claims extraits d'un JWT licence apres verification de signature.
|
||||
* Immuable.
|
||||
*/
|
||||
public record LicenseClaims(
|
||||
String subject,
|
||||
String tierId,
|
||||
String instanceId,
|
||||
Instant issuedAt,
|
||||
Instant expiresAt
|
||||
) {}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.loremind.domain.licensing;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/**
|
||||
* Vue immuable de la licence pour exposition vers les couches superieures.
|
||||
* Decouple le domaine du DTO web et permet de calculer le {@link LicenseStatus}
|
||||
* a un instant donne sans muter l'entite.
|
||||
*/
|
||||
public record LicenseSnapshot(
|
||||
LicenseStatus status,
|
||||
String patreonUserId,
|
||||
String tierId,
|
||||
String instanceId,
|
||||
Instant expiresAt,
|
||||
Instant lastRefreshAttemptAt,
|
||||
boolean lastRefreshSucceeded,
|
||||
boolean betaChannelEnabled
|
||||
) {
|
||||
public static LicenseSnapshot none() {
|
||||
return new LicenseSnapshot(LicenseStatus.NONE, null, null, null, null, null, false, false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.loremind.domain.licensing;
|
||||
|
||||
/**
|
||||
* Etat operationnel de la licence vis-a-vis de l'acces beta.
|
||||
* <p>
|
||||
* Calcule a partir de la presence de licence + son JWT exp + grace period.
|
||||
* <ul>
|
||||
* <li>{@link #NONE} : aucune licence installee</li>
|
||||
* <li>{@link #VALID} : JWT non expire, acces beta autorise</li>
|
||||
* <li>{@link #GRACE} : JWT expire mais dans la periode de tolerance ;
|
||||
* acces beta toujours autorise, l'UI doit avertir</li>
|
||||
* <li>{@link #EXPIRED} : au-dela de la grace period, acces beta refuse</li>
|
||||
* <li>{@link #UNVERIFIABLE} : JWT impossible a verifier (cle publique manquante,
|
||||
* signature invalide, claims malformes) — traite comme NONE pour la securite</li>
|
||||
* </ul>
|
||||
*/
|
||||
public enum LicenseStatus {
|
||||
NONE,
|
||||
VALID,
|
||||
GRACE,
|
||||
EXPIRED,
|
||||
UNVERIFIABLE
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.loremind.domain.licensing;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/**
|
||||
* Credentials de pull pour un registry Docker, distribues par le relais
|
||||
* apres verification d'un JWT licence valide.
|
||||
* <p>
|
||||
* {@code expiresAt} peut etre {@code null} si le credential est statique
|
||||
* (cas du PAT GHCR partage en MVP) ; sinon, l'instance doit re-demander
|
||||
* de nouveaux credentials avant cette date.
|
||||
*/
|
||||
public record RegistryCredentials(
|
||||
String registry,
|
||||
String username,
|
||||
String password,
|
||||
Instant expiresAt
|
||||
) {}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.loremind.domain.licensing.ports;
|
||||
|
||||
import com.loremind.domain.licensing.RegistryCredentials;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* Port de sortie : ecriture du docker config.json partage avec Watchtower.
|
||||
* <p>
|
||||
* Le fichier sert a Watchtower pour s'authentifier au registry prive (GHCR)
|
||||
* lors du pull des images du canal beta. Volume Docker {@code docker-config}
|
||||
* monte sur Core (en ecriture) et sur Watchtower (en lecture, via la variable
|
||||
* {@code DOCKER_CONFIG}).
|
||||
*/
|
||||
public interface DockerConfigWriter {
|
||||
|
||||
/**
|
||||
* Ecrit ou met a jour les credentials pour le registry indique.
|
||||
* Cree le fichier s'il n'existe pas, conserve les autres registries deja
|
||||
* presents (en theorie : aucun, mais defensif).
|
||||
*/
|
||||
void writeCredentials(RegistryCredentials credentials) throws IOException;
|
||||
|
||||
/**
|
||||
* Supprime le fichier de credentials. Appele quand la licence est invalidee
|
||||
* ou que le toggle beta passe a OFF.
|
||||
*/
|
||||
void clear() throws IOException;
|
||||
|
||||
/**
|
||||
* @return true si le fichier de creds existe actuellement.
|
||||
*/
|
||||
boolean isPresent();
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.loremind.domain.licensing.ports;
|
||||
|
||||
import com.loremind.domain.licensing.LicenseClaims;
|
||||
|
||||
/**
|
||||
* Port de sortie : verification de signature et extraction des claims
|
||||
* d'un JWT emis par le relais.
|
||||
* <p>
|
||||
* Implemente cote infrastructure avec la cle publique Ed25519 embarquee
|
||||
* (SPKI PEM via configuration {@code licensing.jwt.public-key}).
|
||||
*/
|
||||
public interface JwtVerifier {
|
||||
|
||||
/**
|
||||
* Verifie la signature, l'issuer, l'audience et l'expiration du JWT.
|
||||
* @throws JwtVerificationException si la signature est invalide ou les claims malformes
|
||||
*/
|
||||
LicenseClaims verify(String rawJwt) throws JwtVerificationException;
|
||||
|
||||
/**
|
||||
* @return true si la cle publique est configuree et utilisable.
|
||||
* Permet a l'application de masquer la feature licensing si pas configuree.
|
||||
*/
|
||||
boolean isConfigured();
|
||||
|
||||
class JwtVerificationException extends Exception {
|
||||
public JwtVerificationException(String message) {
|
||||
super(message);
|
||||
}
|
||||
public JwtVerificationException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.loremind.domain.licensing.ports;
|
||||
|
||||
import com.loremind.domain.licensing.RegistryCredentials;
|
||||
|
||||
/**
|
||||
* Port de sortie vers le service relais OAuth Patreon.
|
||||
* Encapsule les appels HTTP : refresh JWT et fetch registry credentials.
|
||||
*/
|
||||
public interface LicenseRelay {
|
||||
|
||||
/**
|
||||
* Demande au relais l'URL OAuth a ouvrir pour connecter le compte Patreon.
|
||||
*/
|
||||
String buildConnectUrl(String instanceId);
|
||||
|
||||
/**
|
||||
* Demande au relais de renouveler un JWT existant. Le relais re-verifie
|
||||
* le tier Patreon de l'utilisateur ; renvoie un nouveau JWT si toujours
|
||||
* actif, ou leve {@link RelayException} sinon.
|
||||
*/
|
||||
String refreshToken(String currentJwt) throws RelayException;
|
||||
|
||||
/**
|
||||
* Demande au relais les credentials de pull du registry beta.
|
||||
*/
|
||||
RegistryCredentials fetchRegistryCredentials(String currentJwt) throws RelayException;
|
||||
|
||||
/**
|
||||
* Erreurs distinctes emises par le relais. Permet au service application
|
||||
* de differencier "tier expire" (action utilisateur) de "relais down"
|
||||
* (action transitoire, garde la grace period).
|
||||
*/
|
||||
class RelayException extends Exception {
|
||||
private final RelayErrorKind kind;
|
||||
|
||||
public RelayException(RelayErrorKind kind, String message) {
|
||||
super(message);
|
||||
this.kind = kind;
|
||||
}
|
||||
|
||||
public RelayException(RelayErrorKind kind, String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
this.kind = kind;
|
||||
}
|
||||
|
||||
public RelayErrorKind getKind() {
|
||||
return kind;
|
||||
}
|
||||
}
|
||||
|
||||
enum RelayErrorKind {
|
||||
/** Le relais est joignable mais refuse : tier non actif, JWT trop ancien, etc. */
|
||||
REJECTED,
|
||||
/** Le relais a renvoye un JWT mais il est invalide / non parsable. */
|
||||
BAD_RESPONSE,
|
||||
/** Le relais est injoignable / 5xx / timeout. */
|
||||
TRANSIENT
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.loremind.domain.licensing.ports;
|
||||
|
||||
import com.loremind.domain.licensing.License;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Port de sortie pour la persistance de la licence installee.
|
||||
* <p>
|
||||
* Une seule licence par instance ({@code id = "current"} par convention).
|
||||
*/
|
||||
public interface LicenseRepository {
|
||||
|
||||
Optional<License> findCurrent();
|
||||
|
||||
License save(License license);
|
||||
|
||||
void deleteCurrent();
|
||||
}
|
||||
@@ -1,16 +1,7 @@
|
||||
package com.loremind.infrastructure.ai;
|
||||
|
||||
import com.loremind.domain.generationcontext.CampaignStructuralContext;
|
||||
import com.loremind.domain.generationcontext.CampaignStructuralContext.ArcSummary;
|
||||
import com.loremind.domain.generationcontext.CampaignStructuralContext.BranchHint;
|
||||
import com.loremind.domain.generationcontext.CampaignStructuralContext.ChapterSummary;
|
||||
import com.loremind.domain.generationcontext.CampaignStructuralContext.SceneSummary;
|
||||
import com.loremind.domain.generationcontext.ChatMessage;
|
||||
import com.loremind.domain.generationcontext.ChatRequest;
|
||||
import com.loremind.domain.generationcontext.LoreStructuralContext;
|
||||
import com.loremind.domain.generationcontext.LoreStructuralContext.PageSummary;
|
||||
import com.loremind.domain.generationcontext.NarrativeEntityContext;
|
||||
import com.loremind.domain.generationcontext.PageContext;
|
||||
import com.loremind.domain.generationcontext.ChatUsage;
|
||||
import com.loremind.domain.generationcontext.ports.AiChatProvider;
|
||||
import com.loremind.domain.generationcontext.ports.AiProviderException;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
@@ -22,25 +13,21 @@ import org.springframework.web.reactive.function.client.WebClient;
|
||||
import reactor.core.publisher.Flux;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Adapter de sortie (Architecture Hexagonale) : implémente AiChatProvider
|
||||
* en appelant le Brain Python via WebClient + SSE (Server-Sent Events).
|
||||
* <p>
|
||||
* Responsabilités :
|
||||
* 1. Traduire ChatRequest (domaine) -> JSON attendu par /chat/stream.
|
||||
* Sérialise lore_context, page_context, campaign_context et
|
||||
* narrative_entity de façon conditionnelle selon le scénario d'appel
|
||||
* (chat Lore / chat Lore focalisé page / chat Campagne / chat Campagne
|
||||
* focalisé arc-chapter-scene).
|
||||
* 2. Consommer le flux SSE token par token.
|
||||
* 3. Invoquer onToken / onComplete / onError au bon moment.
|
||||
* 4. Traduire toute erreur technique en AiProviderException.
|
||||
* Responsabilités (après extraction) :
|
||||
* 1. Transport HTTP + consommation du flux SSE.
|
||||
* 2. Dispatch des évènements SSE (data / done / error / usage).
|
||||
* 3. Traduction des erreurs techniques en AiProviderException.
|
||||
* <p>
|
||||
* Les responsabilités auxiliaires sont déléguées :
|
||||
* - Construction du payload JSON : {@link BrainChatPayloadBuilder}.
|
||||
* - Parsing des payloads SSE : {@link BrainSseParser}.
|
||||
* <p>
|
||||
* Le domaine ne voit JAMAIS WebClient, Flux, ni la moindre URL.
|
||||
*/
|
||||
@@ -52,21 +39,28 @@ public class BrainAiChatClient implements AiChatProvider {
|
||||
new ParameterizedTypeReference<>() {};
|
||||
|
||||
private final WebClient webClient;
|
||||
private final BrainChatPayloadBuilder payloadBuilder;
|
||||
private final BrainSseParser sseParser;
|
||||
|
||||
public BrainAiChatClient(
|
||||
WebClient.Builder builder,
|
||||
@Value("${brain.base-url}") String baseUrl) {
|
||||
@Value("${brain.base-url}") String baseUrl,
|
||||
BrainChatPayloadBuilder payloadBuilder,
|
||||
BrainSseParser sseParser) {
|
||||
this.webClient = builder.baseUrl(baseUrl).build();
|
||||
this.payloadBuilder = payloadBuilder;
|
||||
this.sseParser = sseParser;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void streamChat(
|
||||
ChatRequest request,
|
||||
Consumer<ChatUsage> onUsage,
|
||||
Consumer<String> onToken,
|
||||
Runnable onComplete,
|
||||
Consumer<Throwable> onError) {
|
||||
|
||||
Map<String, Object> payload = toPayload(request);
|
||||
Map<String, Object> payload = payloadBuilder.build(request);
|
||||
|
||||
Flux<ServerSentEvent<String>> flux = webClient.post()
|
||||
.uri(CHAT_STREAM_PATH)
|
||||
@@ -81,7 +75,7 @@ public class BrainAiChatClient implements AiChatProvider {
|
||||
// au contrat synchrone du port. L'appelant choisit le thread.
|
||||
flux
|
||||
.timeout(Duration.ofSeconds(120))
|
||||
.doOnNext(sse -> handleEvent(sse, onToken, onError))
|
||||
.doOnNext(sse -> handleEvent(sse, onUsage, onToken, onError))
|
||||
.blockLast();
|
||||
onComplete.run();
|
||||
} catch (Exception e) {
|
||||
@@ -90,12 +84,13 @@ public class BrainAiChatClient implements AiChatProvider {
|
||||
}
|
||||
}
|
||||
|
||||
/** Dispatch selon le type d'événement SSE (data par défaut, done, error). */
|
||||
/** Dispatch selon le type d'évènement SSE (data par défaut, done, error, usage). */
|
||||
private void handleEvent(
|
||||
ServerSentEvent<String> sse,
|
||||
Consumer<ChatUsage> onUsage,
|
||||
Consumer<String> onToken,
|
||||
Consumer<Throwable> onError) {
|
||||
String event = sse.event(); // null si pas d'event: xxx -> c'est un data par défaut
|
||||
String event = sse.event(); // null si pas d'event: xxx -> data par défaut
|
||||
String data = sse.data();
|
||||
|
||||
if ("error".equals(event)) {
|
||||
@@ -104,197 +99,17 @@ public class BrainAiChatClient implements AiChatProvider {
|
||||
return;
|
||||
}
|
||||
if ("done".equals(event)) {
|
||||
return; // la fin est gérée par blockLast + onComplete
|
||||
return; // fin gérée par blockLast + onComplete
|
||||
}
|
||||
// Défaut : événement data avec JSON {"token":"..."}.
|
||||
String token = extractToken(data);
|
||||
if ("usage".equals(event)) {
|
||||
ChatUsage usage = sseParser.parseUsage(data);
|
||||
if (usage != null) onUsage.accept(usage);
|
||||
return;
|
||||
}
|
||||
// Défaut : évènement data avec JSON {"token":"..."}.
|
||||
String token = sseParser.parseToken(data);
|
||||
if (token != null && !token.isEmpty()) {
|
||||
onToken.accept(token);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse minimaliste du JSON {"token":"..."} sans pull Jackson ici.
|
||||
* Si le format se complexifie, on remplacera par un DTO Jackson.
|
||||
*/
|
||||
private String extractToken(String json) {
|
||||
if (json == null) return null;
|
||||
int idx = json.indexOf("\"token\"");
|
||||
if (idx < 0) return null;
|
||||
int colon = json.indexOf(':', idx);
|
||||
int firstQuote = json.indexOf('"', colon + 1);
|
||||
int lastQuote = json.lastIndexOf('"');
|
||||
if (firstQuote < 0 || lastQuote <= firstQuote) return null;
|
||||
return json.substring(firstQuote + 1, lastQuote)
|
||||
.replace("\\n", "\n")
|
||||
.replace("\\\"", "\"")
|
||||
.replace("\\\\", "\\");
|
||||
}
|
||||
|
||||
// --- Construction du payload JSON vers le Brain -------------------------
|
||||
|
||||
/**
|
||||
* Construit le payload JSON. Chaque contexte optionnel est omis s'il est
|
||||
* null, pour s'aligner sur le schéma Pydantic côté Brain (champs
|
||||
* Optional qui restent absents du dict transmis au LLM).
|
||||
*/
|
||||
private Map<String, Object> toPayload(ChatRequest request) {
|
||||
Map<String, Object> root = new LinkedHashMap<>();
|
||||
root.put("messages", request.getMessages().stream()
|
||||
.map(this::messageToMap)
|
||||
.collect(Collectors.toList()));
|
||||
|
||||
if (request.getLoreContext() != null) {
|
||||
root.put("lore_context", loreContextToMap(request.getLoreContext()));
|
||||
}
|
||||
if (request.getPageContext() != null) {
|
||||
root.put("page_context", pageContextToMap(request.getPageContext()));
|
||||
}
|
||||
if (request.getCampaignContext() != null) {
|
||||
root.put("campaign_context", campaignContextToMap(request.getCampaignContext()));
|
||||
}
|
||||
if (request.getNarrativeEntity() != null) {
|
||||
root.put("narrative_entity", narrativeEntityToMap(request.getNarrativeEntity()));
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
private Map<String, Object> messageToMap(ChatMessage m) {
|
||||
Map<String, Object> map = new LinkedHashMap<>();
|
||||
map.put("role", m.role());
|
||||
map.put("content", m.content());
|
||||
return map;
|
||||
}
|
||||
|
||||
private Map<String, Object> loreContextToMap(LoreStructuralContext ctx) {
|
||||
Map<String, Object> map = new LinkedHashMap<>();
|
||||
map.put("lore_name", ctx.getLoreName());
|
||||
map.put("lore_description", ctx.getLoreDescription());
|
||||
|
||||
Map<String, Object> foldersMap = new LinkedHashMap<>();
|
||||
for (Map.Entry<String, List<PageSummary>> e : ctx.getFolders().entrySet()) {
|
||||
foldersMap.put(e.getKey(), e.getValue().stream()
|
||||
.map(this::pageSummaryToMap)
|
||||
.collect(Collectors.toList()));
|
||||
}
|
||||
map.put("folders", foldersMap);
|
||||
map.put("tags", ctx.getTags());
|
||||
return map;
|
||||
}
|
||||
|
||||
private Map<String, Object> pageSummaryToMap(PageSummary ps) {
|
||||
Map<String, Object> map = new LinkedHashMap<>();
|
||||
map.put("title", ps.getTitle());
|
||||
map.put("template_name", ps.getTemplateName());
|
||||
// values/tags/related_page_titles ne sont sérialisés que s'ils contiennent
|
||||
// de l'info — payload réseau plus léger quand la page est vierge.
|
||||
if (ps.getValues() != null && !ps.getValues().isEmpty()) {
|
||||
map.put("values", ps.getValues());
|
||||
}
|
||||
if (ps.getTags() != null && !ps.getTags().isEmpty()) {
|
||||
map.put("tags", ps.getTags());
|
||||
}
|
||||
if (ps.getRelatedPageTitles() != null && !ps.getRelatedPageTitles().isEmpty()) {
|
||||
map.put("related_page_titles", ps.getRelatedPageTitles());
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
private Map<String, Object> pageContextToMap(PageContext pc) {
|
||||
Map<String, Object> map = new LinkedHashMap<>();
|
||||
map.put("title", pc.getTitle());
|
||||
map.put("template_name", pc.getTemplateName());
|
||||
map.put("template_fields", pc.getTemplateFields());
|
||||
map.put("values", pc.getValues());
|
||||
return map;
|
||||
}
|
||||
|
||||
private Map<String, Object> campaignContextToMap(CampaignStructuralContext ctx) {
|
||||
Map<String, Object> map = new LinkedHashMap<>();
|
||||
map.put("campaign_name", ctx.getCampaignName());
|
||||
map.put("campaign_description", ctx.getCampaignDescription());
|
||||
map.put("arcs", ctx.getArcs().stream()
|
||||
.map(this::arcSummaryToMap)
|
||||
.collect(Collectors.toList()));
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper generic pour serialiser les entites structurelles (Arc/Chapter/Scene)
|
||||
* avec name, description et illustration_count conditionnel.
|
||||
*/
|
||||
private <T> Map<String, Object> structuralSummaryToMap(
|
||||
T entity,
|
||||
java.util.function.Function<T, String> nameExtractor,
|
||||
java.util.function.Function<T, String> descriptionExtractor,
|
||||
java.util.function.Function<T, Integer> illustrationCountExtractor,
|
||||
java.util.function.BiConsumer<Map<String, Object>, T> childSerializer) {
|
||||
Map<String, Object> map = new LinkedHashMap<>();
|
||||
map.put("name", nameExtractor.apply(entity));
|
||||
map.put("description", descriptionExtractor.apply(entity));
|
||||
// Envoye au Python pour enrichir le prompt ("N illustrations attachees").
|
||||
// Serialise uniquement si > 0 pour economiser le payload sur les entites sans images.
|
||||
if (illustrationCountExtractor.apply(entity) > 0) {
|
||||
map.put("illustration_count", illustrationCountExtractor.apply(entity));
|
||||
}
|
||||
childSerializer.accept(map, entity);
|
||||
return map;
|
||||
}
|
||||
|
||||
private Map<String, Object> arcSummaryToMap(ArcSummary a) {
|
||||
return structuralSummaryToMap(
|
||||
a,
|
||||
ArcSummary::getName,
|
||||
ArcSummary::getDescription,
|
||||
ArcSummary::getIllustrationCount,
|
||||
(map, arc) -> map.put("chapters", arc.getChapters().stream()
|
||||
.map(this::chapterSummaryToMap)
|
||||
.collect(Collectors.toList())));
|
||||
}
|
||||
|
||||
private Map<String, Object> chapterSummaryToMap(ChapterSummary c) {
|
||||
return structuralSummaryToMap(
|
||||
c,
|
||||
ChapterSummary::getName,
|
||||
ChapterSummary::getDescription,
|
||||
ChapterSummary::getIllustrationCount,
|
||||
(map, chapter) -> map.put("scenes", chapter.getScenes().stream()
|
||||
.map(this::sceneSummaryToMap)
|
||||
.collect(Collectors.toList())));
|
||||
}
|
||||
|
||||
private Map<String, Object> sceneSummaryToMap(SceneSummary s) {
|
||||
return structuralSummaryToMap(
|
||||
s,
|
||||
SceneSummary::getName,
|
||||
SceneSummary::getDescription,
|
||||
SceneSummary::getIllustrationCount,
|
||||
(map, scene) -> {
|
||||
// Branches narratives : serialise uniquement si presentes, pour garder
|
||||
// un payload leger sur les scenes lineaires classiques.
|
||||
if (s.getBranches() != null && !s.getBranches().isEmpty()) {
|
||||
map.put("branches", s.getBranches().stream()
|
||||
.map(this::branchHintToMap)
|
||||
.collect(Collectors.toList()));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private Map<String, Object> branchHintToMap(BranchHint b) {
|
||||
Map<String, Object> map = new LinkedHashMap<>();
|
||||
map.put("label", b.getLabel());
|
||||
map.put("target_scene_name", b.getTargetSceneName());
|
||||
if (b.getCondition() != null && !b.getCondition().isBlank()) {
|
||||
map.put("condition", b.getCondition());
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
private Map<String, Object> narrativeEntityToMap(NarrativeEntityContext ne) {
|
||||
Map<String, Object> map = new LinkedHashMap<>();
|
||||
map.put("entity_type", ne.getEntityType());
|
||||
map.put("title", ne.getTitle());
|
||||
map.put("fields", ne.getFields());
|
||||
return map;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,12 +53,12 @@ public class BrainAiClient implements AiProvider {
|
||||
|
||||
private BrainGeneratePageRequest toBrainRequest(GenerationContext context) {
|
||||
return new BrainGeneratePageRequest(
|
||||
context.getLoreName(),
|
||||
context.getLoreDescription(),
|
||||
context.getFolderName(),
|
||||
context.getTemplateName(),
|
||||
context.getTemplateFields(),
|
||||
context.getPageTitle()
|
||||
context.loreName(),
|
||||
context.loreDescription(),
|
||||
context.folderName(),
|
||||
context.templateName(),
|
||||
context.templateFields(),
|
||||
context.pageTitle()
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
package com.loremind.infrastructure.ai;
|
||||
|
||||
import com.loremind.domain.generationcontext.CampaignStructuralContext;
|
||||
import com.loremind.domain.generationcontext.CampaignStructuralContext.ArcSummary;
|
||||
import com.loremind.domain.generationcontext.CampaignStructuralContext.BranchHint;
|
||||
import com.loremind.domain.generationcontext.CampaignStructuralContext.ChapterSummary;
|
||||
import com.loremind.domain.generationcontext.CampaignStructuralContext.CharacterSummary;
|
||||
import com.loremind.domain.generationcontext.CampaignStructuralContext.NpcSummary;
|
||||
import com.loremind.domain.generationcontext.CampaignStructuralContext.SceneSummary;
|
||||
import com.loremind.domain.generationcontext.ChatMessage;
|
||||
import com.loremind.domain.generationcontext.ChatRequest;
|
||||
import com.loremind.domain.generationcontext.GameSystemContext;
|
||||
import com.loremind.domain.generationcontext.LoreStructuralContext;
|
||||
import com.loremind.domain.generationcontext.LoreStructuralContext.PageSummary;
|
||||
import com.loremind.domain.generationcontext.NarrativeEntityContext;
|
||||
import com.loremind.domain.generationcontext.PageContext;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.BiConsumer;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Helper d'infrastructure : traduit un ChatRequest (domaine) vers le dict JSON
|
||||
* attendu par le Brain Python (/chat/stream).
|
||||
* <p>
|
||||
* Extrait de BrainAiChatClient pour isoler la responsabilité "sérialisation
|
||||
* de payload" (SRP) — le client HTTP se concentre désormais uniquement sur le
|
||||
* transport et le streaming SSE.
|
||||
* <p>
|
||||
* Chaque contexte optionnel (lore, page, campaign, entité narrative) est omis
|
||||
* si null, pour s'aligner sur le schéma Pydantic (champs Optional absents).
|
||||
*/
|
||||
@Component
|
||||
public class BrainChatPayloadBuilder {
|
||||
|
||||
public Map<String, Object> build(ChatRequest request) {
|
||||
Map<String, Object> root = new LinkedHashMap<>();
|
||||
root.put("messages", request.messages().stream()
|
||||
.map(this::messageToMap)
|
||||
.collect(Collectors.toList()));
|
||||
|
||||
if (request.loreContext() != null) {
|
||||
root.put("lore_context", loreContextToMap(request.loreContext()));
|
||||
}
|
||||
if (request.pageContext() != null) {
|
||||
root.put("page_context", pageContextToMap(request.pageContext()));
|
||||
}
|
||||
if (request.campaignContext() != null) {
|
||||
root.put("campaign_context", campaignContextToMap(request.campaignContext()));
|
||||
}
|
||||
if (request.narrativeEntity() != null) {
|
||||
root.put("narrative_entity", narrativeEntityToMap(request.narrativeEntity()));
|
||||
}
|
||||
if (request.gameSystemContext() != null) {
|
||||
root.put("game_system_context", gameSystemContextToMap(request.gameSystemContext()));
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
private Map<String, Object> gameSystemContextToMap(GameSystemContext gs) {
|
||||
Map<String, Object> map = new LinkedHashMap<>();
|
||||
map.put("system_name", gs.systemName());
|
||||
if (gs.systemDescription() != null && !gs.systemDescription().isBlank()) {
|
||||
map.put("system_description", gs.systemDescription());
|
||||
}
|
||||
map.put("sections", gs.sections() != null ? gs.sections() : Map.of());
|
||||
return map;
|
||||
}
|
||||
|
||||
private Map<String, Object> messageToMap(ChatMessage m) {
|
||||
Map<String, Object> map = new LinkedHashMap<>();
|
||||
map.put("role", m.role());
|
||||
map.put("content", m.content());
|
||||
return map;
|
||||
}
|
||||
|
||||
private Map<String, Object> loreContextToMap(LoreStructuralContext ctx) {
|
||||
Map<String, Object> map = new LinkedHashMap<>();
|
||||
map.put("lore_name", ctx.loreName());
|
||||
map.put("lore_description", ctx.loreDescription());
|
||||
|
||||
Map<String, Object> foldersMap = new LinkedHashMap<>();
|
||||
for (Map.Entry<String, List<PageSummary>> e : ctx.folders().entrySet()) {
|
||||
foldersMap.put(e.getKey(), e.getValue().stream()
|
||||
.map(this::pageSummaryToMap)
|
||||
.collect(Collectors.toList()));
|
||||
}
|
||||
map.put("folders", foldersMap);
|
||||
map.put("tags", ctx.tags());
|
||||
return map;
|
||||
}
|
||||
|
||||
private Map<String, Object> pageSummaryToMap(PageSummary ps) {
|
||||
Map<String, Object> map = new LinkedHashMap<>();
|
||||
map.put("title", ps.title());
|
||||
map.put("template_name", ps.templateName());
|
||||
// values/tags/related_page_titles : omis si vides pour alléger le payload.
|
||||
if (ps.values() != null && !ps.values().isEmpty()) {
|
||||
map.put("values", ps.values());
|
||||
}
|
||||
if (ps.tags() != null && !ps.tags().isEmpty()) {
|
||||
map.put("tags", ps.tags());
|
||||
}
|
||||
if (ps.relatedPageTitles() != null && !ps.relatedPageTitles().isEmpty()) {
|
||||
map.put("related_page_titles", ps.relatedPageTitles());
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
private Map<String, Object> pageContextToMap(PageContext pc) {
|
||||
Map<String, Object> map = new LinkedHashMap<>();
|
||||
map.put("title", pc.title());
|
||||
map.put("template_name", pc.templateName());
|
||||
map.put("template_fields", pc.templateFields());
|
||||
map.put("values", pc.values());
|
||||
return map;
|
||||
}
|
||||
|
||||
private Map<String, Object> campaignContextToMap(CampaignStructuralContext ctx) {
|
||||
Map<String, Object> map = new LinkedHashMap<>();
|
||||
map.put("campaign_name", ctx.campaignName());
|
||||
map.put("campaign_description", ctx.campaignDescription());
|
||||
map.put("arcs", ctx.arcs().stream()
|
||||
.map(this::arcSummaryToMap)
|
||||
.collect(Collectors.toList()));
|
||||
// Liste des PJ : omise si aucun pour alléger le prompt des campagnes sans fiches.
|
||||
if (ctx.characters() != null && !ctx.characters().isEmpty()) {
|
||||
map.put("characters", ctx.characters().stream()
|
||||
.map(this::characterSummaryToMap)
|
||||
.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;
|
||||
}
|
||||
|
||||
private Map<String, Object> characterSummaryToMap(CharacterSummary c) {
|
||||
Map<String, Object> map = new LinkedHashMap<>();
|
||||
map.put("name", c.name());
|
||||
if (c.snippet() != null && !c.snippet().isBlank()) {
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper générique pour sérialiser les entités structurelles (Arc/Chapter/Scene)
|
||||
* avec name, description et illustration_count conditionnel.
|
||||
*/
|
||||
private <T> Map<String, Object> structuralSummaryToMap(
|
||||
T entity,
|
||||
Function<T, String> nameExtractor,
|
||||
Function<T, String> descriptionExtractor,
|
||||
Function<T, Integer> illustrationCountExtractor,
|
||||
BiConsumer<Map<String, Object>, T> childSerializer) {
|
||||
Map<String, Object> map = new LinkedHashMap<>();
|
||||
map.put("name", nameExtractor.apply(entity));
|
||||
map.put("description", descriptionExtractor.apply(entity));
|
||||
if (illustrationCountExtractor.apply(entity) > 0) {
|
||||
map.put("illustration_count", illustrationCountExtractor.apply(entity));
|
||||
}
|
||||
childSerializer.accept(map, entity);
|
||||
return map;
|
||||
}
|
||||
|
||||
private Map<String, Object> arcSummaryToMap(ArcSummary a) {
|
||||
return structuralSummaryToMap(
|
||||
a,
|
||||
ArcSummary::name,
|
||||
ArcSummary::description,
|
||||
ArcSummary::illustrationCount,
|
||||
(map, arc) -> map.put("chapters", arc.chapters().stream()
|
||||
.map(this::chapterSummaryToMap)
|
||||
.collect(Collectors.toList())));
|
||||
}
|
||||
|
||||
private Map<String, Object> chapterSummaryToMap(ChapterSummary c) {
|
||||
return structuralSummaryToMap(
|
||||
c,
|
||||
ChapterSummary::name,
|
||||
ChapterSummary::description,
|
||||
ChapterSummary::illustrationCount,
|
||||
(map, chapter) -> map.put("scenes", chapter.scenes().stream()
|
||||
.map(this::sceneSummaryToMap)
|
||||
.collect(Collectors.toList())));
|
||||
}
|
||||
|
||||
private Map<String, Object> sceneSummaryToMap(SceneSummary s) {
|
||||
return structuralSummaryToMap(
|
||||
s,
|
||||
SceneSummary::name,
|
||||
SceneSummary::description,
|
||||
SceneSummary::illustrationCount,
|
||||
(map, scene) -> {
|
||||
// Branches narratives : omises si absentes (scènes linéaires classiques).
|
||||
if (s.branches() != null && !s.branches().isEmpty()) {
|
||||
map.put("branches", s.branches().stream()
|
||||
.map(this::branchHintToMap)
|
||||
.collect(Collectors.toList()));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private Map<String, Object> branchHintToMap(BranchHint b) {
|
||||
Map<String, Object> map = new LinkedHashMap<>();
|
||||
map.put("label", b.label());
|
||||
map.put("target_scene_name", b.targetSceneName());
|
||||
if (b.condition() != null && !b.condition().isBlank()) {
|
||||
map.put("condition", b.condition());
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
private Map<String, Object> narrativeEntityToMap(NarrativeEntityContext ne) {
|
||||
Map<String, Object> map = new LinkedHashMap<>();
|
||||
map.put("entity_type", ne.entityType());
|
||||
map.put("title", ne.title());
|
||||
map.put("fields", ne.fields());
|
||||
return map;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package com.loremind.infrastructure.ai;
|
||||
|
||||
import com.loremind.domain.conversationcontext.ConversationMessage;
|
||||
import com.loremind.domain.conversationcontext.ports.ConversationTitleGenerator;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Adaptateur : appelle le Brain POST /summarize/conversation-title pour
|
||||
* obtenir un titre court a partir des premiers messages.
|
||||
*
|
||||
* Fallback volontairement silencieux : si le Brain est indisponible, on
|
||||
* renvoie un titre par defaut plutot que de casser l'UX chat.
|
||||
*/
|
||||
@Component
|
||||
public class BrainConversationTitleClient implements ConversationTitleGenerator {
|
||||
|
||||
private static final String PATH = "/summarize/conversation-title";
|
||||
private static final String FALLBACK = "Nouvelle conversation";
|
||||
|
||||
private final WebClient webClient;
|
||||
|
||||
public BrainConversationTitleClient(
|
||||
WebClient.Builder builder,
|
||||
@Value("${brain.base-url}") String baseUrl) {
|
||||
this.webClient = builder.baseUrl(baseUrl).build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String generate(List<ConversationMessage> firstMessages) {
|
||||
if (firstMessages == null || firstMessages.isEmpty()) {
|
||||
return FALLBACK;
|
||||
}
|
||||
Map<String, Object> payload = new LinkedHashMap<>();
|
||||
payload.put("messages", firstMessages.stream()
|
||||
.map(m -> Map.<String, Object>of(
|
||||
"role", m.getRole(),
|
||||
"content", m.getContent() == null ? "" : m.getContent()))
|
||||
.collect(Collectors.toList()));
|
||||
|
||||
try {
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> resp = webClient.post()
|
||||
.uri(PATH)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.bodyValue(payload)
|
||||
.retrieve()
|
||||
.bodyToMono(Map.class)
|
||||
.timeout(Duration.ofSeconds(20))
|
||||
.block();
|
||||
if (resp == null) return FALLBACK;
|
||||
Object title = resp.get("title");
|
||||
if (title == null) return FALLBACK;
|
||||
String s = title.toString().trim();
|
||||
return s.isEmpty() ? FALLBACK : s;
|
||||
} catch (Exception e) {
|
||||
return FALLBACK;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package com.loremind.infrastructure.ai;
|
||||
|
||||
import com.loremind.domain.generationcontext.ChatUsage;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Helper d'infrastructure : parse les payloads JSON véhiculés dans les
|
||||
* évènements SSE reçus du Brain Python.
|
||||
* <p>
|
||||
* Implémentation volontairement minimaliste (pas de Jackson ici) car les
|
||||
* schémas attendus sont figés et simples : {"token":"..."} et
|
||||
* {"system":N,"history":N,"current":N,"max":N}. Si la complexité augmente,
|
||||
* remplacer par un ObjectMapper + DTOs.
|
||||
*/
|
||||
@Component
|
||||
public class BrainSseParser {
|
||||
|
||||
/**
|
||||
* Parse un JSON {"system":N,"history":N,"current":N,"max":N} en ChatUsage.
|
||||
* Renvoie null si le payload est illisible — l'appelant décidera de ne
|
||||
* simplement pas propager l'usage (le stream token continue).
|
||||
*/
|
||||
public ChatUsage parseUsage(String json) {
|
||||
if (json == null) return null;
|
||||
try {
|
||||
int system = extractIntField(json, "system");
|
||||
int history = extractIntField(json, "history");
|
||||
int current = extractIntField(json, "current");
|
||||
int max = extractIntField(json, "max");
|
||||
return new ChatUsage(system, history, current, max);
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse {"token":"..."} et renvoie la valeur du champ token (chaîne vide
|
||||
* ou null si introuvable).
|
||||
*/
|
||||
public String parseToken(String json) {
|
||||
if (json == null) return null;
|
||||
int idx = json.indexOf("\"token\"");
|
||||
if (idx < 0) return null;
|
||||
int colon = json.indexOf(':', idx);
|
||||
int firstQuote = json.indexOf('"', colon + 1);
|
||||
int lastQuote = json.lastIndexOf('"');
|
||||
if (firstQuote < 0 || lastQuote <= firstQuote) return null;
|
||||
return json.substring(firstQuote + 1, lastQuote)
|
||||
.replace("\\n", "\n")
|
||||
.replace("\\\"", "\"")
|
||||
.replace("\\\\", "\\");
|
||||
}
|
||||
|
||||
private int extractIntField(String json, String field) {
|
||||
String needle = "\"" + field + "\"";
|
||||
int idx = json.indexOf(needle);
|
||||
if (idx < 0) return 0;
|
||||
int colon = json.indexOf(':', idx);
|
||||
if (colon < 0) return 0;
|
||||
int start = colon + 1;
|
||||
while (start < json.length() && Character.isWhitespace(json.charAt(start))) start++;
|
||||
int end = start;
|
||||
while (end < json.length() && (Character.isDigit(json.charAt(end)) || json.charAt(end) == '-')) end++;
|
||||
if (end == start) return 0;
|
||||
return Integer.parseInt(json.substring(start, end));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package com.loremind.infrastructure.licensing;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import com.loremind.domain.licensing.RegistryCredentials;
|
||||
import com.loremind.domain.licensing.ports.DockerConfigWriter;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.attribute.PosixFilePermissions;
|
||||
import java.util.Base64;
|
||||
|
||||
/**
|
||||
* Implementation : ecriture du fichier {@code config.json} au format Docker
|
||||
* standard, dans un volume partage avec Watchtower.
|
||||
* <p>
|
||||
* Format produit :
|
||||
* <pre>{@code
|
||||
* {
|
||||
* "auths": {
|
||||
* "ghcr.io": {
|
||||
* "auth": "<base64(username:password)>"
|
||||
* }
|
||||
* }
|
||||
* }
|
||||
* }</pre>
|
||||
*/
|
||||
@Component
|
||||
public class FileDockerConfigWriter implements DockerConfigWriter {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(FileDockerConfigWriter.class);
|
||||
|
||||
private final Path configPath;
|
||||
private final ObjectMapper mapper = new ObjectMapper();
|
||||
|
||||
public FileDockerConfigWriter(
|
||||
@Value("${licensing.docker-config-path:/shared/docker/config.json}") String pathStr) {
|
||||
this.configPath = Path.of(pathStr);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void writeCredentials(RegistryCredentials credentials) throws IOException {
|
||||
ensureParentDirectory();
|
||||
|
||||
ObjectNode root;
|
||||
if (Files.exists(configPath)) {
|
||||
try {
|
||||
JsonNode existing = mapper.readTree(configPath.toFile());
|
||||
root = existing.isObject() ? (ObjectNode) existing : mapper.createObjectNode();
|
||||
} catch (IOException e) {
|
||||
log.warn("Existing docker config unreadable, overwriting: {}", e.getMessage());
|
||||
root = mapper.createObjectNode();
|
||||
}
|
||||
} else {
|
||||
root = mapper.createObjectNode();
|
||||
}
|
||||
|
||||
ObjectNode auths = root.has("auths") && root.get("auths").isObject()
|
||||
? (ObjectNode) root.get("auths")
|
||||
: root.putObject("auths");
|
||||
|
||||
String b64 = Base64.getEncoder().encodeToString(
|
||||
(credentials.username() + ":" + credentials.password()).getBytes(StandardCharsets.UTF_8));
|
||||
|
||||
ObjectNode entry = mapper.createObjectNode();
|
||||
entry.put("auth", b64);
|
||||
auths.set(credentials.registry(), entry);
|
||||
|
||||
Files.writeString(configPath, mapper.writerWithDefaultPrettyPrinter().writeValueAsString(root),
|
||||
StandardCharsets.UTF_8);
|
||||
applyRestrictivePermissions();
|
||||
log.info("Docker config written at {} for registry {}", configPath, credentials.registry());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void clear() throws IOException {
|
||||
if (Files.exists(configPath)) {
|
||||
Files.delete(configPath);
|
||||
log.info("Docker config cleared at {}", configPath);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isPresent() {
|
||||
return Files.exists(configPath);
|
||||
}
|
||||
|
||||
private void ensureParentDirectory() throws IOException {
|
||||
Path parent = configPath.getParent();
|
||||
if (parent != null && !Files.exists(parent)) {
|
||||
Files.createDirectories(parent);
|
||||
}
|
||||
}
|
||||
|
||||
/** 0600 sur POSIX. Sur Windows (dev), no-op silencieux. */
|
||||
private void applyRestrictivePermissions() {
|
||||
try {
|
||||
Files.setPosixFilePermissions(configPath, PosixFilePermissions.fromString("rw-------"));
|
||||
} catch (UnsupportedOperationException | IOException e) {
|
||||
// Windows / FS qui ne supporte pas POSIX => ignore (le conteneur tourne sous Linux en prod)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package com.loremind.infrastructure.licensing;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.loremind.domain.licensing.RegistryCredentials;
|
||||
import com.loremind.domain.licensing.ports.LicenseRelay;
|
||||
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.Component;
|
||||
import org.springframework.web.client.HttpClientErrorException;
|
||||
import org.springframework.web.client.HttpServerErrorException;
|
||||
import org.springframework.web.client.RestClientException;
|
||||
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.Map;
|
||||
|
||||
/**
|
||||
* Client HTTP du relais OAuth Patreon (deploye sur Cloudflare Workers).
|
||||
* Voir {@code relay/} pour le code du relais.
|
||||
*/
|
||||
@Component
|
||||
public class HttpLicenseRelay implements LicenseRelay {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(HttpLicenseRelay.class);
|
||||
|
||||
private final RestTemplate http;
|
||||
private final String baseUrl;
|
||||
|
||||
public HttpLicenseRelay(
|
||||
RestTemplateBuilder builder,
|
||||
@Value("${licensing.relay.base-url:}") String baseUrl) {
|
||||
this.http = builder
|
||||
.setConnectTimeout(Duration.ofSeconds(5))
|
||||
.setReadTimeout(Duration.ofSeconds(15))
|
||||
.build();
|
||||
this.baseUrl = stripTrailingSlash(baseUrl);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String buildConnectUrl(String instanceId) {
|
||||
if (baseUrl.isBlank()) {
|
||||
throw new IllegalStateException("Licensing relay base URL not configured");
|
||||
}
|
||||
String encoded = URLEncoder.encode(instanceId, StandardCharsets.UTF_8);
|
||||
return baseUrl + "/oauth/start?instance_id=" + encoded;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String refreshToken(String currentJwt) throws RelayException {
|
||||
if (baseUrl.isBlank()) {
|
||||
throw new RelayException(RelayErrorKind.TRANSIENT, "relay not configured");
|
||||
}
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||
Map<String, String> body = Map.of("jwt", currentJwt);
|
||||
|
||||
ResponseEntity<JsonNode> resp;
|
||||
try {
|
||||
resp = http.exchange(
|
||||
baseUrl + "/token/refresh",
|
||||
HttpMethod.POST,
|
||||
new HttpEntity<>(body, headers),
|
||||
JsonNode.class);
|
||||
} catch (HttpClientErrorException e) {
|
||||
throw new RelayException(RelayErrorKind.REJECTED,
|
||||
"relay rejected refresh: " + e.getStatusCode() + " " + e.getStatusText());
|
||||
} catch (HttpServerErrorException e) {
|
||||
throw new RelayException(RelayErrorKind.TRANSIENT,
|
||||
"relay 5xx: " + e.getStatusCode());
|
||||
} catch (RestClientException e) {
|
||||
throw new RelayException(RelayErrorKind.TRANSIENT, "relay unreachable: " + e.getMessage(), e);
|
||||
}
|
||||
|
||||
JsonNode payload = resp.getBody();
|
||||
if (payload == null || !payload.hasNonNull("jwt")) {
|
||||
throw new RelayException(RelayErrorKind.BAD_RESPONSE, "missing jwt in refresh response");
|
||||
}
|
||||
return payload.get("jwt").asText();
|
||||
}
|
||||
|
||||
@Override
|
||||
public RegistryCredentials fetchRegistryCredentials(String currentJwt) throws RelayException {
|
||||
if (baseUrl.isBlank()) {
|
||||
throw new RelayException(RelayErrorKind.TRANSIENT, "relay not configured");
|
||||
}
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||
Map<String, String> body = Map.of("jwt", currentJwt);
|
||||
|
||||
ResponseEntity<JsonNode> resp;
|
||||
try {
|
||||
resp = http.exchange(
|
||||
baseUrl + "/registry/credentials",
|
||||
HttpMethod.POST,
|
||||
new HttpEntity<>(body, headers),
|
||||
JsonNode.class);
|
||||
} catch (HttpClientErrorException e) {
|
||||
throw new RelayException(RelayErrorKind.REJECTED,
|
||||
"relay rejected creds: " + e.getStatusCode() + " " + e.getStatusText());
|
||||
} catch (HttpServerErrorException e) {
|
||||
throw new RelayException(RelayErrorKind.TRANSIENT,
|
||||
"relay 5xx: " + e.getStatusCode());
|
||||
} catch (RestClientException e) {
|
||||
throw new RelayException(RelayErrorKind.TRANSIENT, "relay unreachable: " + e.getMessage(), e);
|
||||
}
|
||||
|
||||
JsonNode payload = resp.getBody();
|
||||
if (payload == null
|
||||
|| !payload.hasNonNull("registry")
|
||||
|| !payload.hasNonNull("username")
|
||||
|| !payload.hasNonNull("password")) {
|
||||
throw new RelayException(RelayErrorKind.BAD_RESPONSE, "incomplete credentials response");
|
||||
}
|
||||
Instant expiresAt = null;
|
||||
if (payload.hasNonNull("expires_at")) {
|
||||
try {
|
||||
expiresAt = Instant.parse(payload.get("expires_at").asText());
|
||||
} catch (Exception e) {
|
||||
log.warn("Cannot parse expires_at from relay creds response: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
return new RegistryCredentials(
|
||||
payload.get("registry").asText(),
|
||||
payload.get("username").asText(),
|
||||
payload.get("password").asText(),
|
||||
expiresAt
|
||||
);
|
||||
}
|
||||
|
||||
private static String stripTrailingSlash(String s) {
|
||||
if (s == null) return "";
|
||||
String v = s.trim();
|
||||
if (v.endsWith("/")) v = v.substring(0, v.length() - 1);
|
||||
return v;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package com.loremind.infrastructure.licensing;
|
||||
|
||||
import com.loremind.application.licensing.LicenseService;
|
||||
import com.loremind.domain.licensing.LicenseSnapshot;
|
||||
import com.loremind.domain.licensing.LicenseStatus;
|
||||
import com.loremind.domain.licensing.RegistryCredentials;
|
||||
import com.loremind.domain.licensing.ports.DockerConfigWriter;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Daemon planifie qui :
|
||||
* <ul>
|
||||
* <li>renouvelle le JWT licence via le relais avant expiration (J-2)</li>
|
||||
* <li>met a jour les credentials registry GHCR pour Watchtower
|
||||
* (volume partage docker-config) tant que le canal beta est ON</li>
|
||||
* <li>nettoie les credentials si la licence est invalidee ou le toggle OFF</li>
|
||||
* </ul>
|
||||
* Idempotent : peut tourner toutes les 6h sans risque, fait du no-op
|
||||
* la plupart du temps.
|
||||
*/
|
||||
@Component
|
||||
public class LicenseRefreshDaemon {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(LicenseRefreshDaemon.class);
|
||||
|
||||
/** 6 heures entre chaque cycle. Suffisant pour rattraper un J-2 sans surcharger. */
|
||||
private static final long FIXED_DELAY_MS = 6L * 60L * 60L * 1000L;
|
||||
/** Premier run apres 30s pour laisser le contexte Spring se stabiliser. */
|
||||
private static final long INITIAL_DELAY_MS = 30_000L;
|
||||
|
||||
private final LicenseService licenseService;
|
||||
private final DockerConfigWriter dockerConfigWriter;
|
||||
|
||||
public LicenseRefreshDaemon(LicenseService licenseService,
|
||||
DockerConfigWriter dockerConfigWriter) {
|
||||
this.licenseService = licenseService;
|
||||
this.dockerConfigWriter = dockerConfigWriter;
|
||||
}
|
||||
|
||||
@Scheduled(initialDelay = INITIAL_DELAY_MS, fixedDelay = FIXED_DELAY_MS)
|
||||
public void tick() {
|
||||
if (!licenseService.isLicensingEnabled()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
licenseService.refreshIfNeeded();
|
||||
syncDockerConfig();
|
||||
} catch (Exception e) {
|
||||
log.error("LicenseRefreshDaemon tick failed: {}", e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Aligne le fichier docker config avec l'etat de la licence et le toggle :
|
||||
* <ul>
|
||||
* <li>VALID/GRACE + beta ON -> ecrit/refresh les creds</li>
|
||||
* <li>tout autre cas -> efface le fichier</li>
|
||||
* </ul>
|
||||
*/
|
||||
private void syncDockerConfig() {
|
||||
LicenseSnapshot snap = licenseService.getCurrentSnapshot();
|
||||
boolean shouldHaveCreds = snap.betaChannelEnabled()
|
||||
&& (snap.status() == LicenseStatus.VALID || snap.status() == LicenseStatus.GRACE);
|
||||
|
||||
if (!shouldHaveCreds) {
|
||||
try {
|
||||
if (dockerConfigWriter.isPresent()) {
|
||||
dockerConfigWriter.clear();
|
||||
}
|
||||
} catch (IOException e) {
|
||||
log.warn("Cannot clear docker config: {}", e.getMessage());
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
Optional<RegistryCredentials> creds = licenseService.fetchRegistryCredentials();
|
||||
if (creds.isEmpty()) {
|
||||
log.warn("Beta enabled but cannot fetch registry credentials (relay down or rejected)");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
dockerConfigWriter.writeCredentials(creds.get());
|
||||
} catch (IOException e) {
|
||||
log.error("Cannot write docker config: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
package com.loremind.infrastructure.licensing;
|
||||
|
||||
import com.loremind.domain.licensing.LicenseClaims;
|
||||
import com.loremind.domain.licensing.ports.JwtVerifier;
|
||||
import com.nimbusds.jose.JWSAlgorithm;
|
||||
import com.nimbusds.jose.JWSVerifier;
|
||||
import com.nimbusds.jose.crypto.Ed25519Verifier;
|
||||
import com.nimbusds.jose.jwk.OctetKeyPair;
|
||||
import com.nimbusds.jwt.JWTClaimsSet;
|
||||
import com.nimbusds.jwt.SignedJWT;
|
||||
import org.bouncycastle.asn1.ASN1Sequence;
|
||||
import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.text.ParseException;
|
||||
import java.util.Base64;
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
* Verifie les JWT EdDSA/Ed25519 emis par le relais Patreon.
|
||||
* <p>
|
||||
* La cle publique est fournie en PEM SPKI via la propriete
|
||||
* {@code licensing.jwt.public-key} (env {@code LICENSING_JWT_PUBLIC_KEY}).
|
||||
* Si la cle est absente ou invalide, {@link #isConfigured()} retourne false
|
||||
* et {@link #verify} echoue systematiquement — la feature licensing est
|
||||
* desactivee silencieusement.
|
||||
*/
|
||||
@Component
|
||||
public class NimbusJwtVerifier implements JwtVerifier {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(NimbusJwtVerifier.class);
|
||||
|
||||
private final String expectedIssuer;
|
||||
private final String expectedAudience;
|
||||
private final OctetKeyPair publicKey;
|
||||
|
||||
public NimbusJwtVerifier(
|
||||
@Value("${licensing.jwt.public-key:}") String publicKeyPemFromEnv,
|
||||
@Value("${licensing.jwt.expected-issuer:loremind-auth}") String expectedIssuer,
|
||||
@Value("${licensing.jwt.expected-audience:loremind-instance}") String expectedAudience) {
|
||||
this.expectedIssuer = expectedIssuer;
|
||||
this.expectedAudience = expectedAudience;
|
||||
// Strategie : env var en priorite (rotation possible sans rebuild),
|
||||
// sinon ressource classpath embarquee dans le binaire.
|
||||
String pem = (publicKeyPemFromEnv != null && !publicKeyPemFromEnv.isBlank())
|
||||
? publicKeyPemFromEnv
|
||||
: loadEmbeddedKey();
|
||||
this.publicKey = parsePemSpki(pem);
|
||||
if (publicKey == null) {
|
||||
log.info("Licensing JWT verifier disabled (no public key found)");
|
||||
} else {
|
||||
String source = (publicKeyPemFromEnv != null && !publicKeyPemFromEnv.isBlank()) ? "env" : "embedded";
|
||||
log.info("Licensing JWT verifier enabled (issuer={}, audience={}, key source={})",
|
||||
expectedIssuer, expectedAudience, source);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Charge la cle publique embarquee dans le binaire (resource classpath).
|
||||
* Le fichier est un PEM SPKI standard, fourni a la build pour chaque
|
||||
* release. Si absent, la feature licensing est desactivee.
|
||||
*/
|
||||
private static String loadEmbeddedKey() {
|
||||
ClassPathResource resource = new ClassPathResource("licensing/jwt-public-key.pem");
|
||||
if (!resource.exists()) {
|
||||
return null;
|
||||
}
|
||||
try (InputStream in = resource.getInputStream()) {
|
||||
return new String(in.readAllBytes(), StandardCharsets.UTF_8);
|
||||
} catch (IOException e) {
|
||||
log.warn("Cannot read embedded JWT public key: {}", e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isConfigured() {
|
||||
return publicKey != null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public LicenseClaims verify(String rawJwt) throws JwtVerificationException {
|
||||
if (publicKey == null) {
|
||||
throw new JwtVerificationException("JWT verifier not configured");
|
||||
}
|
||||
if (rawJwt == null || rawJwt.isBlank()) {
|
||||
throw new JwtVerificationException("JWT is empty");
|
||||
}
|
||||
|
||||
SignedJWT signed;
|
||||
try {
|
||||
signed = SignedJWT.parse(rawJwt);
|
||||
} catch (ParseException e) {
|
||||
throw new JwtVerificationException("JWT parse error: " + e.getMessage(), e);
|
||||
}
|
||||
|
||||
JWSAlgorithm alg = signed.getHeader().getAlgorithm();
|
||||
if (!JWSAlgorithm.EdDSA.equals(alg)) {
|
||||
throw new JwtVerificationException("Unexpected JWT algorithm: " + alg);
|
||||
}
|
||||
|
||||
try {
|
||||
JWSVerifier verifier = new Ed25519Verifier(publicKey);
|
||||
if (!signed.verify(verifier)) {
|
||||
throw new JwtVerificationException("JWT signature invalid");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
throw new JwtVerificationException("JWT signature verification failed: " + e.getMessage(), e);
|
||||
}
|
||||
|
||||
JWTClaimsSet claims;
|
||||
try {
|
||||
claims = signed.getJWTClaimsSet();
|
||||
} catch (ParseException e) {
|
||||
throw new JwtVerificationException("JWT claims parse error", e);
|
||||
}
|
||||
|
||||
if (!expectedIssuer.equals(claims.getIssuer())) {
|
||||
throw new JwtVerificationException("JWT issuer mismatch: " + claims.getIssuer());
|
||||
}
|
||||
if (claims.getAudience() == null || !claims.getAudience().contains(expectedAudience)) {
|
||||
throw new JwtVerificationException("JWT audience mismatch");
|
||||
}
|
||||
|
||||
Date exp = claims.getExpirationTime();
|
||||
Date iat = claims.getIssueTime();
|
||||
String sub = claims.getSubject();
|
||||
if (exp == null || iat == null || sub == null) {
|
||||
throw new JwtVerificationException("JWT missing required claims");
|
||||
}
|
||||
|
||||
// Note : on ne refuse pas un JWT expire ici. C'est au LicenseService
|
||||
// de decider ce qu'il fait d'un JWT expire (grace period, refresh, etc.).
|
||||
// La verification de signature reste valide tant que la cle existe.
|
||||
|
||||
String tierId;
|
||||
String instanceId;
|
||||
try {
|
||||
tierId = claims.getStringClaim("tier_id");
|
||||
instanceId = claims.getStringClaim("instance_id");
|
||||
} catch (ParseException e) {
|
||||
throw new JwtVerificationException("JWT custom claim parse error", e);
|
||||
}
|
||||
if (tierId == null || tierId.isBlank() || instanceId == null || instanceId.isBlank()) {
|
||||
throw new JwtVerificationException("JWT missing tier_id or instance_id");
|
||||
}
|
||||
|
||||
return new LicenseClaims(
|
||||
sub,
|
||||
tierId,
|
||||
instanceId,
|
||||
iat.toInstant(),
|
||||
exp.toInstant()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse une cle publique Ed25519 au format PEM SPKI vers un Nimbus
|
||||
* {@link OctetKeyPair} (forme JWK utilisee pour la verification).
|
||||
*/
|
||||
private static OctetKeyPair parsePemSpki(String pem) {
|
||||
if (pem == null || pem.isBlank()) return null;
|
||||
try {
|
||||
String base64 = pem
|
||||
.replace("-----BEGIN PUBLIC KEY-----", "")
|
||||
.replace("-----END PUBLIC KEY-----", "")
|
||||
.replaceAll("\\s+", "");
|
||||
byte[] der = Base64.getDecoder().decode(base64);
|
||||
SubjectPublicKeyInfo spki = SubjectPublicKeyInfo.getInstance(ASN1Sequence.fromByteArray(der));
|
||||
byte[] keyBytes = spki.getPublicKeyData().getOctets();
|
||||
String x = Base64.getUrlEncoder().withoutPadding().encodeToString(keyBytes);
|
||||
return new OctetKeyPair.Builder(com.nimbusds.jose.jwk.Curve.Ed25519, com.nimbusds.jose.util.Base64URL.from(x))
|
||||
.build();
|
||||
} catch (IOException | IllegalArgumentException e) {
|
||||
log.warn("Cannot parse licensing JWT public key: {}", e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package com.loremind.infrastructure.persistence;
|
||||
|
||||
import com.loremind.domain.gamesystemcontext.GameSystem;
|
||||
import com.loremind.domain.gamesystemcontext.ports.GameSystemRepository;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Seed 3 rulesets libres au premier démarrage (si la table game_systems est vide).
|
||||
* <p>
|
||||
* Objectif : donner à l'utilisateur un point de départ pour comprendre le format
|
||||
* attendu (markdown structuré par titres H2) et permettre une démo "out of the box"
|
||||
* sans devoir taper ses propres règles.
|
||||
* <p>
|
||||
* Les rulesets fournis sont des <b>extraits libres</b> (Nimble, SRD 5.1 extrait,
|
||||
* homebrew exemple) — pas des règles officielles complètes. L'utilisateur est
|
||||
* libre de les éditer, supprimer, ou les utiliser comme template.
|
||||
* <p>
|
||||
* Idempotence : ne seed qu'une fois. Si l'utilisateur supprime un ruleset seedé,
|
||||
* il ne revient pas au redémarrage — c'est voulu (respect du choix utilisateur).
|
||||
*/
|
||||
@Component
|
||||
public class GameSystemSeeder {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(GameSystemSeeder.class);
|
||||
|
||||
private final GameSystemRepository gameSystemRepository;
|
||||
|
||||
public GameSystemSeeder(GameSystemRepository gameSystemRepository) {
|
||||
this.gameSystemRepository = gameSystemRepository;
|
||||
}
|
||||
|
||||
@EventListener(ApplicationReadyEvent.class)
|
||||
public void seedIfEmpty() {
|
||||
if (!gameSystemRepository.findAll().isEmpty()) {
|
||||
log.debug("GameSystem seed skipped — table non vide.");
|
||||
return;
|
||||
}
|
||||
log.info("Seed initial des GameSystems (table vide)...");
|
||||
for (GameSystem gs : defaultSystems()) {
|
||||
gameSystemRepository.save(gs);
|
||||
}
|
||||
log.info("GameSystems seedés : {}", defaultSystems().size());
|
||||
}
|
||||
|
||||
private List<GameSystem> defaultSystems() {
|
||||
return List.of(
|
||||
GameSystem.builder()
|
||||
.name("Nimble (extrait)")
|
||||
.description("Système léger et narratif, résolution rapide des combats.")
|
||||
.author("LoreMind seed")
|
||||
.isPublic(false)
|
||||
.rulesMarkdown(NIMBLE_RULES)
|
||||
.build(),
|
||||
GameSystem.builder()
|
||||
.name("D&D 5e SRD (extrait)")
|
||||
.description("Extrait libre des bases du System Reference Document 5.1.")
|
||||
.author("LoreMind seed")
|
||||
.isPublic(false)
|
||||
.rulesMarkdown(DND_SRD_RULES)
|
||||
.build(),
|
||||
GameSystem.builder()
|
||||
.name("Homebrew Exemple")
|
||||
.description("Template minimaliste à dupliquer pour créer votre propre système.")
|
||||
.author("LoreMind seed")
|
||||
.isPublic(false)
|
||||
.rulesMarkdown(HOMEBREW_EXAMPLE)
|
||||
.build()
|
||||
);
|
||||
}
|
||||
|
||||
private static final String NIMBLE_RULES = """
|
||||
Système Nimble — résolution rapide, narration fluide, peu de tableaux. Agnostique (aucun univers imposé).
|
||||
|
||||
## Combat
|
||||
- Initiative libre : les joueurs décrivent leur action dans l'ordre qu'ils veulent, le MJ joue les ennemis quand la fiction l'exige.
|
||||
- Résolution : 1d20 + mod, difficulté 10/15/20 (facile/normal/dur). 20 naturel = critique (double dégâts).
|
||||
- Dégâts : arme légère 1d6, arme lourde 1d10, projectile 1d8. Pas de table d'armure, l'armure augmente la difficulté à toucher.
|
||||
- Blessures : un PJ peut encaisser 3 blessures graves avant de tomber. Pas de PV fins — on raconte les coups.
|
||||
|
||||
## Classes
|
||||
- **Guerrier** : +2 en combat, peut relancer un dé de dégât 1×/scène.
|
||||
- **Explorateur** : +2 en perception/survie, ignore la première blessure d'une scène.
|
||||
- **Mage** : peut lancer un effet de magie par scène, nécessite une composante racontée.
|
||||
- **Barde** : +2 en social, peut inspirer un allié (relance de dé).
|
||||
|
||||
## Monstres
|
||||
Les monstres ont 3 stats : Menace (difficulté à toucher), Dégâts (dé de dégât), Résistance (nombre de blessures).
|
||||
Exemples : Gobelin (Menace 10, 1d6, 1), Ogre (Menace 13, 1d10, 3), Dragon adulte (Menace 18, 2d10, 6).
|
||||
""";
|
||||
|
||||
private static final String DND_SRD_RULES = """
|
||||
Extrait libre du SRD 5.1 (Open Game License). Pour les règles complètes, consulter le SRD officiel.
|
||||
|
||||
## Combat
|
||||
- Initiative : 1d20 + mod Dex au début du combat, ordre fixe par round.
|
||||
- Action par tour : une action, une action bonus (si classe le permet), une réaction, mouvement jusqu'à la vitesse.
|
||||
- Attaque : 1d20 + mod caractéristique + bonus maîtrise vs CA de la cible.
|
||||
- Dégâts : dé de l'arme + mod caractéristique. Critique sur 20 naturel (double les dés de dégâts).
|
||||
- Avantage/Désavantage : lancer 2d20 et garder le meilleur / pire.
|
||||
|
||||
## Classes
|
||||
- **Barbare** : d12 PV, rage (+dégâts, résistance). Caractéristique principale : Force.
|
||||
- **Barde** : d8 PV, sorts + inspiration bardique. Caractéristique : Charisme.
|
||||
- **Clerc** : d8 PV, sorts divins, canalise la divinité. Caractéristique : Sagesse.
|
||||
- **Druide** : d8 PV, sorts nature + forme animale. Caractéristique : Sagesse.
|
||||
- **Ensorceleur** : d6 PV, sorts innés + métamagie. Caractéristique : Charisme.
|
||||
- **Guerrier** : d10 PV, maîtrise martiale, second souffle. Caractéristique : Force ou Dextérité.
|
||||
- **Magicien** : d6 PV, livre de sorts, grande flexibilité. Caractéristique : Intelligence.
|
||||
- **Moine** : d8 PV, arts martiaux + ki. Caractéristique : Dextérité + Sagesse.
|
||||
- **Paladin** : d10 PV, sorts + serment + imposition des mains. Caractéristique : Force + Charisme.
|
||||
- **Rôdeur** : d10 PV, ennemi juré + explorateur + sorts. Caractéristique : Dextérité + Sagesse.
|
||||
- **Roublard** : d8 PV, attaque sournoise + expertise. Caractéristique : Dextérité.
|
||||
|
||||
## Monstres
|
||||
Stat block standard : CA, PV, Vitesse, For/Dex/Con/Int/Sag/Cha, jets de sauvegarde, compétences, sens, langues, Facteur de Puissance (FP).
|
||||
Exemples : Gobelin (FP 1/4, CA 15, 7 PV), Ogre (FP 2, CA 11, 59 PV), Dragon rouge adulte (FP 17, CA 19, 256 PV).
|
||||
""";
|
||||
|
||||
private static final String HOMEBREW_EXAMPLE = """
|
||||
Template vide à dupliquer et remplir pour créer votre propre système.
|
||||
|
||||
## Combat
|
||||
(Décrivez ici comment se résout un combat : initiative, jet d'attaque, dégâts, points de vie, critiques...)
|
||||
|
||||
## Classes
|
||||
(Listez les archétypes jouables : nom, stats de base, capacités signature.)
|
||||
|
||||
## Monstres
|
||||
(Format de stat block pour vos créatures : stats, capacités spéciales, FP/niveau.)
|
||||
|
||||
## Magie
|
||||
(Si votre système a un système de magie : écoles, coût, composantes, listes de sorts/pouvoirs.)
|
||||
|
||||
## Progression
|
||||
(Comment les PJ montent en puissance : XP, niveaux, acquisitions par niveau.)
|
||||
""";
|
||||
}
|
||||
@@ -37,6 +37,9 @@ public class ArcJpaEntity {
|
||||
@Column(name = "\"order\"", nullable = false)
|
||||
private int order;
|
||||
|
||||
@Column
|
||||
private String icon;
|
||||
|
||||
// Champs narratifs enrichis — ajoutés automatiquement par Hibernate DDL (ddl-auto=update)
|
||||
@Column(columnDefinition = "TEXT")
|
||||
private String themes;
|
||||
|
||||
@@ -45,6 +45,13 @@ public class CampaignJpaEntity {
|
||||
@Column(name = "lore_id")
|
||||
private String loreId;
|
||||
|
||||
/**
|
||||
* ID du GameSystem associé (nullable).
|
||||
* Weak reference inter-contexte — pas de @ManyToOne / pas de FK DB.
|
||||
*/
|
||||
@Column(name = "game_system_id")
|
||||
private String gameSystemId;
|
||||
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
createdAt = LocalDateTime.now();
|
||||
|
||||
@@ -37,6 +37,9 @@ public class ChapterJpaEntity {
|
||||
@Column(name = "\"order\"", nullable = false)
|
||||
private int order;
|
||||
|
||||
@Column
|
||||
private String icon;
|
||||
|
||||
// Champs narratifs enrichis — ajoutés automatiquement par Hibernate DDL (ddl-auto=update)
|
||||
@Column(name = "gm_notes", columnDefinition = "TEXT")
|
||||
private String gmNotes;
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
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 personnages (PJ) d'une campagne.
|
||||
* Pas de FK physique vers campaigns (weak reference cross-agrégat intra-contexte :
|
||||
* on reste dans le Campaign Context, mais l'agrégat Character est autonome).
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "characters")
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class CharacterJpaEntity {
|
||||
|
||||
@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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package com.loremind.infrastructure.persistence.entity;
|
||||
|
||||
import jakarta.persistence.CascadeType;
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.FetchType;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Index;
|
||||
import jakarta.persistence.OneToMany;
|
||||
import jakarta.persistence.OrderBy;
|
||||
import jakarta.persistence.PrePersist;
|
||||
import jakarta.persistence.PreUpdate;
|
||||
import jakarta.persistence.Table;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Persistance d'une conversation de chat IA.
|
||||
*
|
||||
* Les refs loreId / campaignId / entityId sont des weak references (String,
|
||||
* pas de FK) — coherent avec la politique inter-contexte du reste du code.
|
||||
* Indexes compose pour accelerer le listing par contexte dans la sidebar.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "conversations", indexes = {
|
||||
@Index(name = "idx_conv_lore_entity", columnList = "lore_id,entity_type,entity_id,updated_at"),
|
||||
@Index(name = "idx_conv_campaign_entity", columnList = "campaign_id,entity_type,entity_id,updated_at")
|
||||
})
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class ConversationJpaEntity {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(nullable = false)
|
||||
private String title;
|
||||
|
||||
@Column(name = "lore_id")
|
||||
private String loreId;
|
||||
|
||||
@Column(name = "campaign_id")
|
||||
private String campaignId;
|
||||
|
||||
@Column(name = "entity_type")
|
||||
private String entityType;
|
||||
|
||||
@Column(name = "entity_id")
|
||||
private String entityId;
|
||||
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
/**
|
||||
* Messages enfants. Charges a la demande (fetch=LAZY) pour ne pas plomber
|
||||
* le listing sidebar. Cascade ALL + orphanRemoval : la suppression d'une
|
||||
* conversation efface ses messages.
|
||||
*/
|
||||
@OneToMany(mappedBy = "conversation", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
|
||||
@OrderBy("createdAt ASC, id ASC")
|
||||
@Builder.Default
|
||||
private List<ConversationMessageJpaEntity> messages = new ArrayList<>();
|
||||
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
createdAt = now;
|
||||
updatedAt = now;
|
||||
}
|
||||
|
||||
@PreUpdate
|
||||
protected void onUpdate() {
|
||||
updatedAt = LocalDateTime.now();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.loremind.infrastructure.persistence.entity;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.PrePersist;
|
||||
import jakarta.persistence.Table;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.ToString;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* Persistance d'un message appartenant a une {@link ConversationJpaEntity}.
|
||||
* Les messages sont ordonnes par createdAt ASC (ordre d'ajout = ordre lu).
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "conversation_messages")
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class ConversationMessageJpaEntity {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
/** "user" | "assistant" | "system". */
|
||||
@Column(nullable = false, length = 16)
|
||||
private String role;
|
||||
|
||||
@Column(nullable = false, columnDefinition = "TEXT")
|
||||
private String content;
|
||||
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
/**
|
||||
* Reference vers la conversation parent. ToString exclu pour eviter une
|
||||
* boucle infinie quand Lombok genere toString() (conv -> messages -> conv...).
|
||||
*/
|
||||
@ManyToOne(optional = false)
|
||||
@JoinColumn(name = "conversation_id", nullable = false)
|
||||
@ToString.Exclude
|
||||
private ConversationJpaEntity conversation;
|
||||
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
createdAt = LocalDateTime.now();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.loremind.infrastructure.persistence.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* Entité JPA pour la persistance des GameSystems (systèmes de JDR).
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "game_systems")
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class GameSystemJpaEntity {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(nullable = false)
|
||||
private String name;
|
||||
|
||||
@Column(columnDefinition = "TEXT")
|
||||
private String description;
|
||||
|
||||
@Column(name = "rules_markdown", columnDefinition = "TEXT")
|
||||
private String rulesMarkdown;
|
||||
|
||||
@Column
|
||||
private String author;
|
||||
|
||||
@Column(name = "is_public", nullable = false)
|
||||
private boolean isPublic;
|
||||
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
createdAt = LocalDateTime.now();
|
||||
updatedAt = LocalDateTime.now();
|
||||
}
|
||||
|
||||
@PreUpdate
|
||||
protected void onUpdate() {
|
||||
updatedAt = LocalDateTime.now();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.loremind.infrastructure.persistence.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/**
|
||||
* Entite JPA pour la licence Patreon installee.
|
||||
* <p>
|
||||
* Singleton : une seule ligne par instance (id = "current"). Ce design permet
|
||||
* de ne jamais avoir de licence "fantome" en base et de simplifier les queries.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "licenses")
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class LicenseJpaEntity {
|
||||
|
||||
@Id
|
||||
private String id;
|
||||
|
||||
@Column(name = "raw_jwt", columnDefinition = "TEXT", nullable = false)
|
||||
private String rawJwt;
|
||||
|
||||
@Column(name = "patreon_user_id", nullable = false)
|
||||
private String patreonUserId;
|
||||
|
||||
@Column(name = "tier_id", nullable = false)
|
||||
private String tierId;
|
||||
|
||||
@Column(name = "instance_id", nullable = false)
|
||||
private String instanceId;
|
||||
|
||||
@Column(name = "issued_at", nullable = false)
|
||||
private Instant issuedAt;
|
||||
|
||||
@Column(name = "expires_at", nullable = false)
|
||||
private Instant expiresAt;
|
||||
|
||||
@Column(name = "last_refresh_attempt_at")
|
||||
private Instant lastRefreshAttemptAt;
|
||||
|
||||
@Column(name = "last_refresh_succeeded", nullable = false)
|
||||
private boolean lastRefreshSucceeded;
|
||||
|
||||
@Column(name = "beta_channel_enabled", nullable = false)
|
||||
private boolean betaChannelEnabled;
|
||||
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
private Instant createdAt;
|
||||
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
private Instant updatedAt;
|
||||
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
Instant now = Instant.now();
|
||||
if (createdAt == null) createdAt = now;
|
||||
updatedAt = now;
|
||||
}
|
||||
|
||||
@PreUpdate
|
||||
protected void onUpdate() {
|
||||
updatedAt = Instant.now();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.loremind.infrastructure.persistence.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* Entité JPA pour 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)
|
||||
private int order;
|
||||
|
||||
@Column
|
||||
private String icon;
|
||||
|
||||
// Champs narratifs enrichis — ajoutés automatiquement par Hibernate (ddl-auto=update)
|
||||
|
||||
// Contexte et ambiance
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.loremind.infrastructure.persistence.jpa;
|
||||
|
||||
import com.loremind.infrastructure.persistence.entity.CharacterJpaEntity;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Repository
|
||||
public interface CharacterJpaRepository extends JpaRepository<CharacterJpaEntity, Long> {
|
||||
|
||||
List<CharacterJpaEntity> findByCampaignIdOrderByOrderAsc(Long campaignId);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package com.loremind.infrastructure.persistence.jpa;
|
||||
|
||||
import com.loremind.infrastructure.persistence.entity.ConversationJpaEntity;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Repository Spring Data JPA pour ConversationJpaEntity.
|
||||
*
|
||||
* Les requetes de listing par contexte gerent explicitement les NULL parce
|
||||
* que JPQL `=` ne matche pas NULL. On combine `IS NULL` / `=` selon si le
|
||||
* filtre est fourni — plus simple qu'une Specification Criteria API.
|
||||
*/
|
||||
@Repository
|
||||
public interface ConversationJpaRepository extends JpaRepository<ConversationJpaEntity, Long> {
|
||||
|
||||
/** Listing Lore racine (entity_type IS NULL). */
|
||||
@Query("""
|
||||
SELECT c FROM ConversationJpaEntity c
|
||||
WHERE c.loreId = :loreId
|
||||
AND c.entityType IS NULL
|
||||
ORDER BY c.updatedAt DESC
|
||||
""")
|
||||
List<ConversationJpaEntity> findByLoreRoot(@Param("loreId") String loreId);
|
||||
|
||||
/** Listing Lore + entite precise. */
|
||||
@Query("""
|
||||
SELECT c FROM ConversationJpaEntity c
|
||||
WHERE c.loreId = :loreId
|
||||
AND c.entityType = :entityType
|
||||
AND c.entityId = :entityId
|
||||
ORDER BY c.updatedAt DESC
|
||||
""")
|
||||
List<ConversationJpaEntity> findByLoreAndEntity(
|
||||
@Param("loreId") String loreId,
|
||||
@Param("entityType") String entityType,
|
||||
@Param("entityId") String entityId);
|
||||
|
||||
/** Listing Campagne racine. */
|
||||
@Query("""
|
||||
SELECT c FROM ConversationJpaEntity c
|
||||
WHERE c.campaignId = :campaignId
|
||||
AND c.entityType IS NULL
|
||||
ORDER BY c.updatedAt DESC
|
||||
""")
|
||||
List<ConversationJpaEntity> findByCampaignRoot(@Param("campaignId") String campaignId);
|
||||
|
||||
/** Listing Campagne + entite precise. */
|
||||
@Query("""
|
||||
SELECT c FROM ConversationJpaEntity c
|
||||
WHERE c.campaignId = :campaignId
|
||||
AND c.entityType = :entityType
|
||||
AND c.entityId = :entityId
|
||||
ORDER BY c.updatedAt DESC
|
||||
""")
|
||||
List<ConversationJpaEntity> findByCampaignAndEntity(
|
||||
@Param("campaignId") String campaignId,
|
||||
@Param("entityType") String entityType,
|
||||
@Param("entityId") String entityId);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.loremind.infrastructure.persistence.jpa;
|
||||
|
||||
import com.loremind.infrastructure.persistence.entity.GameSystemJpaEntity;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Repository
|
||||
public interface GameSystemJpaRepository extends JpaRepository<GameSystemJpaEntity, Long> {
|
||||
|
||||
@Query("SELECT g FROM GameSystemJpaEntity g WHERE LOWER(g.name) LIKE LOWER(CONCAT('%', :query, '%'))")
|
||||
List<GameSystemJpaEntity> findByNameContainingIgnoreCase(@Param("query") String query);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.loremind.infrastructure.persistence.jpa;
|
||||
|
||||
import com.loremind.infrastructure.persistence.entity.LicenseJpaEntity;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
@Repository
|
||||
public interface LicenseJpaRepository extends JpaRepository<LicenseJpaEntity, String> {
|
||||
}
|
||||
@@ -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())
|
||||
.campaignId(jpaEntity.getCampaignId().toString())
|
||||
.order(jpaEntity.getOrder())
|
||||
.icon(jpaEntity.getIcon())
|
||||
.themes(jpaEntity.getThemes())
|
||||
.stakes(jpaEntity.getStakes())
|
||||
.gmNotes(jpaEntity.getGmNotes())
|
||||
@@ -99,6 +100,7 @@ public class PostgresArcRepository implements ArcRepository {
|
||||
.description(arc.getDescription())
|
||||
.campaignId(Long.parseLong(arc.getCampaignId()))
|
||||
.order(arc.getOrder())
|
||||
.icon(arc.getIcon())
|
||||
.themes(arc.getThemes())
|
||||
.stakes(arc.getStakes())
|
||||
.gmNotes(arc.getGmNotes())
|
||||
|
||||
@@ -71,6 +71,7 @@ public class PostgresCampaignRepository implements CampaignRepository {
|
||||
.updatedAt(jpaEntity.getUpdatedAt())
|
||||
.arcsCount(jpaEntity.getArcsCount())
|
||||
.loreId(jpaEntity.getLoreId())
|
||||
.gameSystemId(jpaEntity.getGameSystemId())
|
||||
.build();
|
||||
}
|
||||
|
||||
@@ -84,6 +85,7 @@ public class PostgresCampaignRepository implements CampaignRepository {
|
||||
.updatedAt(campaign.getUpdatedAt())
|
||||
.arcsCount(campaign.getArcsCount())
|
||||
.loreId(campaign.getLoreId())
|
||||
.gameSystemId(campaign.getGameSystemId())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@ public class PostgresChapterRepository implements ChapterRepository {
|
||||
.description(jpaEntity.getDescription())
|
||||
.arcId(jpaEntity.getArcId().toString())
|
||||
.order(jpaEntity.getOrder())
|
||||
.icon(jpaEntity.getIcon())
|
||||
.gmNotes(jpaEntity.getGmNotes())
|
||||
.playerObjectives(jpaEntity.getPlayerObjectives())
|
||||
.narrativeStakes(jpaEntity.getNarrativeStakes())
|
||||
@@ -96,6 +97,7 @@ public class PostgresChapterRepository implements ChapterRepository {
|
||||
.description(chapter.getDescription())
|
||||
.arcId(Long.parseLong(chapter.getArcId()))
|
||||
.order(chapter.getOrder())
|
||||
.icon(chapter.getIcon())
|
||||
.gmNotes(chapter.getGmNotes())
|
||||
.playerObjectives(chapter.getPlayerObjectives())
|
||||
.narrativeStakes(chapter.getNarrativeStakes())
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package com.loremind.infrastructure.persistence.postgres;
|
||||
|
||||
import com.loremind.domain.campaigncontext.Character;
|
||||
import com.loremind.domain.campaigncontext.ports.CharacterRepository;
|
||||
import com.loremind.infrastructure.persistence.entity.CharacterJpaEntity;
|
||||
import com.loremind.infrastructure.persistence.jpa.CharacterJpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Repository
|
||||
public class PostgresCharacterRepository implements CharacterRepository {
|
||||
|
||||
private final CharacterJpaRepository jpaRepository;
|
||||
|
||||
public PostgresCharacterRepository(CharacterJpaRepository jpaRepository) {
|
||||
this.jpaRepository = jpaRepository;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Character save(Character character) {
|
||||
CharacterJpaEntity entity = toJpaEntity(character);
|
||||
CharacterJpaEntity saved = jpaRepository.save(entity);
|
||||
return toDomainEntity(saved);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<Character> findById(String id) {
|
||||
return jpaRepository.findById(Long.parseLong(id)).map(this::toDomainEntity);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Character> 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 Character toDomainEntity(CharacterJpaEntity e) {
|
||||
return Character.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 CharacterJpaEntity toJpaEntity(Character c) {
|
||||
Long id = c.getId() != null ? Long.parseLong(c.getId()) : null;
|
||||
return CharacterJpaEntity.builder()
|
||||
.id(id)
|
||||
.name(c.getName())
|
||||
.markdownContent(c.getMarkdownContent())
|
||||
.campaignId(Long.parseLong(c.getCampaignId()))
|
||||
.order(c.getOrder())
|
||||
.createdAt(c.getCreatedAt())
|
||||
.updatedAt(c.getUpdatedAt())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package com.loremind.infrastructure.persistence.postgres;
|
||||
|
||||
import com.loremind.domain.conversationcontext.Conversation;
|
||||
import com.loremind.domain.conversationcontext.ConversationMessage;
|
||||
import com.loremind.domain.conversationcontext.ports.ConversationRepository;
|
||||
import com.loremind.infrastructure.persistence.entity.ConversationJpaEntity;
|
||||
import com.loremind.infrastructure.persistence.entity.ConversationMessageJpaEntity;
|
||||
import com.loremind.infrastructure.persistence.jpa.ConversationJpaRepository;
|
||||
import jakarta.persistence.EntityNotFoundException;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Adaptateur Postgres pour ConversationRepository.
|
||||
*
|
||||
* Les methodes de listing ne chargent PAS les messages (messages LAZY,
|
||||
* liste vide renvoyee cote domaine) — la sidebar n'a besoin que des
|
||||
* meta-donnees. findById charge les messages via fetch explicite de la
|
||||
* collection dans une transaction.
|
||||
*/
|
||||
@Repository
|
||||
public class PostgresConversationRepository implements ConversationRepository {
|
||||
|
||||
private final ConversationJpaRepository jpa;
|
||||
|
||||
public PostgresConversationRepository(ConversationJpaRepository jpa) {
|
||||
this.jpa = jpa;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public Conversation save(Conversation conversation) {
|
||||
ConversationJpaEntity entity = toJpaEntity(conversation);
|
||||
ConversationJpaEntity saved = jpa.save(entity);
|
||||
return toDomain(saved, true);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
public Optional<Conversation> findById(String id) {
|
||||
return jpa.findById(Long.parseLong(id))
|
||||
.map(e -> {
|
||||
// Force l'initialisation LAZY avant de sortir de la transaction.
|
||||
e.getMessages().size();
|
||||
return toDomain(e, true);
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
public List<Conversation> findByContext(String loreId, String campaignId, String entityType, String entityId) {
|
||||
List<ConversationJpaEntity> rows;
|
||||
if (loreId != null) {
|
||||
rows = (entityType == null)
|
||||
? jpa.findByLoreRoot(loreId)
|
||||
: jpa.findByLoreAndEntity(loreId, entityType, entityId);
|
||||
} else if (campaignId != null) {
|
||||
rows = (entityType == null)
|
||||
? jpa.findByCampaignRoot(campaignId)
|
||||
: jpa.findByCampaignAndEntity(campaignId, entityType, entityId);
|
||||
} else {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
return rows.stream().map(e -> toDomain(e, false)).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void deleteById(String id) {
|
||||
jpa.deleteById(Long.parseLong(id));
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public ConversationMessage appendMessage(String conversationId, ConversationMessage message) {
|
||||
ConversationJpaEntity conv = jpa.findById(Long.parseLong(conversationId))
|
||||
.orElseThrow(() -> new EntityNotFoundException("Conversation " + conversationId));
|
||||
|
||||
ConversationMessageJpaEntity msg = ConversationMessageJpaEntity.builder()
|
||||
.role(message.getRole())
|
||||
.content(message.getContent())
|
||||
.conversation(conv)
|
||||
.build();
|
||||
conv.getMessages().add(msg);
|
||||
// Force updatedAt via @PreUpdate en modifiant la conv (touch).
|
||||
conv.setUpdatedAt(java.time.LocalDateTime.now());
|
||||
|
||||
ConversationJpaEntity saved = jpa.save(conv);
|
||||
ConversationMessageJpaEntity persisted = saved.getMessages().get(saved.getMessages().size() - 1);
|
||||
return toDomainMessage(persisted);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void updateTitle(String conversationId, String title) {
|
||||
ConversationJpaEntity conv = jpa.findById(Long.parseLong(conversationId))
|
||||
.orElseThrow(() -> new EntityNotFoundException("Conversation " + conversationId));
|
||||
conv.setTitle(title);
|
||||
jpa.save(conv);
|
||||
}
|
||||
|
||||
// ---------- Mapping ----------
|
||||
|
||||
private ConversationJpaEntity toJpaEntity(Conversation c) {
|
||||
Long id = c.getId() != null ? Long.parseLong(c.getId()) : null;
|
||||
return ConversationJpaEntity.builder()
|
||||
.id(id)
|
||||
.title(c.getTitle())
|
||||
.loreId(c.getLoreId())
|
||||
.campaignId(c.getCampaignId())
|
||||
.entityType(c.getEntityType())
|
||||
.entityId(c.getEntityId())
|
||||
.createdAt(c.getCreatedAt())
|
||||
.updatedAt(c.getUpdatedAt())
|
||||
.build();
|
||||
}
|
||||
|
||||
private Conversation toDomain(ConversationJpaEntity e, boolean withMessages) {
|
||||
List<ConversationMessage> msgs = withMessages
|
||||
? e.getMessages().stream().map(this::toDomainMessage).collect(Collectors.toList())
|
||||
: new java.util.ArrayList<>();
|
||||
return Conversation.builder()
|
||||
.id(e.getId().toString())
|
||||
.title(e.getTitle())
|
||||
.loreId(e.getLoreId())
|
||||
.campaignId(e.getCampaignId())
|
||||
.entityType(e.getEntityType())
|
||||
.entityId(e.getEntityId())
|
||||
.createdAt(e.getCreatedAt())
|
||||
.updatedAt(e.getUpdatedAt())
|
||||
.messages(msgs)
|
||||
.build();
|
||||
}
|
||||
|
||||
private ConversationMessage toDomainMessage(ConversationMessageJpaEntity e) {
|
||||
return ConversationMessage.builder()
|
||||
.id(e.getId() != null ? e.getId().toString() : null)
|
||||
.role(e.getRole())
|
||||
.content(e.getContent())
|
||||
.createdAt(e.getCreatedAt())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package com.loremind.infrastructure.persistence.postgres;
|
||||
|
||||
import com.loremind.domain.gamesystemcontext.GameSystem;
|
||||
import com.loremind.domain.gamesystemcontext.ports.GameSystemRepository;
|
||||
import com.loremind.infrastructure.persistence.entity.GameSystemJpaEntity;
|
||||
import com.loremind.infrastructure.persistence.jpa.GameSystemJpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Repository
|
||||
public class PostgresGameSystemRepository implements GameSystemRepository {
|
||||
|
||||
private final GameSystemJpaRepository jpaRepository;
|
||||
|
||||
public PostgresGameSystemRepository(GameSystemJpaRepository jpaRepository) {
|
||||
this.jpaRepository = jpaRepository;
|
||||
}
|
||||
|
||||
@Override
|
||||
public GameSystem save(GameSystem gameSystem) {
|
||||
GameSystemJpaEntity entity = toJpaEntity(gameSystem);
|
||||
GameSystemJpaEntity saved = jpaRepository.save(entity);
|
||||
return toDomainEntity(saved);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<GameSystem> findById(String id) {
|
||||
return jpaRepository.findById(Long.parseLong(id)).map(this::toDomainEntity);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<GameSystem> findAll() {
|
||||
return jpaRepository.findAll().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));
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<GameSystem> searchByName(String query) {
|
||||
return jpaRepository.findByNameContainingIgnoreCase(query).stream()
|
||||
.map(this::toDomainEntity)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
private GameSystem toDomainEntity(GameSystemJpaEntity e) {
|
||||
return GameSystem.builder()
|
||||
.id(e.getId().toString())
|
||||
.name(e.getName())
|
||||
.description(e.getDescription())
|
||||
.rulesMarkdown(e.getRulesMarkdown())
|
||||
.author(e.getAuthor())
|
||||
.isPublic(e.isPublic())
|
||||
.createdAt(e.getCreatedAt())
|
||||
.updatedAt(e.getUpdatedAt())
|
||||
.build();
|
||||
}
|
||||
|
||||
private GameSystemJpaEntity toJpaEntity(GameSystem g) {
|
||||
Long id = g.getId() != null ? Long.parseLong(g.getId()) : null;
|
||||
return GameSystemJpaEntity.builder()
|
||||
.id(id)
|
||||
.name(g.getName())
|
||||
.description(g.getDescription())
|
||||
.rulesMarkdown(g.getRulesMarkdown())
|
||||
.author(g.getAuthor())
|
||||
.isPublic(g.isPublic())
|
||||
.createdAt(g.getCreatedAt())
|
||||
.updatedAt(g.getUpdatedAt())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package com.loremind.infrastructure.persistence.postgres;
|
||||
|
||||
import com.loremind.domain.licensing.License;
|
||||
import com.loremind.domain.licensing.ports.LicenseRepository;
|
||||
import com.loremind.infrastructure.persistence.entity.LicenseJpaEntity;
|
||||
import com.loremind.infrastructure.persistence.jpa.LicenseJpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Optional;
|
||||
|
||||
@Repository
|
||||
public class PostgresLicenseRepository implements LicenseRepository {
|
||||
|
||||
static final String CURRENT_ID = "current";
|
||||
|
||||
private final LicenseJpaRepository jpa;
|
||||
|
||||
public PostgresLicenseRepository(LicenseJpaRepository jpa) {
|
||||
this.jpa = jpa;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<License> findCurrent() {
|
||||
return jpa.findById(CURRENT_ID).map(this::toDomain);
|
||||
}
|
||||
|
||||
@Override
|
||||
public License save(License license) {
|
||||
LicenseJpaEntity entity = toEntity(license);
|
||||
if (entity.getCreatedAt() == null) {
|
||||
entity.setCreatedAt(Instant.now());
|
||||
}
|
||||
LicenseJpaEntity saved = jpa.save(entity);
|
||||
return toDomain(saved);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deleteCurrent() {
|
||||
jpa.deleteById(CURRENT_ID);
|
||||
}
|
||||
|
||||
private License toDomain(LicenseJpaEntity e) {
|
||||
return License.builder()
|
||||
.id(e.getId())
|
||||
.rawJwt(e.getRawJwt())
|
||||
.patreonUserId(e.getPatreonUserId())
|
||||
.tierId(e.getTierId())
|
||||
.instanceId(e.getInstanceId())
|
||||
.issuedAt(e.getIssuedAt())
|
||||
.expiresAt(e.getExpiresAt())
|
||||
.lastRefreshAttemptAt(e.getLastRefreshAttemptAt())
|
||||
.lastRefreshSucceeded(e.isLastRefreshSucceeded())
|
||||
.betaChannelEnabled(e.isBetaChannelEnabled())
|
||||
.createdAt(e.getCreatedAt())
|
||||
.updatedAt(e.getUpdatedAt())
|
||||
.build();
|
||||
}
|
||||
|
||||
private LicenseJpaEntity toEntity(License l) {
|
||||
return LicenseJpaEntity.builder()
|
||||
.id(CURRENT_ID)
|
||||
.rawJwt(l.getRawJwt())
|
||||
.patreonUserId(l.getPatreonUserId())
|
||||
.tierId(l.getTierId())
|
||||
.instanceId(l.getInstanceId())
|
||||
.issuedAt(l.getIssuedAt())
|
||||
.expiresAt(l.getExpiresAt())
|
||||
.lastRefreshAttemptAt(l.getLastRefreshAttemptAt())
|
||||
.lastRefreshSucceeded(l.isLastRefreshSucceeded())
|
||||
.betaChannelEnabled(l.isBetaChannelEnabled())
|
||||
.createdAt(l.getCreatedAt())
|
||||
.updatedAt(l.getUpdatedAt())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
.chapterId(jpaEntity.getChapterId().toString())
|
||||
.order(jpaEntity.getOrder())
|
||||
.icon(jpaEntity.getIcon())
|
||||
.location(jpaEntity.getLocation())
|
||||
.timing(jpaEntity.getTiming())
|
||||
.atmosphere(jpaEntity.getAtmosphere())
|
||||
@@ -104,6 +105,7 @@ public class PostgresSceneRepository implements SceneRepository {
|
||||
.description(scene.getDescription())
|
||||
.chapterId(Long.parseLong(scene.getChapterId()))
|
||||
.order(scene.getOrder())
|
||||
.icon(scene.getIcon())
|
||||
.location(scene.getLocation())
|
||||
.timing(scene.getTiming())
|
||||
.atmosphere(scene.getAtmosphere())
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user