Compare commits

...

66 Commits

Author SHA1 Message Date
70ec1f2fb9 Modification du timeout pour permettre d'analyser de plus gros livres
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Build & Push Images / build-switcher (push) Has been cancelled
2026-06-08 14:49:00 +02:00
f638fdd24b Mise à jour d'un test unitaire qui ne passait plus avec la mise à jour précédente
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Build & Push Images / build-switcher (push) Has been cancelled
2026-06-08 11:11:50 +02:00
e85ab0e6b1 passage version 0.11.1-beta 2026-06-08 11:05:48 +02:00
ed22d9f29c Résolution d'un bug de switch entre les PNJ lorsqu'on essai de passer directement de l'un à l'autre
Ajout de dossiers pour la partie PNJ pour qu'on puisse les regrouper par cité par exemple et que ce soit plus lisible
2026-06-08 11:05:07 +02:00
edc4434298 Plusieurs gros ajouts :
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Build & Push Images / build-switcher (push) Has been cancelled
- Possibilité de discuter avec un PDF ; RAG ou analyse approfondie. Enlèvement de l'autre outil PDF de discussion qui analysait d'abord un PDF en proposant directement une intégration sans attendre qu'on pose de question
- Mise en place de l'import directement dans les outils dans la sidebar
- Mise en place d'un outil pour créer des tables aléatoires avec possibilité d'utiliser pendant la partie
- Mise en place d'un outil pour mettre en place des PNJ, scènes, chapitre.... directement à partir de la discussion avec le PDF
- Mise en place RAG avec mistal-embeding ou nomic si on utilise ollama
- Mise en place mistral, google en fournisseurs alternatifs pour l'IA dans le cloud
- version 0.11.0-bêta
2026-06-07 09:52:15 +02:00
5eb15dc449 passage 0.10.4
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Build & Push Images / build-switcher (push) Has been cancelled
2026-06-05 16:54:41 +02:00
da5b602f20 Meilleure gestion des erreurs, moins permissif au niveau de la créativité du modèle. 2026-06-05 16:54:28 +02:00
9ea43a1889 Ajout d'open router en fournisseur IA ; ajout de la possibilité de mettre des conditions de déverouillage pour les chapitres optionnels
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Build & Push Images / build-switcher (push) Has been cancelled
2026-06-05 00:23:19 +02:00
211e26dae1 changement de version
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Build & Push Images / build-switcher (push) Has been cancelled
2026-06-04 14:14:19 +02:00
53065c952b Correction de la configuration nginx pour ne pas bloquer un gros PDF 2026-06-04 14:13:41 +02:00
6d00543a59 Ajout de 2 fonctionnalitées principales : import PDF que ce soit pour les règles ou les campagnes directement.
Some checks failed
Build & Push Images / build (web) (push) Has been cancelled
Build & Push Images / build-switcher (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (brain) (push) Has been cancelled
Fonctionnalité de comparaison PDF / campagne pour faire un mix et demander des conseils à l'IA
2026-06-04 13:55:27 +02:00
091de0daf7 Mise à jour des tests unitaires et passage en v0.9.2-beta en conséquence.
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Build & Push Images / build-switcher (push) Has been cancelled
2026-06-03 15:56:26 +02:00
504c4b7b6c Plusieurs ajouts :
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Build & Push Images / build-switcher (push) Has been cancelled
- Possibilité de configurer des lieux dans une scène : permet de configurer un donjon par exemple avec les pièces, les trésors par pièce, la narration..... Mise en place également de conditions permettant de conditionner le déblocage des quêtes.
- Possibilité de transformé un arc en instance non linéaire afin de faire un hub. Permet de jouer de préparer des campagnes type Dragon of Icespire peak plus facilement.
- Configuration de partie : chaque partie va contenir les séances, ce qui permettra de suivre le déblocage des conditions pour les quêtes.

Passage en 0.9.1-beta
2026-06-03 15:44:48 +02:00
e3fc96a1bc Ajout d'un mode "jeu" (possibilité de lancer des sessions dans une campagne). Cela permet de faire de prendre des notes en live au cours d'une partie et d'avoir plusieurs outils sous la main pour aider le mj :
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Build & Push Images / build-switcher (push) Has been cancelled
- Possibilité de parler à une IA pour règle de jeu ou élément de lore / campagne au cours d'une partie comme aide mémoire
- Onglet dédié aux personnages de la campagne
- Onglet dédié aux scènes
- Onglet avec dès pour ceux qui souhaitent ;

Possibilité de rajouté une note en tant qu'évènement, jet de dès ou encore action du joueur par exemple. D'autres ajouts seront fait dans le futur (notamment des tables aléatoires pour PNJ en live).
2026-05-20 14:59:26 +02:00
bc05b1536d Correction du bug de switch entre lore / campagne et la sidebar qui ne s'actualise pas en conséquence. Ajout d'un test playwright pour éviter toute régression à l'avenir 2026-05-19 19:15:00 +02:00
0e350142de mise à jour vers 0.8.7-beta
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Build & Push Images / build-switcher (push) Has been cancelled
2026-05-19 18:45:56 +02:00
11cb708de9 Mise à jour du switcher pour régler le soucis de switch entre stable et bêta
Some checks failed
E2E Tests / e2e (push) Failing after 29s
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Build & Push Images / build-switcher (push) Has been cancelled
2026-05-19 18:36:00 +02:00
53343c0a75 Mise à jour de la config du switcher pour prendre les crédit du GHCR + sh plus verbeux en cas de bugs
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Build & Push Images / build-switcher (push) Has been cancelled
2026-05-19 18:25:54 +02:00
43f45d68a1 Mise à jour vers 0.8.5 ; ajout de la bascule entre le canal bêta et le canal stable 2026-05-19 18:05:17 +02:00
4bbc6333fc Ajout d'un globalExceptionHandler pour intercepter toutes les erreurs possibles et avoir un peu plus de détails.
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Suppression du détail de la mise à jour de chaque composant : l'utilisateur ce fiche de savoir composant x / y à jour car on fera la mise à jour pour tout à chaque fois
(même montée en version pour chaque composant même si composant y non touché par exemple... c'est la montée en version de l'appli qui compte)
2026-05-19 14:38:38 +02:00
dc908b8d93 Mise à jour du pom coté pore pour intégrer une dépendance vers crypto.tink ; sinon la vérification de la licence patreon ne marche pas.
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
2026-05-19 14:06:57 +02:00
ae9455c244 Ajout de tests playwright et correction de tests non passant (pour les tests ajoutés : partie game system ).
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Correction de plusieurs anomalies : problème de switch entre 2 templates (par exemple si on était sur un template 1 et qu'on voulait passer directement au 2, ce dernier ne chargeait pas) ;
correction du soucis d'apparition de la sidebar à gauche qui disparaissait sans explication ; problème de redirection : lorsqu'on terminait de créer un PJ / PNJ ; on arrivait sur l'accueil de la campagne au lieu de voir le résultat de la création.
Problème de redirection également lors du clique sur un PNJ / PJ sur le coté : on arrivait sur l'édition au lieu de la présentation. Correction de la première lettre stylisée : tout est au même style comme ça plus de probleme de lecture.

Nouveautées : stylisation des modales (notamment suppression, warning.....) avec en prime l'ajout d'un warning lors du changement de système pour avertir que les fiches persos ne sont pas conservées.
Ajout d'une option pour créer un game system directement à la création d'une campagne afin de faciliter la mise en place de cette dernière.
Ajout d'un bouton pour créer un nouveau template directement lorsqu'on créer une page : ça permet de créer un template et de revenir sur la page qu'on était en train de créer sans perdre le titre.

Passage en bêta 0.8.4
2026-05-19 13:37:22 +02:00
e346bb4b85 Changement du readme 2026-05-17 18:04:24 +02:00
a1c9e67013 Refonte de toute la partie fiche de personnage avec mise en place d'un nouveau bloc de liste d'attribut (pour tout ce qui sera statistiques, compétences etc....)
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Passage V0.8.3
2026-04-30 15:53:38 +02:00
b0edfb5aab Mise en place du picker d'image pour la partie header / illustration des fiches personnage
Some checks failed
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Build & Push Images / build (brain) (push) Has been cancelled
Migration pour l'ancienne partie des fiches perso vers les nouvelles pages
Vue retravaillée pour les fiches perso
2026-04-30 10:54:27 +02:00
117334315f Refonte du système JDR + système de personnage joueurs / non joueurs :
- Système de templating dans le game system : en effet, les templates sont liés au game system car les fiches personnages ne sont pas forcément les même selon les jeux (perso Dnd possède + de compétences que Nimble par exemple)
- changement des fiches personnages pour adapter le templating au niveau des campagnes et remplir des pages de perso
2026-04-30 10:42:09 +02:00
8e0c1c1017 Mise en place d'un composant permettant d'améliorer l'experience de mise à jour (via un rafraichissement de l'appli).
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Modification de la partie web pour prendre la modification en compte
2026-04-29 14:39:30 +02:00
cb9eb14054 Correction problème mise à jour : l'application ne voyait pas les mises à jour quand on lançait docker après avoir push la dernière version.
Effectivement : au demarrage, docker ce mettait automatiquement sur la dernière version alors qu'il n'avait pas necessairement récupérer, ducoup comparaison faisait true et on arrivait pas à avoir la derniere version du code.
Push de la clé jwt publique : sinon pas incluse dans le jar finale et la section patreon n'apparaissait pas.
2026-04-29 10:56:37 +02:00
fd14e1e572 Correction updateCheckServiceTest qui faisait planter le build gitea
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
2026-04-28 19:12:09 +02:00
f3c445cfa1 Mise en place de la connexion au canal privé pour la bêta avec Patreon et passage en v0.8.0 2026-04-28 19:04:11 +02:00
1a5a87629d Autre patch dockerfile
Some checks failed
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Build & Push Images / build (brain) (push) Has been cancelled
2026-04-27 22:11:43 +02:00
e61bcd5dbb Patch dockerfile bookworm a lieu de alpine pour corriger le problème de build 2026-04-27 21:56:04 +02:00
bc8fb30398 Patch dockerfile pour ne plus que le build plante 2026-04-27 21:43:13 +02:00
ec45104a80 Correction package-lock 2026-04-27 19:17:01 +02:00
3f8cdd177b Modification lors de la création d'élément de campagne : quand on créer un nouvel élément, on arrive sur la modification et non le résumé de l'élément 2026-04-27 19:03:58 +02:00
4ed9104a53 Correction du soucis de mise à jour via l'application
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
2026-04-27 16:19:56 +02:00
defbf2d1f7 Passage V0.7.0
Some checks failed
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Build & Push Images / build (brain) (push) Has been cancelled
2026-04-27 15:51:13 +02:00
d2cf9f8c5c Changement sur le Readme
Ajout d'une partie spécifique pour des PNJ dans la partie campagne
2026-04-27 15:48:04 +02:00
a92e31b187 Mise à jour du readme d'accueil pour l'accès à la documentation 2026-04-27 08:27:38 +02:00
00627d1543 Passage version 0.6.14 + résolution d'un soucis sur l'updater depuis la migration sur git
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
2026-04-26 19:08:49 +02:00
47a8fc529d Mise en place de la pipeline pour github plutot que gitea ; mise en place des images docker sur GHCR plutôt que gitea
Some checks failed
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Build & Push Images / build (brain) (push) Has been cancelled
Passage version v0.6.13
2026-04-26 10:46:46 +02:00
c524516b2a Empêche la modale de ce fermer tant que le llm n'est pas télécharger
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
2026-04-26 09:12:36 +02:00
6a4ae5c326 Forçage HTTP/1.1 pour la partie python et passage en v0.6.11
Some checks failed
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Build & Push Images / build (brain) (push) Has been cancelled
2026-04-26 01:55:02 +02:00
4e01fdfa9c Correction d'un bug lors de tentative de téléchargement de llm pour ollama
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
2026-04-26 01:45:39 +02:00
b60a790c70 Passage version 0.6.9
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
2026-04-26 01:30:35 +02:00
147d0701db Changement du watchtower pour une version plus récente : projet originel abandonné, repris par un fork. 2026-04-26 01:19:58 +02:00
98e54613ce Mise en place v0.6.8
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Amélioration de l'installation automatique
Ajout de la possibilité de télécharger le llm que l'on veut à l'interieur de l'application en communicant avec ollama
2026-04-26 01:11:04 +02:00
c47f987cef Mise à jour de la conf pour être sur que le cache angular est bien refresh
Mise à jour des installeurs
Mise en place de secure-host pour ne pas exposer Ollama à l'exterieur
2026-04-26 00:18:49 +02:00
7cd0a8253a Correction pour éviter que la fenêtre ce ferme sans qu'on voit le message d'erreur
Some checks failed
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Build & Push Images / build (brain) (push) Has been cancelled
2026-04-25 18:37:40 +02:00
7b189acee9 Ajout d'un .bat pour l'exécution du .ps1 2026-04-25 18:34:52 +02:00
08c91dd4c6 Changement sur l'installation automatique : réduction des patterns suspects dans l'installation pour les antivirus (par exemple, monter automatiquement les privilèges en admin...),
afin d'éviter que l'appli ne soit détectée comme un virus
2026-04-25 18:24:44 +02:00
dbf0b13189 Ajout d'un script pour installation automatique du produit
Some checks failed
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Build & Push Images / build (brain) (push) Has been cancelled
Ajout d'une partie mise à jour automatique : plus besoin de docker pull en ligne de commande ; on peut passer par l'interface
Refactoring partie Java pour respecter d'avantage le DDD : plus de jackson dans la partie domain

Passage version 0.6.6
2026-04-25 13:24:32 +02:00
aba02ca9cf Evolutions :
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
- Ajout d'icônes dans la scène, chapitre et arc
- Possibilité de bouger les cases dans la partie graphe et les textes associés si ces derniers ne sont pas visibles
- Changement sur le thème du graphe : mode sombre et plus blanc
- Barre d'action en haut, même pour la partie scène
- Mode sticky corrigé : plus de trou entre le haut du navigateur web et de la barre d'action

Passage version 0.6.5
2026-04-25 11:41:14 +02:00
854110683b Correction d'un test unitaire
Ajout d'un champs image dans les templates par défaut en + du champs nom, description pour avoir un exemple
Correction du visuel du champs d'ajout lors de la modification d'un template (apparition ligne pleine au lieu de texte en pointillé)
Ajout d'un intercepteur pour la partie démo de l'application afin de bien rafraichir le cache angular lorsque le temps de démo est expiré
2026-04-25 09:23:56 +02:00
3ad8145572 Fix : problème d'ascenseur en bas de la page au niveau des templates
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Sélection du template par défaut lors de la création d'une page en fonction du dossier
Passage v0.6.2
2026-04-25 01:39:05 +02:00
0d27ea71b0 Ajout de test, correctif d'un problème d'horloge pour le workflow gitea actions pour le e2e 2026-04-25 00:51:32 +02:00
f6b4e3ff82 Fix workflow gitea action pour e2e (tests automatisés via playwright) + correction d'une incohérence dans l'API coté java. Ajout d'autres tests utilisateur 2026-04-25 00:45:04 +02:00
2a438b79ce Mise en place de tests utilisateurs avec playwright pour la partie angular + corrections au niveau des labels avec for et id pour cliquer dessus 2026-04-25 00:25:53 +02:00
6ab7f64909 Fix rate limit derriere Cloudflare + CORS sur POST demo 2026-04-24 08:55:40 +02:00
0ad1d09802 Fix CORS 403 sur POST : passer APP_CORS_ALLOWED_ORIGINS au core démo 2026-04-24 08:46:26 +02:00
c6a228a48f Remplace docker SDK par appels HTTP directs (zero deps)
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
2026-04-24 07:39:49 +02:00
9eabd2abf8 Docker SDK v28 pour resoudre les conflits transitifs 2026-04-24 07:33:48 +02:00
a23f82d635 Correction d'une dépendance go 2026-04-24 07:30:20 +02:00
280dced405 Mise à jour du dockerfile suite à une dépendance trop ancienne sur go 2026-04-24 07:26:42 +02:00
987187943e Orchestrateur go pour lancer la démo et mettre en place plusieurs instances docker 2026-04-23 17:49:26 +02:00
2e7d46cc63 Changement dans la config pour éviter les url en dur + mise en place d'un mode démo 2026-04-23 17:15:08 +02:00
577 changed files with 39004 additions and 3502 deletions

View File

@@ -38,3 +38,15 @@ LLM_MODEL=gemma4:26b
# 1min.ai (si LLM_PROVIDER=onemin) # 1min.ai (si LLM_PROVIDER=onemin)
ONEMIN_API_KEY= ONEMIN_API_KEY=
ONEMIN_MODEL=gpt-4o-mini ONEMIN_MODEL=gpt-4o-mini
# --- Mises a jour automatiques (Watchtower) ------------------------------
# Watchtower verifie les nouvelles versions de core/brain/web et permet
# le declenchement manuel via l'UI (bouton "Mettre a jour"). Postgres et
# MinIO sont exclus volontairement.
#
# Activer : COMPOSE_PROFILES=autoupdate + WATCHTOWER_TOKEN non vide.
# COMPOSE_PROFILES=autoupdate
# WATCHTOWER_TOKEN=change-me-use-openssl-rand-hex-32
# WATCHTOWER_MONITOR_ONLY=false # true = detecter sans appliquer
# WATCHTOWER_SCHEDULE=0 0 4 * * *
# TZ=Europe/Paris

95
.gitea/workflows/e2e.yml Normal file
View 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

View File

@@ -6,8 +6,10 @@ on:
- 'v*' - 'v*'
env: env:
REGISTRY: git.igmlcreation.fr GITEA_REGISTRY: git.igmlcreation.fr
REGISTRY_USER: ietm64 GITEA_REGISTRY_USER: ietm64
GHCR_REGISTRY: ghcr.io
GHCR_NAMESPACE: igmlcreation
jobs: jobs:
build: build:
@@ -26,19 +28,114 @@ jobs:
- name: Login to Gitea Registry - name: Login to Gitea Registry
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
registry: ${{ env.REGISTRY }} registry: ${{ env.GITEA_REGISTRY }}
username: ${{ env.REGISTRY_USER }} username: ${{ env.GITEA_REGISTRY_USER }}
password: ${{ secrets.DOCKER_PAT }} password: ${{ secrets.DOCKER_PAT }}
- name: Extract version # Login to GHCR (GitHub Container Registry) pour distribuer les images
id: meta # publiquement aux utilisateurs finaux. Reputation domaine plus elevee
run: echo "version=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT # 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: Build & push ${{ matrix.component }} # Detection du canal :
# - tag vX.Y.Z -> stable (push :latest + :version sur les repos publics)
# - tag vX.Y.Z-beta* -> beta (push :beta + :version sur les repos GHCR prives
# loremind-beta-<component> ; backup Gitea avec :version)
- name: Extract version & channel
id: meta
run: |
VERSION="${GITHUB_REF_NAME#v}"
echo "version=${VERSION}" >> $GITHUB_OUTPUT
if [[ "${VERSION}" == *-beta* ]]; then
echo "channel=beta" >> $GITHUB_OUTPUT
else
echo "channel=stable" >> $GITHUB_OUTPUT
fi
# Build & push canal STABLE
- name: Build & push ${{ matrix.component }} (stable)
if: steps.meta.outputs.channel == 'stable'
uses: docker/build-push-action@v5 uses: docker/build-push-action@v5
with: with:
context: ./${{ matrix.component }} context: ./${{ matrix.component }}
push: true push: true
tags: | tags: |
${{ env.REGISTRY }}/${{ env.REGISTRY_USER }}/${{ matrix.component }}:latest ${{ env.GITEA_REGISTRY }}/${{ env.GITEA_REGISTRY_USER }}/${{ matrix.component }}:latest
${{ env.REGISTRY }}/${{ env.REGISTRY_USER }}/${{ matrix.component }}:${{ steps.meta.outputs.version }} ${{ env.GITEA_REGISTRY }}/${{ env.GITEA_REGISTRY_USER }}/${{ matrix.component }}:${{ steps.meta.outputs.version }}
${{ env.GHCR_REGISTRY }}/${{ env.GHCR_NAMESPACE }}/loremind-${{ matrix.component }}:latest
${{ env.GHCR_REGISTRY }}/${{ env.GHCR_NAMESPACE }}/loremind-${{ matrix.component }}:${{ steps.meta.outputs.version }}
# Build & push canal BETA
# GHCR : repos prives loremind-beta-<component> (gated par PAT distribue
# via le relais Patreon aux tiers Compagnon).
# Gitea : backup prive avec :version uniquement (pas de :latest pour ne
# pas faire upgrader les installs branchees sur Gitea).
- name: Build & push ${{ matrix.component }} (beta)
if: steps.meta.outputs.channel == 'beta'
uses: docker/build-push-action@v5
with:
context: ./${{ matrix.component }}
push: true
tags: |
${{ env.GITEA_REGISTRY }}/${{ env.GITEA_REGISTRY_USER }}/${{ matrix.component }}:${{ steps.meta.outputs.version }}
${{ env.GHCR_REGISTRY }}/${{ env.GHCR_NAMESPACE }}/loremind-beta-${{ matrix.component }}:beta
${{ env.GHCR_REGISTRY }}/${{ env.GHCR_NAMESPACE }}/loremind-beta-${{ matrix.component }}:${{ steps.meta.outputs.version }}
# Job separe pour le sidecar `switcher`.
# Pourquoi separe : le switcher est volontairement HORS de IMAGE_NAMESPACE
# (cf. docker-compose.yml). Il est toujours pulle depuis le repo public
# `loremind-switcher`, quel que soit le canal de l'instance. On le build
# donc uniquement sur les releases stables — pas la peine de re-publier
# une variante beta du switcher, c'est une infrastructure neutre.
build-switcher:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Detect channel
id: meta
run: |
VERSION="${GITHUB_REF_NAME#v}"
echo "version=${VERSION}" >> $GITHUB_OUTPUT
if [[ "${VERSION}" == *-beta* ]]; then
echo "channel=beta" >> $GITHUB_OUTPUT
else
echo "channel=stable" >> $GITHUB_OUTPUT
fi
- name: Login to Gitea Registry
if: steps.meta.outputs.channel == 'stable'
uses: docker/login-action@v3
with:
registry: ${{ env.GITEA_REGISTRY }}
username: ${{ env.GITEA_REGISTRY_USER }}
password: ${{ secrets.DOCKER_PAT }}
- name: Login to GHCR
if: steps.meta.outputs.channel == 'stable'
uses: docker/login-action@v3
with:
registry: ${{ env.GHCR_REGISTRY }}
username: ${{ env.GHCR_NAMESPACE }}
password: ${{ secrets.GHCR_TOKEN }}
- name: Build & push switcher (stable only)
if: steps.meta.outputs.channel == 'stable'
uses: docker/build-push-action@v5
with:
context: ./switcher
push: true
tags: |
${{ env.GITEA_REGISTRY }}/${{ env.GITEA_REGISTRY_USER }}/switcher:latest
${{ env.GITEA_REGISTRY }}/${{ env.GITEA_REGISTRY_USER }}/switcher:${{ steps.meta.outputs.version }}
${{ env.GHCR_REGISTRY }}/${{ env.GHCR_NAMESPACE }}/loremind-switcher:latest
${{ env.GHCR_REGISTRY }}/${{ env.GHCR_NAMESPACE }}/loremind-switcher:${{ steps.meta.outputs.version }}

19
.gitignore vendored
View File

@@ -7,6 +7,11 @@
brain/data/settings.json brain/data/settings.json
*.key *.key
*.pem *.pem
# Exception : la cle PUBLIQUE JWT du relais Patreon est destinee a etre
# embarquee dans le binaire. Pas de risque a la committer (c'est une cle
# publique par construction). Sans cette exception, le module licensing
# est silencieusement desactive dans les builds CI.
!core/src/main/resources/licensing/jwt-public-key.pem
# ============================================================================ # ============================================================================
# Java / Spring Boot / Maven # Java / Spring Boot / Maven
@@ -53,6 +58,12 @@ yarn-error.log*
.pnpm-debug.log* .pnpm-debug.log*
coverage/ coverage/
# Playwright (E2E)
web/test-results/
web/playwright-report/
web/blob-report/
web/playwright/.cache/
# ============================================================================ # ============================================================================
# IDE / Editeurs # IDE / Editeurs
# ============================================================================ # ============================================================================
@@ -85,8 +96,16 @@ Thumbs.db
# Documentation hors-code (conservee hors du repo) # Documentation hors-code (conservee hors du repo)
# ============================================================================ # ============================================================================
docs/ docs/
loremind-docs/
# ============================================================================ # ============================================================================
# Docker Compose override (dev uniquement, non-distribue aux end users) # Docker Compose override (dev uniquement, non-distribue aux end users)
# ============================================================================ # ============================================================================
docker-compose.override.yml docker-compose.override.yml
# ============================================================================
# Relais OAuth Patreon (repo Gitea separe, clone localement pour facilite)
# ============================================================================
relay/
scripts/bump-version.mjs
brain/data/notebooks/5.json

View File

@@ -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`.

View File

@@ -1,75 +1,66 @@
# LoreMind # LoreMind
Application web d'aide aux Maîtres de Jeu (JDR) pour centraliser la gestion de l'univers (Lore) et le suivi des campagnes, avec un moteur IA intégré pour générer du contenu structuré. > Application web auto-hébergeable pour MJ qui veulent centraliser leur univers, leurs campagnes et leurs personnages avec un assistant IA contextuel.
## Fonctionnalités [![Licence: AGPL v3](https://img.shields.io/badge/Licence-AGPL%20v3-blue.svg)](LICENSE)
[![Documentation](https://img.shields.io/badge/docs-loremind--docs-green)](https://loremind-docs.igmlcreation.fr/)
[![Démo](https://img.shields.io/badge/d%C3%A9mo-en%20ligne-orange)](https://loremind-demo.igmlcreation.fr/)
[![Patreon](https://img.shields.io/badge/Patreon-soutenir-red)](https://www.patreon.com/c/IGMLCreation)
[![Discord](https://img.shields.io/badge/Discord-rejoindre-5865F2)](https://discord.gg/cPpFzCjEzQ)
- Gestion centralisée du Lore : Lieux, Factions, PNJ, et tous les éléments de votre univers ## Découvrir LoreMind en vidéo
- 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 [![Présentation LoreMind](https://img.youtube.com/vi/llJkmlotbB8/maxresdefault.jpg)](https://www.youtube.com/watch?v=llJkmlotbB8)
### Page d'accueil ![Tableau de bord](https://raw.githubusercontent.com/IGMLcreation/loremind-docs/main/static/img/screenshots/dashboard.png)
![Accueil](docs/maquettes/général/Accueil.png)
### Recherche ## Ce que ça fait
![Recherche](docs/maquettes/général/Ecran de recherche.png)
## Stack Technologique LoreMind regroupe ce qu'un MJ utilise habituellement éparpillé entre plusieurs outils. L'application s'articule autour de trois modules principaux, augmentés par un assistant IA qui exploite tout votre contenu.
LoreMind utilise une architecture distribuée pour séparer les responsabilités : ### Lore
- **Frontend** : Angular (Interface utilisateur, affichage du lore, formulaires de templates) Construire votre univers avec une arborescence de pages templatées : lieux, factions, PNJ, événements, organisations... Chaque type de page suit un template configurable, ce qui garantit la cohérence et facilite la navigation dans des univers riches.
- **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 ### Game System
### Backend Java (Domain-Driven Design & Hexagonal) Stocker les règles de votre système de jeu (D&D, Nimble, créations maison...) et définir les modèles de fiches de personnages associés. Les règles indexées peuvent être injectées dans le contexte de l'IA pour des réponses fidèles à votre système.
Le Backend Core respecte strictement : ### Campaign
- **Domain-Driven Design (DDD)** : Séparation en Bounded Contexts autonomes
- **Architecture Hexagonale (Ports et Adaptateurs)** : Domaine pur sans dépendances techniques
#### Bounded Contexts Structurer vos campagnes en Arcs → Chapitres → Scènes avec séparation claire du contenu MJ et du contenu joueurs. Gérer les PJ et PNJ via des fiches dynamiques basées sur les templates du game system retenu.
- **LoreContext** : Gestion de l'encyclopédie de l'univers
- **CampaignContext** : Suivi des sessions et chronologie
- **GenerationContext** : Gestion des requêtes IA et templates
#### Couches ### Assistant IA
- **Domaine (Core)** : Entités métier pures et interfaces (Ports)
- **Application** : Orchestration des flux (Use Cases)
- **Infrastructure** : Implémentation technique (Adapters)
## Installation Un assistant contextuel qui pioche dans votre Lore, vos règles et vos campagnes pour répondre à vos questions, suggérer du contenu cohérent, ou rebondir sur une situation improvisée en table.
Pour installer LoreMind chez vous (Docker requis), suivez le guide **[INSTALL.md](INSTALL.md)** — 3 étapes, 5 minutes chrono : L'IA s'exécute **en local via [Ollama](https://ollama.com/)** ou via **[1min.ai](https://1min.ai/)**. D'autres moteurs seront supportés à l'avenir.
1. Télécharger `docker-compose.yml` + `.env.example` depuis la [dernière release](https://git.igmlcreation.fr/ietm64/LoreMindMJ/releases) ## Documentation
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`. Toute la documentation (installation, configuration, prise en main) est sur **[loremind-docs.igmlcreation.fr](https://loremind-docs.igmlcreation.fr/)**.
## Développement (contributeurs) ## Démo en ligne
Pour builder les images localement depuis les sources : Une instance de démonstration est disponible sur **[loremind-demo.igmlcreation.fr](https://loremind-demo.igmlcreation.fr/)**.
```bash Quelques limites à connaître :
git clone https://git.igmlcreation.fr/ietm64/LoreMindMJ.git - 10 utilisateurs maximum simultanés (instances isolées)
cd LoreMindMJ - Session limitée à 20 minutes avant réinitialisation
# Créer un docker-compose.override.yml local (voir docs de contrib) - Partie IA non incluse dans la démo (nécessite Ollama ou 1min.ai côté serveur)
docker compose up -d --build
```
## License ## Soutenir le projet
LoreMind est **et restera gratuit en auto-hébergement**. Le développement avance plus vite avec votre soutien :
- **[Patreon](https://www.patreon.com/c/IGMLCreation)** — accès anticipé aux features, vote sur la roadmap, devlogs exclusifs
- **[Discord](https://discord.gg/cPpFzCjEzQ)** — annonces, support, retours utilisateurs
## Licence
LoreMind est distribué sous licence **[GNU AGPL v3](LICENSE)**. LoreMind est distribué sous licence **[GNU AGPL v3](LICENSE)**.
En pratique : En pratique :
- Tu peux l'utiliser gratuitement, l'héberger où tu veux, le modifier, le redistribuer. - Vous pouvez l'utiliser gratuitement, l'héberger, la modifier, la redistribuer.
- Si tu modifies le code et que tu exposes l'application modifiée sur un réseau (même en SaaS privé), tu dois rendre tes modifications publiques sous la même licence. - Si vous modifiez le code et que vous exposez l'application modifiée sur un réseau (même en SaaS privé), vous devez rendre vos modifications publiques sous la même licence.
- Les univers (Lore) et campagnes que tu crées avec LoreMind **t'appartiennent entièrement** — la licence ne couvre que le code de l'application. - Les univers (Lore) et campagnes que vous créez avec LoreMind **vous appartiennent entièrement** — la licence ne couvre que le code de l'application.

View File

@@ -1,7 +1,15 @@
FROM python:3.12-slim FROM python:3.12-slim
WORKDIR /app WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends curl \ # curl : healthcheck docker.
# tesseract-ocr (+ langues fra/eng) : repli OCR de l'import de PDF de regles
# pour les pages sans couche texte (scans). Inutile pour les PDF born-digital
# mais necessaire pour couvrir tous les cas.
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
tesseract-ocr \
tesseract-ocr-fra \
tesseract-ocr-eng \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
COPY requirements.txt . COPY requirements.txt .

View File

@@ -0,0 +1,125 @@
"""Use case : conseils d'adaptation d'un PDF à une campagne EXISTANTE.
L'IA connaît la campagne de l'utilisateur (un « brief » : structure arcs/chapitres/
scènes + PNJ + univers/lore), lit le contenu du PDF, et rédige des recommandations
d'INTÉGRATION/ADAPTATION (où insérer, reskins de PNJ, transposition à l'univers,
doublons à réconcilier…). Sortie en markdown, streamée token par token.
Contrairement à l'IMPORT (qui produit une arborescence à créer), ici on produit
du CONSEIL libre : rien n'est créé, l'utilisateur applique à la main.
"""
from __future__ import annotations
import logging
from typing import AsyncIterator
from app.domain.models import ChatMessage
from app.domain.ports import LLMChatProvider, PdfExtractionError, PdfTextExtractor
logger = logging.getLogger(__name__)
# Plus créatif que l'import (tâche de structuration) : ici on conseille/adapte.
_TEMPERATURE = 0.7
_SYSTEM_PREFIX = (
"Tu es un assistant pour Maître de Jeu de jeu de rôle. L'utilisateur a une "
"campagne EXISTANTE (décrite plus bas) et souhaite ADAPTER et INTÉGRER le "
"contenu d'un PDF (aventure, donjon, supplément) à CETTE campagne précise."
)
_SYSTEM_SUFFIX = (
"Produis des CONSEILS D'ADAPTATION concrets, actionnables et en FRANÇAIS, "
"en markdown structuré (titres ##, listes). Couvre notamment :\n"
"- **Où l'insérer** : à quel(s) arc(s)/chapitre(s) EXISTANT(s) rattacher ce "
"contenu, dans quel ordre, et — si l'arc est un hub — sous quelles conditions de déblocage.\n"
"- **Reskins / liens PNJ** : quels PNJ EXISTANTS de la campagne peuvent incarner "
"ou remplacer les personnages clés du PDF.\n"
"- **Adaptation à l'univers** : comment transposer lieux, factions, noms propres et "
"ton vers l'univers de l'utilisateur plutôt que le cadre d'origine du PDF.\n"
"- **Doublons / conflits** : ce qui recoupe l'existant et comment le réconcilier.\n"
"- **Ajustements de ton et de difficulté**.\n\n"
"Réfère-toi TOUJOURS aux éléments existants par leur NOM. Ne réécris PAS le PDF en "
"entier : donne des recommandations. Si une information manque, propose des options."
)
class AdaptCampaignUseCase:
"""Génère (en streaming) des conseils d'adaptation d'un PDF à une campagne."""
def __init__(
self,
llm: LLMChatProvider,
extractor: PdfTextExtractor,
max_input_tokens: int = 10000,
) -> None:
self._llm = llm
self._extractor = extractor
# L'adaptation envoie le PDF en UNE requête (pas de découpage). On plafonne
# donc l'entrée pour ne pas dépasser la taille de requête acceptée par le
# provider (sinon HTTP 400). Calé sur la taille des morceaux d'import.
self._max_input_tokens = max_input_tokens
async def stream(
self,
pdf_bytes: bytes,
brief: str,
messages: list[ChatMessage],
) -> AsyncIterator[str]:
"""Conversationnel : le PDF + la campagne sont le CONTEXTE (system prompt),
`messages` est l'échange (demande initiale, puis feedbacks de l'utilisateur)."""
doc = self._extractor.extract(pdf_bytes)
pdf_text = doc.full_text
if not pdf_text.strip():
raise PdfExtractionError("Aucun texte exploitable n'a été extrait du PDF.")
brief = brief or ""
pdf_text, truncated = self._fit_pdf_to_budget(pdf_text, brief)
logger.info(
"Adaptation campagne : %s page(s) (%s via OCR), brief %s car., PDF %s car.%s, %s message(s).",
doc.page_count, doc.ocr_page_count, len(brief), len(pdf_text),
" (tronqué)" if truncated else "", len(messages),
)
trunc_note = (
"\n[Note : PDF tronqué pour tenir dans une requête — base-toi sur ce début.]"
if truncated else ""
)
# Concaténation (pas .format) : brief/PDF peuvent contenir des { } littéraux.
system_prompt = (
f"{_SYSTEM_PREFIX}\n\n"
"--- CAMPAGNE EXISTANTE DE L'UTILISATEUR ---\n"
f"{brief.strip() or '(campagne encore vide)'}\n\n"
"--- CONTENU DU PDF À ADAPTER ---\n"
f"{pdf_text}{trunc_note}\n\n"
f"{_SYSTEM_SUFFIX}\n\n"
"Tu es en CONVERSATION : à chaque message de l'utilisateur, ajuste, corrige "
"ou propose des alternatives en gardant tout ce contexte à l'esprit."
)
# 1er tour : si aucun message, on lance la demande initiale par défaut.
convo = messages or [ChatMessage(
role="user",
content="Propose-moi comment intégrer et adapter ce PDF à ma campagne.",
)]
async for token in self._llm.stream_chat(
convo, system_prompt=system_prompt, temperature=_TEMPERATURE
):
yield token
def _fit_pdf_to_budget(self, pdf_text: str, brief: str) -> tuple[str, bool]:
"""Tronque le texte du PDF pour que (brief + PDF) tienne dans le budget tokens.
Évite un HTTP 400 « requête trop grosse » côté provider. Réserve une marge
pour le prompt système et le brief.
"""
import tiktoken
enc = tiktoken.get_encoding("cl100k_base")
brief_tokens = len(enc.encode(brief))
budget = max(2000, self._max_input_tokens - brief_tokens - 1000) # 1000 = marge système
pdf_tokens = enc.encode(pdf_text)
if len(pdf_tokens) <= budget:
return pdf_text, False
return enc.decode(pdf_tokens[:budget]), True

View File

@@ -21,11 +21,15 @@ from app.domain.models import (
ChatMessage, ChatMessage,
ChapterSummary, ChapterSummary,
CharacterSummary, CharacterSummary,
NpcSummary,
GameSystemContext, GameSystemContext,
JournalEntrySummary,
LoreStructuralContext, LoreStructuralContext,
NarrativeEntityContext, NarrativeEntityContext,
PageContext, PageContext,
PageSummary, PageSummary,
QuestSummary,
SessionContext,
) )
from app.domain.ports import LLMChatProvider from app.domain.ports import LLMChatProvider
@@ -66,6 +70,7 @@ class ChatUseCase:
campaign_context: CampaignStructuralContext | None = None, campaign_context: CampaignStructuralContext | None = None,
narrative_entity: NarrativeEntityContext | None = None, narrative_entity: NarrativeEntityContext | None = None,
game_system_context: GameSystemContext | None = None, game_system_context: GameSystemContext | None = None,
session_context: SessionContext | None = None,
) -> AsyncIterator[str]: ) -> AsyncIterator[str]:
"""Streame les tokens de la réponse assistant pour le dernier message user. """Streame les tokens de la réponse assistant pour le dernier message user.
@@ -75,7 +80,7 @@ class ChatUseCase:
cette règle à la frontière HTTP. cette règle à la frontière HTTP.
""" """
system_prompt = self._build_system_prompt( system_prompt = self._build_system_prompt(
lore_context, page_context, campaign_context, narrative_entity, game_system_context lore_context, page_context, campaign_context, narrative_entity, game_system_context, session_context
) )
async for token in self._llm.stream_chat( async for token in self._llm.stream_chat(
messages, messages,
@@ -91,12 +96,13 @@ class ChatUseCase:
campaign_context: CampaignStructuralContext | None = None, campaign_context: CampaignStructuralContext | None = None,
narrative_entity: NarrativeEntityContext | None = None, narrative_entity: NarrativeEntityContext | None = None,
game_system_context: GameSystemContext | None = None, game_system_context: GameSystemContext | None = None,
session_context: SessionContext | None = None,
) -> str: ) -> str:
"""Version publique — utilisée par le controller HTTP pour compter """Version publique — utilisée par le controller HTTP pour compter
les tokens du system prompt avant de streamer (jauge de contexte). les tokens du system prompt avant de streamer (jauge de contexte).
""" """
return self._build_system_prompt( return self._build_system_prompt(
lore_context, page_context, campaign_context, narrative_entity, game_system_context lore_context, page_context, campaign_context, narrative_entity, game_system_context, session_context
) )
# --- Construction du system prompt -------------------------------------- # --- Construction du system prompt --------------------------------------
@@ -108,6 +114,7 @@ class ChatUseCase:
campaign: CampaignStructuralContext | None, campaign: CampaignStructuralContext | None,
narrative: NarrativeEntityContext | None, narrative: NarrativeEntityContext | None,
game_system: GameSystemContext | None = None, game_system: GameSystemContext | None = None,
session: SessionContext | None = None,
) -> str: ) -> str:
sections = [_BASE_SYSTEM] sections = [_BASE_SYSTEM]
if lore is not None: if lore is not None:
@@ -120,6 +127,8 @@ class ChatUseCase:
sections.append(self._format_page(page)) sections.append(self._format_page(page))
if narrative is not None: if narrative is not None:
sections.append(self._format_narrative_entity(narrative)) sections.append(self._format_narrative_entity(narrative))
if session is not None:
sections.append(self._format_session(session))
return "\n\n".join(sections) return "\n\n".join(sections)
# --- Blocs Lore --------------------------------------------------------- # --- Blocs Lore ---------------------------------------------------------
@@ -198,10 +207,12 @@ class ChatUseCase:
else "\n(Cette campagne n'est associée à aucun univers — tu peux proposer des éléments d'ambiance libres.)" else "\n(Cette campagne n'est associée à aucun univers — tu peux proposer des éléments d'ambiance libres.)"
) )
characters_block = ChatUseCase._format_characters(ctx.characters) characters_block = ChatUseCase._format_characters(ctx.characters)
npcs_block = ChatUseCase._format_npcs(ctx.npcs)
return ( return (
"--- CAMPAGNE COURANTE ---\n" "--- CAMPAGNE COURANTE ---\n"
f"Nom : {ctx.campaign_name}{desc}{lore_note}\n" f"Nom : {ctx.campaign_name}{desc}{lore_note}\n"
f"{characters_block}\n" f"{characters_block}"
f"{npcs_block}\n"
"Structure narrative (les flèches → indiquent des transitions de scène " "Structure narrative (les flèches → indiquent des transitions de scène "
"déclenchées par un choix des joueurs) :\n" "déclenchées par un choix des joueurs) :\n"
f"{arcs_block}" f"{arcs_block}"
@@ -231,6 +242,33 @@ class ChatUseCase:
) )
return "\n".join(lines) + "\n" return "\n".join(lines) + "\n"
@staticmethod
def _format_npcs(npcs: list[NpcSummary]) -> str:
"""Bloc PNJ — symétrique aux PJ avec sa propre instruction anti-halluci.
Distinction importante : pour les PNJ, l'IA est ENCOURAGÉE à proposer de
nouveaux PNJ (création créative = OK). En revanche, elle ne doit pas
référencer comme existant un PNJ qui n'est pas dans la liste ci-dessous.
"""
if not npcs:
return (
"\nPersonnages non-joueurs (PNJ) : aucun défini pour l'instant. "
"Tu peux librement proposer de nouveaux PNJ au MJ, mais ne "
"fais pas comme s'ils existaient déjà dans la campagne.\n"
)
lines = ["\nPersonnages non-joueurs (PNJ) connus :"]
for n in npcs:
if n.snippet:
lines.append(f"- **{n.name}** — {n.snippet}")
else:
lines.append(f"- **{n.name}** (fiche vide)")
lines.append(
"Pour une fiche complète d'un PNJ existant (apparence, motivations), "
"n'invente rien : demande au MJ d'ouvrir l'éditeur du PNJ. Tu peux "
"en revanche proposer librement de NOUVEAUX PNJ."
)
return "\n".join(lines) + "\n"
@staticmethod @staticmethod
def _format_arcs(arcs: list[ArcSummary]) -> str: def _format_arcs(arcs: list[ArcSummary]) -> str:
if not arcs: if not arcs:
@@ -258,7 +296,8 @@ class ChatUseCase:
else: else:
for scene in chapter.scenes: for scene in chapter.scenes:
sc_hint = ChatUseCase._illustration_hint(scene.illustration_count) sc_hint = ChatUseCase._illustration_hint(scene.illustration_count)
block.append(f" - {scene.name} (scène){sc_hint}") scene_kind = " (lieu explorable)" if scene.rooms else " (scène)"
block.append(f" - {scene.name}{scene_kind}{sc_hint}")
if scene.description: if scene.description:
block.append(f" Description : {scene.description}") block.append(f" Description : {scene.description}")
for br in scene.branches: for br in scene.branches:
@@ -266,6 +305,19 @@ class ChatUseCase:
block.append( block.append(
f'"{br.label}" vers {br.target_scene_name}{cond}' f'"{br.label}" vers {br.target_scene_name}{cond}'
) )
# Pièces du lieu explorable (mode donjon)
for room in scene.rooms:
floor = f" [étage {room.floor}]" if room.floor is not None else ""
block.append(f"{room.name}{floor}")
if room.description:
block.append(f" {room.description}")
if room.enemies:
block.append(f" Ennemis : {room.enemies}")
for rb in room.branches:
cond = f" (si : {rb.condition})" if rb.condition else ""
block.append(
f'"{rb.label}" vers {rb.target_room_name}{cond}'
)
return block return block
@staticmethod @staticmethod
@@ -312,6 +364,141 @@ class ChatUseCase:
f"{sections_block}" f"{sections_block}"
) )
# --- Bloc Session de jeu (Play Context) ---------------------------------
_ENTRY_TYPE_LABELS = {
"NOTE": "Note du MJ",
"EVENT": "Évènement",
"DICE_ROLL": "Jet de dés",
"PLAYER_ACTION": "Action joueur",
}
@staticmethod
def _format_session(sc: SessionContext) -> str:
"""Bloc journal de la session en cours + résumé des sessions précédentes.
Fournit à l'IA le contexte temporel : ce qui s'est passé jusqu'ici,
dans l'ordre chronologique. Permet de référencer un PNJ rencontré,
rappeler un évènement antérieur, ou rebondir sur une action joueur.
Pour les sessions PRÉCÉDENTES de la même campagne, on ne remonte que
les EVENTs (les moments marquants) pour préserver le contexte LLM.
"""
status = "EN COURS" if sc.active else "TERMINÉE"
started = f" — démarrée {sc.started_at}" if sc.started_at else ""
previous_block = ChatUseCase._format_previous_events(sc.previous_events)
hub_block = ChatUseCase._format_hub_status(sc)
if not sc.entries:
current_block = "(Aucune entrée dans le journal pour l'instant — la session vient de commencer.)"
else:
lines: list[str] = []
for e in sc.entries:
label = ChatUseCase._ENTRY_TYPE_LABELS.get(e.type, e.type)
ts = f" [{e.occurred_at}]" if e.occurred_at else ""
content = e.content.replace("\n", "\n ")
lines.append(f"- {label}{ts} : {content}")
current_block = "\n".join(lines)
return (
"--- SESSION DE JEU EN COURS ---\n"
f"Nom : {sc.session_name}\n"
f"Statut : {status}{started}\n"
f"{hub_block}"
f"{previous_block}"
"\nJournal chronologique de la session courante (du plus ancien au plus récent) :\n"
f"{current_block}\n\n"
"IMPORTANT : tu es l'assistant du MJ PENDANT la partie. Tes réponses doivent :\n"
"- Tenir compte des évènements déjà capturés (sessions précédentes + journal courant).\n"
"- Être concrètes et utiles en temps réel : descriptions sensorielles, "
"réactions de PNJ cohérentes avec leur fiche, suggestions de complications "
"qui s'enchaînent à ce qui vient de se passer.\n"
"- Éviter les longs développements : le MJ est en train d'animer une partie, "
"il a besoin d'idées immédiatement actionnables."
)
@staticmethod
def _format_hub_status(sc: SessionContext) -> str:
"""Bloc Hub : quêtes ouvertes + flags actifs.
Vide si la campagne n'a aucun Arc HUB (toutes les listes vides côté Core).
Les quêtes LOCKED apparaissent par leur TITRE uniquement : l'IA sait
qu'elles existent (utile pour les teasers, les rumeurs en jeu) mais ne
peut pas spoiler leurs détails.
"""
if (not sc.available_quests
and not sc.in_progress_quests
and not sc.locked_quest_titles
and not sc.active_flags):
return ""
lines = ["", "État du Hub (quêtes parallèles et faits narratifs) :"]
if sc.in_progress_quests:
lines.append(" Quêtes en cours :")
lines.extend(ChatUseCase._format_quest_lines(sc.in_progress_quests))
if sc.available_quests:
lines.append(" Quêtes disponibles (non démarrées, prêtes à être lancées) :")
lines.extend(ChatUseCase._format_quest_lines(sc.available_quests))
if sc.locked_quest_titles:
titles = ", ".join(f'"{t}"' for t in sc.locked_quest_titles)
lines.append(
" Quêtes encore verrouillées (existent mais non accessibles — "
f"tu peux y faire allusion sous forme de rumeurs sans spoiler leur contenu) : {titles}"
)
if sc.active_flags:
lines.append(
" Faits actifs : " + ", ".join(f"`{f}`" for f in sc.active_flags)
)
lines.append(
" Conseille des actions cohérentes avec ces quêtes ouvertes. Ne fais "
"PAS comme si une quête verrouillée était déjà accessible aux PJ."
)
lines.append("") # séparateur visuel avant le récap des sessions précédentes
return "\n".join(lines)
@staticmethod
def _format_quest_lines(quests: list[QuestSummary]) -> list[str]:
"""Sérialise une liste de QuestSummary en lignes indentées."""
out: list[str] = []
for q in quests:
arc = f" [arc : {q.arc_name}]" if q.arc_name else ""
out.append(f" - {q.name}{arc}")
if q.description:
out.append(f" Synopsis : {q.description}")
return out
@staticmethod
def _format_previous_events(events: list[JournalEntrySummary]) -> str:
"""Bloc "Story so far" : EVENTs marquants des sessions antérieures.
Vide si la campagne en est à sa première session. On groupe par
session source pour aider l'IA à situer chaque évènement temporellement.
"""
if not events:
return ""
# Groupement par session source en préservant l'ordre d'apparition.
grouped: dict[str, list[JournalEntrySummary]] = {}
for e in events:
key = e.source_session_name or "(session inconnue)"
grouped.setdefault(key, []).append(e)
lines = ["\nRécapitulatif des sessions précédentes (évènements marquants uniquement) :"]
for session_name, items in grouped.items():
lines.append(f"{session_name} :")
for e in items:
ts = f" [{e.occurred_at}]" if e.occurred_at else ""
content = e.content.replace("\n", "\n ")
lines.append(f" - {content}{ts}")
lines.append("") # ligne vide avant le bloc journal courant
return "\n".join(lines) + "\n"
@staticmethod @staticmethod
def _format_narrative_entity(ne: NarrativeEntityContext) -> str: def _format_narrative_entity(ne: NarrativeEntityContext) -> str:
"""Bloc équivalent à _format_page mais pour Arc/Chapter/Scene.""" """Bloc équivalent à _format_page mais pour Arc/Chapter/Scene."""
@@ -319,7 +506,8 @@ class ChatUseCase:
"arc": "ARC", "arc": "ARC",
"chapter": "CHAPITRE", "chapter": "CHAPITRE",
"scene": "SCÈNE", "scene": "SCÈNE",
"character": "FICHE DE PERSONNAGE", "character": "FICHE DE PERSONNAGE (PJ)",
"npc": "FICHE DE PNJ",
}.get(ne.entity_type.lower(), ne.entity_type.upper()) }.get(ne.entity_type.lower(), ne.entity_type.upper())
if ne.fields: if ne.fields:
fields_block = "\n".join( fields_block = "\n".join(

View File

@@ -0,0 +1,79 @@
"""Découpage d'un long texte en morceaux qui tiennent dans la fenêtre LLM.
Partagé par les imports (règles, campagne) : un livre dépasse la fenêtre de
contexte, on le découpe par paragraphes jusqu'à une cible de tokens, en coupant
les paragraphes géants si besoin. Dimensionnement via tiktoken (cl100k_base),
approximation suffisante (±10% vs tokenizer natif).
"""
from __future__ import annotations
# Cible conservatrice : tient dans une fenêtre Ollama (num_ctx 16384) en laissant
# la place au prompt + à la sortie JSON. Les providers à grand contexte (1min.ai)
# le supportent largement.
CHUNK_TARGET_TOKENS = 6000
def chunk_text(full_text: str, target_tokens: int = CHUNK_TARGET_TOKENS) -> list[str]:
"""Découpe `full_text` en morceaux ~`target_tokens` tokens (frontières de §)."""
if not full_text.strip():
return []
import tiktoken
enc = tiktoken.get_encoding("cl100k_base")
paragraphs = [p for p in full_text.split("\n\n") if p.strip()]
chunks: list[str] = []
current: list[str] = []
current_tokens = 0
for para in paragraphs:
para_tokens = len(enc.encode(para))
# Un paragraphe seul plus gros que la cible : on le coupe en sous-blocs.
if para_tokens > target_tokens:
if current:
chunks.append("\n\n".join(current))
current, current_tokens = [], 0
chunks.extend(_split_oversized(para, enc, target_tokens))
continue
if current_tokens + para_tokens > target_tokens and current:
chunks.append("\n\n".join(current))
current, current_tokens = [], 0
current.append(para)
current_tokens += para_tokens
if current:
chunks.append("\n\n".join(current))
return chunks
def _split_oversized(paragraph: str, enc, target_tokens: int) -> list[str]:
"""Coupe un paragraphe géant en sous-blocs ~`target_tokens` tokens."""
tokens = enc.encode(paragraph)
out: list[str] = []
for i in range(0, len(tokens), target_tokens):
out.append(enc.decode(tokens[i : i + target_tokens]))
return out
def split_in_half(text: str) -> tuple[str, str]:
"""Coupe `text` en deux moitiés ~égales, de préférence sur un saut de ligne
proche du milieu (pour ne pas trancher en plein mot/phrase).
Sert au repli anti-troncature des imports : quand la SORTIE d'un morceau est
coupée (le modèle ne peut pas tout réécrire en une réponse), on retraite ce
morceau en deux moitiés. Renvoie ('', '') si le texte est trop court pour
être découpé utilement (garde-fou anti-récursion infinie).
"""
text = text.strip()
if len(text) < 400:
return "", ""
mid = len(text) // 2
# Cherche un saut de ligne juste avant le milieu, sinon juste après.
cut = text.rfind("\n", 0, mid)
if cut < len(text) // 4:
nxt = text.find("\n", mid)
cut = nxt if nxt != -1 else mid
left, right = text[:cut].strip(), text[cut:].strip()
if not left or not right:
return "", ""
return left, right

View File

@@ -0,0 +1,20 @@
"""Port d'embeddings (RAG des notebooks).
Abstraction du calcul de vecteurs : un texte → une liste de floats. Les adapters
concrets (Ollama local, Mistral cloud) la satisfont par duck typing, comme pour
les LLMProvider. Le RAG n'en dépend que via cette interface.
"""
from __future__ import annotations
from typing import Protocol
class EmbeddingError(Exception):
"""Échec du calcul d'embeddings (modèle indisponible, réseau, quota…)."""
class EmbeddingProvider(Protocol):
"""Calcule les vecteurs d'une liste de textes (ordre préservé)."""
async def embed(self, texts: list[str]) -> list[list[float]]:
...

View File

@@ -0,0 +1,373 @@
"""Use case : import d'un PDF de campagne → arbre arc → chapitre → scène.
Couche APPLICATION. Même chaîne que l'import de règles (extraction + OCR +
chunking + map-reduce) mais la cible est une ARBORESCENCE narrative :
- MAP : chaque morceau → un sous-arbre {arcs:[{chapters:[{scenes}]}]}
- REDUCE : fusion par NOM à chaque niveau (un chapitre coupé entre 2 morceaux
est recollé ; ses scènes s'accumulent).
PROPOSITION non persistée : le Core crée les entités seulement après revue.
"""
from __future__ import annotations
import logging
from app.application.chunking import chunk_text, split_in_half
from app.application.llm_json import load_json_object, looks_like_truncated_json
from app.application.llm_retry import generate_with_retry
from app.application.streaming import with_heartbeat
# Repli anti-troncature : si la sortie d'un morceau est coupée, on le retraite en
# 2 moitiés. Borné en profondeur (3 niveaux => jusqu'à 8 sous-blocs).
_MAX_SPLIT_DEPTH = 3
from app.domain.models import (
ArcProposal,
CampaignImportResult,
ChapterProposal,
RoomProposal,
SceneProposal,
)
from app.domain.ports import LLMProvider, LLMProviderError, PdfTextExtractor
logger = logging.getLogger(__name__)
# Très basse : structuration = recopie/réorganisation fidèle, pas de créativité.
# Plus la valeur est haute, plus le modèle "brode" (invente du contenu absent).
_TEMPERATURE = 0.1
# Nom de l'arc unique quand le livre n'est pas découpé en actes/parties.
_DEFAULT_ARC_NAME = "Aventure principale"
# Morceaux PLUS GROS que pour les règles : l'IA voit une quête/un chapitre entier
# d'un coup et le structure de façon cohérente (1 scène par lieu) au lieu de le
# fragmenter en dizaines de scènes. Adapté aux providers à grand contexte (1min.ai).
_CHUNK_TARGET_TOKENS = 10000
_MAP_SYSTEM = """Tu es un assistant qui structure un livre de campagne de jeu de rôle.
On te donne un EXTRAIT brut d'un PDF de campagne (texte parfois mal coupé par la mise en page).
Ta tâche : en dégager une ARBORESCENCE narrative à GROS GRAIN : arcs → chapitres → scènes,
et — pour les lieux explorables — leurs PIÈCES (rooms).
- Un ARC = un acte / une grande partie de la campagne (souvent un seul pour une aventure courte).
- Un CHAPITRE = une étape majeure du récit : un chapitre du livre, OU — dans une
campagne "hub" / bac-à-sable — UNE QUÊTE ou UN LIEU principal débloqué depuis le
point central (ex : Dragon of Icespire Peak → chaque quête/lieu = un chapitre).
- Une SCÈNE = un temps fort jouable du chapitre : un lieu, une rencontre clé, un moment pivot.
- Une PIÈCE (room) = une salle d'un lieu explorable (donjon, crypte, manoir...).
TYPE D'ARC ("type") :
- "HUB" si la campagne est un bac-à-sable : des quêtes/lieux optionnels, parallèles,
débloqués depuis un point central, SANS ordre fixe imposé (ex : Dragon of Icespire Peak).
- "LINEAR" si les chapitres se jouent dans un ordre séquentiel imposé.
- Dans le doute : "LINEAR".
GRANULARITÉ (évite la sur-détection) :
- Vise PEU de scènes : typiquement 1 à 6 par chapitre. PAS des dizaines.
- Un LIEU EXPLORABLE (donjon, crypte, manoir, grotte à plusieurs salles) = UNE SEULE
scène. Ses salles vont dans le tableau "rooms" de cette scène — JAMAIS en scènes séparées.
- NE crée PAS une scène par rencontre isolée, par PNJ, par monstre ou par paragraphe.
- IGNORE : blocs de stats, listes de monstres, encarts de règles, légendes de cartes,
pieds de page, sommaires, crédits.
CONTENU D'UNE SCÈNE (fidélité au livre — important) :
- `description` = synopsis de la scène, 2 à 4 phrases (plus que 1 ligne, mais pas le texte intégral).
- `player_narration` = le texte d'AMBIANCE « à lire aux joueurs » (encadrés / boxed text /
« lecture à voix haute »), recopié FIDÈLEMENT s'il existe dans l'extrait. Vide sinon.
- `gm_notes` = les informations pour le MJ : secrets, développement, ce qui se passe,
conséquences, indices cachés. Vide si rien de tel.
- Ne RÉSUME pas abusivement player_narration et gm_notes : recopie le contenu utile du livre.
PIÈCES (rooms) — uniquement pour les scènes qui sont des lieux explorables :
- Une entrée par salle numérotée/nommée du donjon (ex : "1. Entrée", "2. Salle des gardes").
- `enemies` = créatures/boss de la salle (vide si aucune). `loot` = trésor/récompense (vide si aucun).
- Pour une scène narrative classique (pas un donjon), "rooms" est un tableau vide [].
Format de réponse :
- Tu réponds UNIQUEMENT par un objet JSON valide, sans markdown ni commentaire autour.
- Schéma EXACT :
{{"arcs": [{{"name": "...", "description": "...", "type": "LINEAR",
"chapters": [{{"name": "...", "description": "...", "scenes": [
{{"name": "...", "description": "...", "player_narration": "...", "gm_notes": "...",
"rooms": [{{"name": "...", "description": "...", "enemies": "...", "loot": "..."}}]}}
]}}]}}
]}}
- Utilise les VRAIS titres du livre pour les noms (pas de paraphrase).
- Si le livre n'est PAS découpé en actes/parties, regroupe tout sous un seul arc nommé "{default_arc}".
- N'invente pas de contenu : tu réorganises et recopies ce qui est présent dans l'extrait.
- Si l'extrait ne contient aucune matière narrative, renvoie {{"arcs": []}}."""
class _TreeMerger:
"""Fusionne les sous-arbres des morceaux en un seul arbre, ordre préservé.
Clés insensibles à la casse à chaque niveau (nom d'arc / chapitre / scène).
Description : la première non-vide rencontrée l'emporte (les morceaux suivants
ne l'écrasent pas).
"""
def __init__(self) -> None:
# arc_key -> {"name", "description", "chapters": {chap_key -> {...}}}
self._arcs: dict[str, dict] = {}
def add(self, arcs_json: list[dict]) -> None:
for arc in arcs_json or []:
name = str(arc.get("name", "")).strip()
if not name:
continue
a = self._arcs.setdefault(
name.lower(), {"name": name, "description": "", "type": "LINEAR", "chapters": {}})
self._fill_desc(a, arc)
# Type d'arc : HUB l'emporte si un seul morceau le signale (propriété globale
# souvent énoncée une fois, dans l'intro du livre).
if str(arc.get("type", "")).strip().upper() == "HUB":
a["type"] = "HUB"
for chap in arc.get("chapters", []) or []:
cname = str(chap.get("name", "")).strip()
if not cname:
continue
c = a["chapters"].setdefault(cname.lower(), {"name": cname, "description": "", "scenes": {}})
self._fill_desc(c, chap)
for sc in chap.get("scenes", []) or []:
sname = str(sc.get("name", "")).strip()
if not sname:
continue
s = c["scenes"].setdefault(
sname.lower(),
{"name": sname, "description": "", "player_narration": "",
"gm_notes": "", "rooms": {}})
self._fill_desc(s, sc)
self._fill_field(s, sc, "player_narration")
self._fill_field(s, sc, "gm_notes")
for rm in sc.get("rooms", []) or []:
rname = str(rm.get("name", "")).strip()
if not rname:
continue
r = s["rooms"].setdefault(
rname.lower(),
{"name": rname, "description": "", "enemies": "", "loot": ""})
self._fill_desc(r, rm)
self._fill_field(r, rm, "enemies")
self._fill_field(r, rm, "loot")
@staticmethod
def _fill_desc(node: dict, src: dict) -> None:
if not node["description"]:
node["description"] = str(src.get("description") or "").strip()
@staticmethod
def _fill_field(node: dict, src: dict, field_name: str) -> None:
if not node[field_name]:
node[field_name] = str(src.get(field_name) or "").strip()
def result(self) -> list[ArcProposal]:
arcs: list[ArcProposal] = []
for a in self._arcs.values():
chapters: list[ChapterProposal] = []
for c in a["chapters"].values():
scenes: list[SceneProposal] = []
for s in c["scenes"].values():
rooms = [
RoomProposal(r["name"], r["description"], r["enemies"], r["loot"])
for r in s["rooms"].values()
]
scenes.append(SceneProposal(
s["name"], s["description"], s["player_narration"], s["gm_notes"], rooms))
chapters.append(ChapterProposal(c["name"], c["description"], scenes))
arcs.append(ArcProposal(a["name"], a["description"], a["type"], chapters))
return arcs
def counts(self) -> tuple[int, int, int]:
arcs = len(self._arcs)
chapters = sum(len(a["chapters"]) for a in self._arcs.values())
scenes = sum(len(c["scenes"]) for a in self._arcs.values() for c in a["chapters"].values())
return arcs, chapters, scenes
class ImportCampaignUseCase:
"""Transforme un PDF de campagne en proposition d'arbre arc→chapitre→scène."""
def __init__(
self,
llm: LLMProvider,
extractor: PdfTextExtractor,
chunk_target_tokens: int = _CHUNK_TARGET_TOKENS,
) -> None:
self._llm = llm
self._extractor = extractor
self._chunk_target_tokens = chunk_target_tokens
async def execute(self, pdf_bytes: bytes) -> CampaignImportResult:
"""Variante non-streamée : traite tout puis renvoie l'arbre complet."""
doc = self._extractor.extract(pdf_bytes)
chunks = chunk_text(doc.full_text, self._chunk_target_tokens)
merger = _TreeMerger()
for i, chunk in enumerate(chunks):
merger.add(await self._map_chunk(chunk, index=i, total=len(chunks)))
return CampaignImportResult(
arcs=merger.result(),
page_count=doc.page_count,
ocr_page_count=doc.ocr_page_count,
)
async def stream(self, pdf_bytes: bytes):
"""Variante streamée : yield des évènements d'avancement.
{"type":"extracting"}, puis {"type":"start", page_count, ocr_page_count,
total}, puis un {"type":"progress", current, total, arc_count,
chapter_count, scene_count} par morceau, et enfin
{"type":"done", arcs:[...], page_count, ocr_page_count}.
"""
yield {"type": "extracting"}
doc = self._extractor.extract(pdf_bytes)
chunks = chunk_text(doc.full_text, self._chunk_target_tokens)
total = len(chunks)
logger.info(
"Import campagne (stream) : %s page(s) (%s via OCR), %s morceau(x).",
doc.page_count, doc.ocr_page_count, total,
)
yield {
"type": "start",
"page_count": doc.page_count,
"ocr_page_count": doc.ocr_page_count,
"total": total,
}
merger = _TreeMerger()
skipped = 0
last_error: str | None = None
for i, chunk in enumerate(chunks):
# RÉSILIENCE : un morceau qui échoue (provider saturé, quota, etc.) est
# SAUTÉ — on ne perd pas tout l'import pour autant. On n'abandonne que
# si AUCUN morceau ne passe (cf. après la boucle).
# HEARTBEAT : keep-alive pendant l'appel LLM pour ne jamais laisser le
# flux SSE silencieux (sinon le Core coupe sur timeout d'inactivité).
try:
arcs_payload: list[dict] | None = None
async for kind, payload in with_heartbeat(
self._map_chunk(chunk, index=i, total=total)
):
if kind == "heartbeat":
yield {"type": "heartbeat", "current": i + 1, "total": total}
else:
arcs_payload = payload
merger.add(arcs_payload or [])
except LLMProviderError as exc:
skipped += 1
last_error = str(exc)
logger.warning("Morceau %s/%s ignoré (échec LLM) : %s", i + 1, total, exc)
yield {"type": "chunk_failed", "current": i + 1, "total": total,
"message": str(exc)[:300]}
arcs, chapters, scenes = merger.counts()
yield {
"type": "progress",
"current": i + 1,
"total": total,
"arc_count": arcs,
"chapter_count": chapters,
"scene_count": scenes,
"skipped": skipped,
}
if total > 0 and skipped == total:
# Tout a échoué : "done" vide serait trompeur → erreur explicite.
yield {"type": "error",
"message": "Tous les morceaux ont échoué auprès du fournisseur IA. "
f"Dernier message : {last_error or 'inconnu'}"}
return
yield {
"type": "done",
"arcs": _serialize_arcs(merger.result()),
"page_count": doc.page_count,
"ocr_page_count": doc.ocr_page_count,
"skipped": skipped,
}
# --- MAP : un morceau → sous-arbre ---------------------------------------
async def _map_chunk(self, chunk: str, *, index: int, total: int) -> list[dict]:
return await self._extract_arcs(chunk, index=index, total=total, depth=0)
async def _extract_arcs(
self, text: str, *, index: int, total: int, depth: int
) -> list[dict]:
"""Extrait l'arborescence d'un texte. Si la SORTIE est tronquée, retraite le
texte en DEUX moitiés et concatène — le `_TreeMerger` final dédoublonne par
nom (un arc/chapitre coupé entre les moitiés est recollé)."""
prompt = (
_MAP_SYSTEM.format(default_arc=_DEFAULT_ARC_NAME)
+ f"\n\n--- EXTRAIT {index + 1}/{total} ---\n{text}\n\n"
"Renvoie maintenant le JSON de l'arborescence."
)
raw = await generate_with_retry(
self._llm, prompt, output_format="json", temperature=_TEMPERATURE)
arcs, truncated = self._parse_arcs(raw, index=index)
if truncated and depth < _MAX_SPLIT_DEPTH:
left, right = split_in_half(text)
if left and right:
logger.info(
"Morceau %s : sortie tronquée → re-découpage en 2 moitiés (niveau %s).",
index, depth + 1)
a = await self._extract_arcs(left, index=index, total=total, depth=depth + 1)
b = await self._extract_arcs(right, index=index, total=total, depth=depth + 1)
return a + b
if truncated:
logger.warning(
"Morceau %s : sortie tronquée, profondeur max atteinte — partiel conservé.", index)
return arcs
@staticmethod
def _parse_arcs(raw: str, *, index: int) -> tuple[list[dict], bool]:
"""Parse robuste → (arcs, tronqué). `tronqué`=True si récupération partielle."""
parsed, recovered = load_json_object(raw)
if parsed is None:
truncated = looks_like_truncated_json(raw)
if not truncated:
logger.warning(
"Morceau %s : aucun objet JSON exploitable, ignoré. "
"Début de la réponse du modèle : %r",
index, (raw or "").strip()[:300] or "(réponse VIDE)")
return [], truncated
if isinstance(parsed, dict):
arcs = parsed.get("arcs", [])
return (arcs if isinstance(arcs, list) else []), recovered
return [], recovered
def _serialize_arcs(arcs: list[ArcProposal]) -> list[dict]:
"""Sérialise l'arbre de dataclasses en dicts JSON pour le flux SSE."""
return [
{
"name": a.name,
"description": a.description,
"type": a.arc_type,
"chapters": [
{
"name": c.name,
"description": c.description,
"scenes": [
{
"name": s.name,
"description": s.description,
"player_narration": s.player_narration,
"gm_notes": s.gm_notes,
"rooms": [
{
"name": r.name,
"description": r.description,
"enemies": r.enemies,
"loot": r.loot,
}
for r in s.rooms
],
}
for s in c.scenes
],
}
for c in a.chapters
],
}
for a in arcs
]

View File

@@ -0,0 +1,279 @@
"""Use case : import d'un PDF de règles → sections markdown structurées.
Couche APPLICATION. Orchestre :
PDF (bytes) → extraction texte (port PdfTextExtractor)
→ CHUNKING (le texte d'un livre dépasse la fenêtre de contexte)
→ MAP : chaque morceau → {titre de section → markdown}
→ REDUCE: fusion des sections de même titre entre morceaux
→ RulesImportResult (proposition, NON persistée)
Ne dépend que des abstractions du domaine (ports LLMProvider + PdfTextExtractor)
→ testable avec des fakes, et indépendant du provider concret (Ollama/1min.ai).
"""
from __future__ import annotations
import logging
from app.application.chunking import CHUNK_TARGET_TOKENS, chunk_text, split_in_half
from app.application.llm_json import load_json_object, looks_like_truncated_json
from app.application.llm_retry import generate_with_retry
from app.application.streaming import with_heartbeat
# Repli anti-troncature : si la SORTIE d'un morceau est coupée (le modèle ne peut
# pas tout réécrire en une réponse), on retraite ce morceau en 2 moitiés. Borné en
# profondeur pour éviter une récursion infinie (3 niveaux => jusqu'à 8 sous-blocs ;
# 1-2 niveaux suffisent en pratique, le reste est un garde-fou).
_MAX_SPLIT_DEPTH = 3
from app.domain.models import RulesImportResult
from app.domain.ports import LLMProvider, LLMProviderError, PdfTextExtractor
logger = logging.getLogger(__name__)
# Température basse : tâche de tri/réécriture fidèle, pas de créativité.
# Très basse : structuration = recopie/réorganisation fidèle, pas de créativité.
# Plus la valeur est haute, plus le modèle "brode" (invente du contenu absent).
_TEMPERATURE = 0.1
# Taxonomie canonique suggérée au modèle pour homogénéiser les titres entre
# morceaux (sinon "Combat" / "Le combat" / "Règles de combat" se dispersent).
# Le modèle reste libre d'en créer d'autres si rien ne correspond.
_CANONICAL_SECTIONS = [
"Règles générales",
"Création de personnage",
"Caractéristiques et tests",
"Compétences",
"Combat",
"Magie et sorts",
"Équipement et objets",
"États et conditions",
"Repos et récupération",
"Progression et niveaux",
"Conseils au Maître de Jeu",
]
_MAP_SYSTEM = """Tu es un assistant qui réorganise un livre de règles de jeu de rôle.
On te donne un EXTRAIT brut d'un PDF de règles (texte parfois mal coupé par la mise en page).
Ta tâche : répartir le contenu de cet extrait dans des SECTIONS THÉMATIQUES.
Règles impératives :
- Tu réponds UNIQUEMENT par un objet JSON valide, sans markdown ni commentaire autour.
- Les CLÉS sont des titres de section (texte court). Les VALEURS sont le contenu de la règle en markdown.
- Utilise EN PRIORITÉ ces titres canoniques quand le contenu y correspond :
{canonical}
- Si un contenu ne rentre dans aucun, crée un titre clair et concis (en français).
- Reproduis FIDÈLEMENT les règles : tu peux nettoyer la coupure des lignes, recoller les mots coupés
par un tiret en fin de ligne, retirer les en-têtes/pieds de page et numéros de page parasites.
- N'INVENTE AUCUNE règle, ne résume pas abusivement : tu réorganises, tu ne réécris pas le fond.
- Ignore les pages de garde, sommaires, crédits, pages vides (renvoie {{}} si l'extrait n'a aucune règle)."""
class _SectionMerger:
"""Fusionne les sections issues des différents morceaux, ordre préservé.
Titres insensibles à la casse ("Combat" / "combat" → une seule clé). Chaque
`add()` renvoie la liste (dé-dupliquée, ordonnée) des titres touchés par ce
morceau — sert au flux de progression pour annoncer les sections trouvées.
"""
def __init__(self) -> None:
self._merged: dict[str, list[str]] = {}
self._canonical_key: dict[str, str] = {}
def add(self, sections: dict[str, str]) -> list[str]:
touched: list[str] = []
for title, content in sections.items():
title = title.strip()
content = (content or "").strip()
if not title or not content:
continue
key = title.lower()
if key not in self._canonical_key:
self._canonical_key[key] = title
self._merged[title] = []
canonical = self._canonical_key[key]
self._merged[canonical].append(content)
touched.append(canonical)
# Dé-duplication en préservant l'ordre d'apparition.
seen: set[str] = set()
return [t for t in touched if not (t in seen or seen.add(t))]
def result(self) -> dict[str, str]:
return {title: "\n\n".join(parts) for title, parts in self._merged.items()}
def _combine_sections(a: dict[str, str], b: dict[str, str]) -> dict[str, str]:
"""Fusionne deux dicts de sections (issus des 2 moitiés d'un morceau re-découpé).
Titres insensibles à la casse : un même titre présent des deux côtés (une section
coupée par le re-découpage) voit ses contenus concaténés au lieu d'être écrasés.
"""
out = dict(a)
by_lower = {k.lower(): k for k in out}
for title, content in b.items():
key = by_lower.get(title.lower())
if key is not None:
out[key] = f"{out[key]}\n\n{content}".strip()
else:
out[title] = content
by_lower[title.lower()] = title
return out
class ImportRulesUseCase:
"""Transforme un PDF de règles en proposition de sections markdown."""
def __init__(
self,
llm: LLMProvider,
extractor: PdfTextExtractor,
chunk_target_tokens: int = CHUNK_TARGET_TOKENS,
) -> None:
self._llm = llm
self._extractor = extractor
self._chunk_target_tokens = chunk_target_tokens
async def execute(self, pdf_bytes: bytes) -> RulesImportResult:
"""Variante non-streamée : traite tout puis renvoie le résultat complet."""
doc = self._extractor.extract(pdf_bytes)
chunks = chunk_text(doc.full_text, self._chunk_target_tokens)
logger.info(
"Import règles : %s page(s) (%s via OCR), %s morceau(x) à traiter.",
doc.page_count, doc.ocr_page_count, len(chunks),
)
merger = _SectionMerger()
for i, chunk in enumerate(chunks):
merger.add(await self._map_chunk(chunk, index=i, total=len(chunks)))
return RulesImportResult(
sections=merger.result(),
page_count=doc.page_count,
ocr_page_count=doc.ocr_page_count,
)
async def stream(self, pdf_bytes: bytes):
"""Variante streamée : yield des évènements d'avancement au fil de l'eau.
Évènements (dicts) : {"type": "extracting"}, puis
{"type": "start", page_count, ocr_page_count, total}, puis un
{"type": "progress", current, total, new_sections:[...]} par morceau,
et enfin {"type": "done", sections, page_count, ocr_page_count}.
"""
# Émis AVANT l'extraction (potentiellement lente si OCR) pour que l'UI
# affiche tout de suite "Extraction…" plutôt qu'un écran figé.
yield {"type": "extracting"}
doc = self._extractor.extract(pdf_bytes)
chunks = chunk_text(doc.full_text, self._chunk_target_tokens)
total = len(chunks)
logger.info(
"Import règles (stream) : %s page(s) (%s via OCR), %s morceau(x).",
doc.page_count, doc.ocr_page_count, total,
)
yield {
"type": "start",
"page_count": doc.page_count,
"ocr_page_count": doc.ocr_page_count,
"total": total,
}
merger = _SectionMerger()
skipped = 0
last_error: str | None = None
for i, chunk in enumerate(chunks):
# RÉSILIENCE : un morceau qui échoue est SAUTÉ, l'import continue.
# Abandon seulement si AUCUN morceau ne passe (cf. après la boucle).
# HEARTBEAT : on émet des keep-alive pendant l'appel LLM (long sur un
# provider lent) pour que le flux SSE ne soit jamais coupé par le Core.
new_titles: list[str] = []
try:
sections: dict[str, str] | None = None
async for kind, payload in with_heartbeat(
self._map_chunk(chunk, index=i, total=total)
):
if kind == "heartbeat":
yield {"type": "heartbeat", "current": i + 1, "total": total}
else:
sections = payload
new_titles = merger.add(sections or {})
except LLMProviderError as exc:
skipped += 1
last_error = str(exc)
logger.warning("Morceau %s/%s ignoré (échec LLM) : %s", i + 1, total, exc)
yield {"type": "chunk_failed", "current": i + 1, "total": total,
"message": str(exc)[:300]}
yield {
"type": "progress",
"current": i + 1,
"total": total,
"new_sections": new_titles,
"skipped": skipped,
}
if total > 0 and skipped == total:
yield {"type": "error",
"message": "Tous les morceaux ont échoué auprès du fournisseur IA. "
f"Dernier message : {last_error or 'inconnu'}"}
return
yield {
"type": "done",
"sections": merger.result(),
"page_count": doc.page_count,
"ocr_page_count": doc.ocr_page_count,
"skipped": skipped,
}
# --- MAP : un morceau → sections -----------------------------------------
async def _map_chunk(self, chunk: str, *, index: int, total: int) -> dict[str, str]:
return await self._extract_sections(chunk, index=index, total=total, depth=0)
async def _extract_sections(
self, text: str, *, index: int, total: int, depth: int
) -> dict[str, str]:
"""Extrait les sections d'un texte. Si la SORTIE est tronquée, retraite le
texte en DEUX moitiés (chacune produit une réponse complète) et fusionne —
ainsi aucune section n'est perdue, quel que soit le plafond de sortie."""
prompt = (
_MAP_SYSTEM.format(
canonical="\n".join(f" - {s}" for s in _CANONICAL_SECTIONS)
)
+ f"\n\n--- EXTRAIT {index + 1}/{total} ---\n{text}\n\n"
"Renvoie maintenant le JSON des sections."
)
raw = await generate_with_retry(
self._llm, prompt, output_format="json", temperature=_TEMPERATURE)
sections, truncated = self._parse_sections(raw, index=index)
if truncated and depth < _MAX_SPLIT_DEPTH:
left, right = split_in_half(text)
if left and right:
logger.info(
"Morceau %s : sortie tronquée → re-découpage en 2 moitiés (niveau %s).",
index, depth + 1)
a = await self._extract_sections(left, index=index, total=total, depth=depth + 1)
b = await self._extract_sections(right, index=index, total=total, depth=depth + 1)
return _combine_sections(a, b)
if truncated:
logger.warning(
"Morceau %s : sortie tronquée, profondeur max atteinte — partiel conservé.", index)
return sections
@staticmethod
def _parse_sections(raw: str, *, index: int) -> tuple[dict[str, str], bool]:
"""Parse robuste → (sections, tronqué). `tronqué`=True si récupération partielle."""
parsed, recovered = load_json_object(raw)
if parsed is None:
# Rien d'exploitable : soit prose (échec), soit JSON coupé avant toute
# structure complète (→ on signalera 'tronqué' pour re-découper).
truncated = looks_like_truncated_json(raw)
if not truncated:
logger.warning(
"Morceau %s : aucun objet JSON exploitable, ignoré. "
"Début de la réponse du modèle : %r",
index, (raw or "").strip()[:300] or "(réponse VIDE)")
return {}, truncated
if not isinstance(parsed, dict):
logger.warning("Morceau %s : le LLM n'a pas renvoyé un objet, ignoré.", index)
return {}, False
return {str(k): str(v) for k, v in parsed.items()}, recovered

View File

@@ -0,0 +1,149 @@
"""Extraction robuste d'un objet JSON depuis une réponse LLM.
Les LLM enrobent souvent leur JSON : fences markdown ```json … ```, texte
d'introduction, commentaire de fin, voire un 2e objet. Un simple
`json.loads(raw)` ou un `raw[first_brace:last_brace]` échoue dans ces cas
("Extra data", accolade parasite dans une string, etc.).
Cette fonction scanne depuis la PREMIÈRE `{` et renvoie exactement le premier
objet `{…}` ÉQUILIBRÉ, en ignorant les accolades à l'intérieur des chaînes JSON
et tout ce qui suit. Renvoie None si aucun objet complet n'est trouvé
(sortie tronquée / accolades non refermées).
"""
from __future__ import annotations
import json
import re
# Blocs de "réflexion" des modèles raisonneurs (Nemotron, DeepSeek-R1, QwQ…).
# Leur contenu est de la prose truffée d'accolades qui piège le détecteur de JSON
# (et n'est jamais la réponse) → on le retire avant toute analyse.
_REASONING_RE = re.compile(r"<think(?:ing)?>.*?</think(?:ing)?>", re.DOTALL | re.IGNORECASE)
def _strip_reasoning(raw: str) -> str:
return _REASONING_RE.sub("", raw)
def load_json_object(raw: str) -> tuple[object | None, bool]:
"""Parse un objet JSON depuis une réponse LLM, avec récupération si tronqué.
Renvoie (objet_parsé, récupéré_partiellement) :
- d'abord on tente le 1er objet complet (extract_json_object) ;
- sinon on tente une réparation du JSON tronqué (repair_truncated_json),
auquel cas le second élément vaut True.
(None, False) si rien d'exploitable.
"""
raw = _strip_reasoning(raw)
obj = extract_json_object(raw)
if obj is not None:
try:
return json.loads(obj), False
except json.JSONDecodeError:
pass
repaired = repair_truncated_json(raw)
if repaired is not None:
try:
return json.loads(repaired), True
except json.JSONDecodeError:
pass
return None, False
def looks_like_truncated_json(raw: str) -> bool:
"""La sortie ressemble-t-elle à un JSON COUPÉ (accolades/crochets non refermés)
plutôt qu'à de la prose ? Sert à déclencher un re-découpage même quand RIEN n'a
pu être récupéré (cas où le 1er contenu est si long qu'il est coupé avant toute
sous-structure complète). On exige un contenu substantiel pour éviter les
faux positifs sur une courte réponse non-JSON."""
s = (raw or "").strip()
if "{" not in s or len(s) < 100:
return False
return s.count("{") > s.count("}") or s.count("[") > s.count("]")
def extract_json_object(raw: str) -> str | None:
if not raw:
return None
text = raw.strip()
start = text.find("{")
if start == -1:
return None
depth = 0
in_string = False
escape = False
for i in range(start, len(text)):
c = text[i]
if in_string:
if escape:
escape = False
elif c == "\\":
escape = True
elif c == '"':
in_string = False
else:
if c == '"':
in_string = True
elif c == "{":
depth += 1
elif c == "}":
depth -= 1
if depth == 0:
return text[start : i + 1]
return None # accolades non refermées (réponse probablement tronquée)
# Fermeture correspondante de chaque ouvrant, pour reconstituer un JSON tronqué.
_CLOSE_OF = {"{": "}", "[": "]"}
def repair_truncated_json(raw: str) -> str | None:
"""Répare un JSON COUPÉ (sortie LLM tronquée) en gardant les éléments complets.
On scanne depuis la première `{` et on retient le DERNIER point où un conteneur
(`}` ou `]`) vient de se fermer — donc juste après une sous-structure complète
(un arc / chapitre / scène / pièce / section entièrement écrit). On coupe là et
on referme les conteneurs encore ouverts. L'élément en cours d'écriture au moment
de la troncature est abandonné, mais tous les précédents sont sauvés.
Renvoie une chaîne JSON équilibrée (à valider par json.loads) ou None.
"""
if not raw:
return None
text = raw.strip()
start = text.find("{")
if start == -1:
return None
stack: list[str] = []
in_string = False
escape = False
best_cut = -1 # index (exclusif) où couper
best_closing = "" # fermetures à ajouter pour rééquilibrer
for i in range(start, len(text)):
c = text[i]
if in_string:
if escape:
escape = False
elif c == "\\":
escape = True
elif c == '"':
in_string = False
else:
if c == '"':
in_string = True
elif c in "{[":
stack.append(c)
elif c in "}]":
if stack:
stack.pop()
# Point de coupe sûr : on vient de fermer une sous-structure complète.
best_cut = i + 1
best_closing = "".join(_CLOSE_OF[b] for b in reversed(stack))
if best_cut == -1:
return None # rien de complet à sauver
head = text[start:best_cut].rstrip().rstrip(",")
return head + best_closing

View File

@@ -0,0 +1,102 @@
"""Retry avec backoff pour les appels LLM one-shot (imports).
Les imports enchaînent de nombreux appels en série ; un échec TRANSITOIRE sur un
seul morceau (503/502 surcharge serveur, 504/524 passerelle, timeout réseau) ne
doit pas faire échouer tout l'import. On réessaie quelques fois avec une attente
croissante. Après épuisement, on relaie l'erreur (problème durable : quota, panne).
Réservé aux appels `generate` (one-shot, bufferisé) : réessayer est propre, sans
risque de doublons. À NE PAS utiliser sur le streaming (re-jouerait des tokens).
"""
from __future__ import annotations
import asyncio
import logging
import re
from app.domain.ports import LLMProvider, LLMProviderError
logger = logging.getLogger(__name__)
# 3 tentatives : assez pour absorber un hoquet transitoire, sans s'acharner des
# minutes sur un modèle durablement lent/saturé (les heartbeats gardent le flux
# vivant, mais inutile de faire patienter l'utilisateur 15 min pour rien).
_ATTEMPTS = 3
_BASE_DELAY_SECONDS = 3.0
# Un rate limit (429) "par minute" ne se libère pas en 2-3s : on attend plus
# longtemps pour ces erreurs-là (le free tier OpenRouter plafonne ~20 req/min).
_RATE_LIMIT_DELAYS = [10.0, 25.0, 45.0]
def _is_rate_limit(exc: LLMProviderError) -> bool:
msg = str(exc).lower()
return "429" in msg or "rate" in msg or "too many requests" in msg
def _is_daily_quota(exc: LLMProviderError) -> bool:
"""Limite PAR JOUR (vs par minute) : réessayer est inutile, elle ne se libère
qu'au reset quotidien. OpenRouter le précise dans le corps du 429."""
msg = str(exc).lower()
return "per-day" in msg or "per day" in msg or "free-models-per-day" in msg
# OpenRouter renvoie souvent le délai conseillé (saturation amont) :
# "retry_after_seconds": 8 ou "Retry-After": "8". On le respecte plutôt que
# d'attendre une durée fixe arbitraire.
_RETRY_AFTER_RE = re.compile(r'retry[_-]?after(?:_seconds)?"?\s*:\s*"?([0-9]+(?:\.[0-9]+)?)', re.IGNORECASE)
def _suggested_retry_after(exc: LLMProviderError) -> float | None:
match = _RETRY_AFTER_RE.search(str(exc))
if not match:
return None
try:
return float(match.group(1))
except ValueError:
return None
async def generate_with_retry(
llm: LLMProvider,
prompt: str,
*,
output_format: str | None = None,
temperature: float | None = None,
) -> str:
"""Comme `llm.generate`, mais réessaie les erreurs transitoires (backoff).
Backoff plus long pour les 429 (rate limit) afin de laisser la fenêtre se
libérer. Nombre de tentatives borné : si le quota est durablement épuisé
(ex. limite/jour), l'erreur finit par remonter au lieu de boucler sans fin.
"""
delay = _BASE_DELAY_SECONDS
last_error: LLMProviderError | None = None
for attempt in range(_ATTEMPTS):
try:
return await llm.generate(prompt, output_format=output_format, temperature=temperature)
except LLMProviderError as exc:
last_error = exc
# Quota JOURNALIER épuisé : inutile d'insister, on remonte tout de suite
# (sinon on enchaîne des attentes longues pour rien, et on spamme l'API).
if _is_daily_quota(exc):
logger.warning("Quota journalier du fournisseur épuisé — abandon : %s", exc)
raise
if attempt < _ATTEMPTS - 1:
if _is_rate_limit(exc):
suggested = _suggested_retry_after(exc)
if suggested is not None:
# Indication serveur (saturation amont) + petite marge, plafonnée.
wait = min(suggested + 2.0, 60.0)
else:
wait = _RATE_LIMIT_DELAYS[min(attempt, len(_RATE_LIMIT_DELAYS) - 1)]
else:
wait = delay
delay *= 2
logger.warning(
"Appel LLM échoué (tentative %s/%s)%s : %s — nouvelle tentative dans %ss.",
attempt + 1, _ATTEMPTS, " [rate limit]" if _is_rate_limit(exc) else "",
exc, wait,
)
await asyncio.sleep(wait)
assert last_error is not None
raise last_error

View File

@@ -0,0 +1,90 @@
"""Use case : chat ANCRÉ sur les sources d'un notebook (RAG).
À chaque message, on retrouve les passages pertinents des sources (via le RAG) et
on les injecte dans le prompt système, en plus du contexte de campagne. Le modèle
répond donc en s'appuyant sur la/les source(s) — pas sur ses connaissances générales.
"""
from __future__ import annotations
from typing import AsyncIterator
from app.application.notebook_rag import NotebookRagUseCase
from app.domain.models import ChatMessage
from app.domain.ports import LLMChatProvider
_SYSTEM_PROMPT = """Tu es un assistant de jeu de rôle qui aide à ADAPTER une source (PDF) à la CAMPAGNE de l'utilisateur.
Tu disposes de DEUX connaissances, toutes deux ci-dessous :
1) LA CAMPAGNE de l'utilisateur (sa structure arcs/chapitres/scènes, ses PNJ, son univers) ;
2) LA SOURCE (extraits pertinents du PDF).
Règles :
- Pour une question sur SA CAMPAGNE (ex. « mon chapitre 3 », « mes PNJ »), appuie-toi sur la section CAMPAGNE.
- Pour une question sur le livre, appuie-toi sur les EXTRAITS DE LA SOURCE.
- CROISE les deux pour proposer des adaptations cohérentes avec sa campagne existante.
- N'invente pas ce qui ne figure ni dans la campagne ni dans la source ; si tu ne sais pas, dis-le.
- Quand un extrait porte un numéro de page (« (p. 12) »), cite-le (« d'après la p. 12 »).
{context_block}
--- EXTRAITS PERTINENTS DE LA SOURCE ---
{sources_block}
--- FIN DES EXTRAITS ---
PROPOSITIONS D'INTÉGRATION (IMPORTANT) :
Quand l'utilisateur veut CRÉER ou ADAPTER un élément concret pour sa campagne (un PNJ,
une scène, un chapitre, un arc, une table aléatoire), termine ta réponse par un ou
plusieurs BLOCS D'ACTION — un objet JSON par bloc, dans une clôture ```loremind-action.
L'interface les transformera en boutons « Créer dans la campagne ». N'en mets que si
c'est pertinent et explicitement souhaité. Formats acceptés :
```loremind-action
{{"type": "npc", "name": "Nom", "description": "Fiche en quelques phrases."}}
```
```loremind-action
{{"type": "scene", "name": "Nom", "description": "Résumé", "content": "Déroulé détaillé."}}
```
```loremind-action
{{"type": "chapter", "name": "Nom", "description": "Résumé du chapitre."}}
```
```loremind-action
{{"type": "arc", "name": "Nom", "description": "Résumé", "arcType": "LINEAR"}}
```
```loremind-action
{{"type": "table", "name": "Nom", "diceFormula": "1d8", "entries": [{{"minRoll":1,"maxRoll":4,"label":"...","detail":"..."}}]}}
```
Réponds en français, de façon utile et concise. Mets le texte explicatif AVANT les blocs d'action."""
class NotebookChatUseCase:
def __init__(self, rag: NotebookRagUseCase, llm: LLMChatProvider) -> None:
self._rag = rag
self._llm = llm
async def stream(
self,
source_ids: list[str],
messages: list[ChatMessage],
context: str = "",
top_k: int = 6,
) -> AsyncIterator[str]:
last_user = next((m.content for m in reversed(messages) if m.role == "user"), "")
passages = await self._rag.retrieve(source_ids, last_user, top_k=top_k)
sources_block = (
"\n\n".join(self._format_passage(p) for p in passages)
if passages else "(aucun passage pertinent trouvé dans les sources)"
)
context_block = (
f"--- TA CAMPAGNE ---\n{context.strip()}\n--- FIN CAMPAGNE ---\n\n"
if context.strip() else "--- TA CAMPAGNE ---\n(aucune donnée de campagne)\n--- FIN CAMPAGNE ---\n\n"
)
system_prompt = _SYSTEM_PROMPT.format(
context_block=context_block, sources_block=sources_block)
async for token in self._llm.stream_chat(messages, system_prompt=system_prompt):
yield token
@staticmethod
def _format_passage(p: dict) -> str:
page = p.get("page")
prefix = f"(p. {page}) " if page else ""
return f"{prefix}{p['text'].strip()}"

View File

@@ -0,0 +1,135 @@
"""Use case « Analyse approfondie » d'un notebook : map-reduce sur TOUT le document.
Contrairement au chat RAG (qui ne ramène que les top-k extraits), ce mode lit
l'INTÉGRALITÉ des sources par lots :
- MAP : pour chaque lot, le modèle extrait ce qui est pertinent pour la question
(ou « RAS » si rien) ;
- REDUCE : il synthétise toutes les notes en une réponse finale (streamée).
→ Répond aux questions globales/exhaustives (« liste tous les… ») quel que soit le
modèle, au prix de plusieurs appels (comme l'import). Le lot est dimensionné par
`batch_tokens` (= taille de morceau d'import) : avec un modèle gros-contexte, peu de
lots ; avec un petit modèle local, plus de lots (mais ça reste exhaustif).
"""
from __future__ import annotations
import logging
from typing import AsyncIterator
import tiktoken
from app.application.llm_retry import generate_with_retry
from app.domain.models import ChatMessage
from app.domain.ports import LLMChatProvider, LLMProvider, LLMProviderError
from app.infrastructure import vector_store
logger = logging.getLogger(__name__)
_NO_MATCH = "RAS"
_MAP_TEMPERATURE = 0.2
_MAP_PROMPT = """Voici un EXTRAIT d'un document. Extrais UNIQUEMENT les informations
pertinentes pour répondre à la question ci-dessous. Conserve les détails utiles et
indique les numéros de page (format « p. X »). Si l'extrait ne contient RIEN de
pertinent, réponds EXACTEMENT « {no_match} » et rien d'autre.
QUESTION : {question}
--- EXTRAIT ---
{excerpt}
--- FIN EXTRAIT ---
Informations pertinentes (ou « {no_match} ») :"""
_REDUCE_SYSTEM = """Tu réponds à la question d'un MJ à partir de NOTES extraites de
l'ENSEMBLE d'un document source (donc tu as une vue COMPLÈTE, pas un simple extrait).
Synthétise ces notes en une réponse claire et structurée, cite les pages (« p. X »),
et n'invente rien qui n'y figure pas. Si une CAMPAGNE est fournie ci-dessous, relie ta
réponse à sa structure / ses PNJ pour des adaptations cohérentes.
{context_block}
--- NOTES EXTRAITES DE TOUT LE DOCUMENT ---
{notes_block}
--- FIN DES NOTES ---
Réponds en français."""
class NotebookDeepUseCase:
def __init__(self, llm: LLMProvider, batch_tokens: int = 10000) -> None:
self._llm = llm
self._batch_tokens = max(2000, batch_tokens)
async def stream(
self,
source_ids: list[str],
messages: list[ChatMessage],
context: str = "",
history_limit: int = 8,
) -> AsyncIterator[dict]:
"""Yield des évènements : {type:'progress',current,total}, {type:'token',token},
{type:'done'}. (Les erreurs LLM des lots sont tolérées : lot ignoré.)
La dernière question utilisateur sert à la LECTURE du document (map) ; la
SYNTHÈSE (reduce) reçoit les `history_limit` derniers messages → les relances
conversationnelles (« et pour les autres ? ») fonctionnent aussi en approfondi.
"""
question = next((m.content for m in reversed(messages) if m.role == "user"), "")
chunks: list[dict] = []
for sid in source_ids:
chunks.extend(vector_store.all_chunks(sid))
if not chunks:
yield {"type": "token", "token": "Aucune source indexée à analyser."}
yield {"type": "done"}
return
batches = self._group(chunks)
total = len(batches)
notes: list[str] = []
for i, batch in enumerate(batches):
yield {"type": "progress", "current": i, "total": total}
excerpt = "\n\n".join(
f"(p. {c['page']}) {c['text'].strip()}" if c.get("page") else c["text"].strip()
for c in batch
)
prompt = _MAP_PROMPT.format(no_match=_NO_MATCH, question=question, excerpt=excerpt)
try:
raw = await generate_with_retry(self._llm, prompt, temperature=_MAP_TEMPERATURE)
except LLMProviderError as exc:
logger.warning("Analyse approfondie : lot %s/%s ignoré : %s", i + 1, total, exc)
continue
answer = raw.strip()
if answer and answer.upper().rstrip(".") != _NO_MATCH:
notes.append(answer)
yield {"type": "progress", "current": total, "total": total}
notes_block = "\n\n".join(notes) if notes else "(aucune information pertinente trouvée dans le document)"
context_block = (
f"--- TA CAMPAGNE (structure, PNJ, univers) ---\n{context.strip()}\n--- FIN CAMPAGNE ---\n\n"
if context.strip() else ""
)
system_prompt = _REDUCE_SYSTEM.format(context_block=context_block, notes_block=notes_block)
# Historique récent pour la cohérence des relances ; on garantit que le
# dernier message est bien la question courante.
reduce_messages = messages[-history_limit:] if messages else [ChatMessage(role="user", content=question)]
llm_chat: LLMChatProvider = self._llm # type: ignore[assignment]
async for token in llm_chat.stream_chat(reduce_messages, system_prompt=system_prompt):
yield {"type": "token", "token": token}
yield {"type": "done"}
def _group(self, chunks: list[dict]) -> list[list[dict]]:
"""Regroupe les extraits en lots ~`batch_tokens` (compte tiktoken)."""
enc = tiktoken.get_encoding("cl100k_base")
batches: list[list[dict]] = []
current: list[dict] = []
current_tokens = 0
for c in chunks:
t = len(enc.encode(c.get("text", "")))
if current and current_tokens + t > self._batch_tokens:
batches.append(current)
current, current_tokens = [], 0
current.append(c)
current_tokens += t
if current:
batches.append(current)
return batches

View File

@@ -0,0 +1,79 @@
"""Use case RAG des notebooks : indexer une source PDF et retrouver les passages
pertinents pour une question.
Chaîne d'indexation : PDF → extraction texte (+OCR) → découpage en extraits courts
→ embeddings → stockage vectoriel (fichier). À la requête : on embed la question
et on récupère les extraits les plus proches (cosinus) pour ancrer le chat.
Extraits PLUS COURTS que pour l'import (recopie) : ici on veut une granularité fine
pour que la recherche pointe un passage précis, pas un demi-chapitre.
"""
from __future__ import annotations
import logging
from app.application.chunking import chunk_text
from app.application.embeddings import EmbeddingProvider
from app.domain.ports import PdfTextExtractor
from app.infrastructure import vector_store
logger = logging.getLogger(__name__)
_RAG_CHUNK_TOKENS = 600
# Un extrait avec quasi aucun texte réel (en-tête/pied de page, fragment de numéro
# de page isolé « 249 250 ») ne sert à rien en RAG → on l'écarte. Seuil bas et
# conservateur : on ne coupe QUE les fragments quasi-vides, jamais une vraie phrase.
_MIN_LETTERS = 15
def _has_enough_text(piece: str) -> bool:
return sum(c.isalpha() for c in piece) >= _MIN_LETTERS
class NotebookRagUseCase:
def __init__(
self,
extractor: PdfTextExtractor,
embedder: EmbeddingProvider,
chunk_target_tokens: int = _RAG_CHUNK_TOKENS,
) -> None:
self._extractor = extractor
self._embedder = embedder
self._chunk_target_tokens = chunk_target_tokens
async def index_source(self, source_id: str, pdf_bytes: bytes) -> dict:
"""Extrait, découpe PAR PAGE (pour garder le n° de page → citations), embed
et stocke une source. Renvoie un récap."""
doc = self._extractor.extract(pdf_bytes)
chunks: list[str] = []
pages: list[int] = []
for page in doc.pages:
for piece in chunk_text(page.text, self._chunk_target_tokens):
if not _has_enough_text(piece):
continue # fragment quasi-vide (en-tête/pied/numéro) → ignoré
chunks.append(piece)
pages.append(page.index + 1) # n° de page 1-based pour l'affichage
logger.info(
"Indexation notebook source=%s : %s page(s) (%s OCR), %s extrait(s).",
source_id, doc.page_count, doc.ocr_page_count, len(chunks),
)
if not chunks:
vector_store.save(source_id, [], [])
return {"chunks": 0, "page_count": doc.page_count, "ocr_page_count": doc.ocr_page_count}
vectors = await self._embedder.embed(chunks)
count = vector_store.save(source_id, chunks, vectors, pages)
return {
"chunks": count,
"page_count": doc.page_count,
"ocr_page_count": doc.ocr_page_count,
}
async def retrieve(self, source_ids: list[str], query: str, top_k: int = 6) -> list[dict]:
"""Passages les plus pertinents (toutes sources) pour `query`."""
ids = [s for s in source_ids if vector_store.exists(s)]
if not ids or not query.strip():
return []
query_vectors = await self._embedder.embed([query])
if not query_vectors:
return []
return vector_store.search(ids, query_vectors[0], top_k)

View File

@@ -0,0 +1,45 @@
"""Heartbeats pour garder un flux SSE 'vivant' pendant une coroutine longue.
Problème résolu : pendant un appel LLM lent (import sur provider gratuit), le
Brain ne produit AUCUN évènement SSE. Le Core (WebClient) ne 'voit aucun item'
et coupe la connexion sur timeout d'inactivité :
ReactiveException: Did not observe any item or terminal signal within Nms
C'est le piège classique du SSE long. La parade standard = envoyer un keep-alive
périodique. `with_heartbeat` exécute une coroutine en émettant un évènement
'heartbeat' toutes les `interval` secondes tant qu'elle tourne, puis son résultat
('result', valeur). Le Core remet son chrono à zéro sur n'importe quel évènement
reçu (même inconnu) → plus de coupure, quelle que soit la lenteur du modèle.
"""
from __future__ import annotations
import asyncio
from typing import Any, AsyncIterator, Awaitable
# Bien sous le timeout d'inactivité du Core (600s) ET de tout proxy (nginx ~60s).
HEARTBEAT_INTERVAL_SECONDS = 15.0
async def with_heartbeat(
coro: Awaitable[Any],
*,
interval: float = HEARTBEAT_INTERVAL_SECONDS,
) -> AsyncIterator[tuple[str, Any]]:
"""Exécute `coro` en émettant ('heartbeat', None) toutes les `interval`s tant
qu'elle n'est pas terminée, puis ('result', valeur).
L'exception éventuelle de `coro` est propagée (re-levée par `task.result()`),
donc l'appelant peut l'attraper normalement. Si l'itération est abandonnée
(client déconnecté), la tâche sous-jacente est annulée.
"""
task: asyncio.Task = asyncio.ensure_future(coro)
try:
while not task.done():
done, _ = await asyncio.wait({task}, timeout=interval)
if not done:
yield ("heartbeat", None)
yield ("result", task.result())
finally:
if not task.done():
task.cancel()

View File

@@ -25,12 +25,16 @@ class Settings(BaseSettings):
extra="ignore", extra="ignore",
) )
# Provider LLM actif. "ollama" = local ; "onemin" = 1min.ai (etage 2). # Provider LLM actif. "ollama" = local ; "onemin" = 1min.ai ;
llm_provider: Literal["ollama", "onemin"] = "ollama" # "openrouter" = OpenRouter ; "mistral" = Mistral ; "gemini" = Google Gemini.
llm_provider: Literal["ollama", "onemin", "openrouter", "mistral", "gemini"] = "ollama"
ollama_base_url: str = "http://localhost:11434" ollama_base_url: str = "http://localhost:11434"
llm_model: str = "gemma4:26b" llm_model: str = "gemma4:26b"
llm_timeout_seconds: int = 120 # Timeout HTTP des appels au LLM. Les imports/adaptations PDF génèrent de gros
# blocs (surtout avec l'extraction riche) → 120s était trop court. Surchargeable
# depuis l'UI (Paramètres) si un import lourd dépasse encore.
llm_timeout_seconds: int = 300
# Fenêtre de contexte (num_ctx Ollama). Défaut Ollama = 2048, trop étroit # Fenêtre de contexte (num_ctx Ollama). Défaut Ollama = 2048, trop étroit
# dès que le Structural Context du Lore dépasse ~10 pages (b9). On monte # dès que le Structural Context du Lore dépasse ~10 pages (b9). On monte
@@ -44,6 +48,48 @@ class Settings(BaseSettings):
onemin_api_key: str = "" onemin_api_key: str = ""
onemin_model: str = "gpt-4o-mini" onemin_model: str = "gpt-4o-mini"
# OpenRouter (OpenAI-compatible). Cle + modele modifiables depuis l'UI.
# Defaut = routeur `openrouter/free` : choisit un modele GRATUIT (0 credit).
# Pour un modele precis gratuit : id finissant par `:free`.
openrouter_api_key: str = ""
openrouter_model: str = "openrouter/free"
# Mistral (La Plateforme, OpenAI-compatible). Cle + modele modifiables depuis
# l'UI. Tier gratuit « Experiment » sur console.mistral.ai (sans CB). Defaut =
# mistral-large-latest (128k contexte, bon en francais et en JSON fidele).
mistral_api_key: str = ""
mistral_model: str = "mistral-large-latest"
# Google Gemini (endpoint OpenAI-compatible). Cle gratuite sur
# aistudio.google.com (sans CB). Defaut = gemini-2.0-flash : ~1M de contexte
# (un livre tient en 1-2 appels), rapide, fidele, quota gratuit genereux.
gemini_api_key: str = ""
gemini_model: str = "gemini-2.0-flash"
# Embeddings (RAG des notebooks/ateliers). Modele SEPARE du chat.
# "ollama" = local (gratuit, illimite, ideal pour indexer un livre = bcp
# d'appels) ; "mistral" = cloud EU (mistral-embed, soumis au rate limit).
embedding_provider: Literal["ollama", "mistral"] = "ollama"
ollama_embedding_model: str = "nomic-embed-text"
mistral_embedding_model: str = "mistral-embed"
# Au démarrage, si le provider d'embeddings est Ollama et que le modèle n'est
# pas présent, le Brain le télécharge automatiquement (en arrière-plan) → le RAG
# marche "out of the box" pour un nouvel utilisateur. Désactivable (connexion
# limitée, gestion manuelle des modèles).
auto_pull_embedding_model: bool = True
# Nombre d'extraits récupérés par question dans le chat des ateliers (RAG).
# Plus haut = plus de couverture pour les questions larges (« liste les… »),
# mais prompt plus long. 8 par défaut (montable jusqu'à ~20 sur grand contexte).
rag_top_k: int = 8
# Taille cible d'un morceau (en tokens) pour l'import de PDF (regles/campagne).
# Plus c'est gros, moins il y a de morceaux => moins de fragmentation et un
# import plus rapide, MAIS il faut que ca tienne dans la fenetre du modele.
# Defaut prudent (compatible Ollama num_ctx 16384). Sur un modele a grand
# contexte (ex: GPT-5 mini, 400k), monter a ~100000 traite un livre en 1 passe.
import_chunk_tokens: int = 10000
# Secret partage entre le Core Spring et le Brain. Le Brain n'accepte une # Secret partage entre le Core Spring et le Brain. Le Brain n'accepte une
# requete que si l'entete X-Internal-Secret correspond. Volontairement # requete que si l'entete X-Internal-Secret correspond. Volontairement
# non-surchargeable via settings_store (securite critique, .env-only). # non-surchargeable via settings_store (securite critique, .env-only).

View File

@@ -29,6 +29,18 @@ _ALLOWED_KEYS = frozenset({
"llm_num_ctx", "llm_num_ctx",
"onemin_api_key", "onemin_api_key",
"onemin_model", "onemin_model",
"openrouter_api_key",
"openrouter_model",
"mistral_api_key",
"mistral_model",
"gemini_api_key",
"gemini_model",
"embedding_provider",
"ollama_embedding_model",
"mistral_embedding_model",
"auto_pull_embedding_model",
"rag_top_k",
"import_chunk_tokens",
}) })

View File

@@ -122,9 +122,29 @@ class SceneBranchHint:
condition: str | None = None condition: str | None = None
@dataclass(frozen=True)
class RoomBranchHint:
"""Indice d'une sortie entre pièces (donjon). target_room_name déjà résolu côté Core."""
label: str
target_room_name: str
condition: str | None = None
@dataclass(frozen=True)
class RoomSummary:
"""Pièce d'un lieu explorable. Projection plate pour le prompt IA (pas de notes MJ)."""
name: str
floor: int | None = None
description: str | None = None
enemies: str | None = None
branches: list[RoomBranchHint] = field(default_factory=list)
@dataclass(frozen=True) @dataclass(frozen=True)
class SceneSummary: class SceneSummary:
"""Résumé d'une scène : nom + description courte + illustrations + branches.""" """Résumé d'une scène : nom + description courte + illustrations + branches + pièces."""
name: str name: str
description: str | None description: str | None
@@ -133,6 +153,8 @@ class SceneSummary:
illustration_count: int = 0 illustration_count: int = 0
# Connexions narratives sortantes (livre dont vous etes le heros). # Connexions narratives sortantes (livre dont vous etes le heros).
branches: list[SceneBranchHint] = field(default_factory=list) branches: list[SceneBranchHint] = field(default_factory=list)
# Pièces du lieu explorable (vide = scène classique).
rooms: list[RoomSummary] = field(default_factory=list)
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -170,6 +192,7 @@ class CampaignStructuralContext:
campaign_description: str | None campaign_description: str | None
arcs: list[ArcSummary] arcs: list[ArcSummary]
characters: list["CharacterSummary"] = field(default_factory=list) characters: list["CharacterSummary"] = field(default_factory=list)
npcs: list["NpcSummary"] = field(default_factory=list)
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -185,6 +208,19 @@ class CharacterSummary:
snippet: str snippet: str
@dataclass(frozen=True)
class NpcSummary:
"""Résumé d'un PNJ : symétrique à CharacterSummary.
Permet à l'IA de connaître les PNJ d'une campagne (nom + snippet) sans
injecter leurs fiches complètes. Évolution prévue : entity_type="npc"
pour focus sur la fiche complète.
"""
name: str
snippet: str
@dataclass(frozen=True) @dataclass(frozen=True)
class NarrativeEntityContext: class NarrativeEntityContext:
"""Contexte d'une entité narrative précise en cours d'édition. """Contexte d'une entité narrative précise en cours d'édition.
@@ -215,3 +251,178 @@ class GameSystemContext:
system_name: str system_name: str
system_description: str | None system_description: str | None
sections: dict[str, str] sections: dict[str, str]
@dataclass(frozen=True)
class JournalEntrySummary:
"""Une entrée du journal d'une Session.
`source_session_name` n'est renseigné que pour les entrées issues de
sessions précédentes (option 3 : continuité narrative entre séances).
"""
type: str
content: str
occurred_at: str | None
source_session_name: str | None = None
@dataclass(frozen=True)
class QuestSummary:
"""Résumé d'une quête (Chapter dans un Arc HUB) pour le system prompt.
Volontairement sans notes MJ ni statut texte : c'est déjà classé côté Core
dans available_quests / in_progress_quests / locked_quest_titles.
"""
name: str
arc_name: str
description: str | None = None
@dataclass(frozen=True)
class SessionContext:
"""Contexte d'une Session de jeu en cours (Play Context).
Combine plusieurs niveaux :
- `entries` : journal COMPLET de la session courante (cappé ~80 entrées)
- `previous_events` : EVENTs marquants des sessions précédentes (continuité)
- `available_quests` / `in_progress_quests` : quêtes du Hub ouvertes
- `locked_quest_titles` : titres seuls des quêtes verrouillées (anti-spoiler)
- `active_flags` : noms des flags de campagne actuellement à true
"""
session_name: str
active: bool
started_at: str | None
entries: list[JournalEntrySummary]
previous_events: list[JournalEntrySummary]
available_quests: list[QuestSummary] = field(default_factory=list)
in_progress_quests: list[QuestSummary] = field(default_factory=list)
locked_quest_titles: list[str] = field(default_factory=list)
active_flags: list[str] = field(default_factory=list)
# ─────────────────────── Import de PDF (règles → GameSystem) ───────────────────────
@dataclass(frozen=True)
class ExtractedPage:
"""Texte extrait d'UNE page de PDF, avec la trace de la méthode utilisée.
`used_ocr=True` signale que la page n'avait pas de couche texte exploitable
(born-digital absent) et a donc été rasterisée puis passée à l'OCR. Permet
au CLI/diagnostic de dire à l'utilisateur si son PDF est "texte" ou "scan".
"""
index: int # 0-based
text: str
used_ocr: bool
@dataclass(frozen=True)
class ExtractedDocument:
"""Résultat brut de l'extraction d'un PDF : une entrée par page."""
pages: list[ExtractedPage]
@property
def page_count(self) -> int:
return len(self.pages)
@property
def ocr_page_count(self) -> int:
return sum(1 for p in self.pages if p.used_ocr)
@property
def full_text(self) -> str:
"""Concatène le texte de toutes les pages, séparées par un saut double."""
return "\n\n".join(p.text for p in self.pages if p.text.strip())
@dataclass(frozen=True)
class RulesImportResult:
"""Proposition structurée de règles : sections markdown indexées par titre.
`sections` = {titre H2 → contenu markdown}. C'est une PROPOSITION : rien
n'est persisté côté Core tant que l'utilisateur n'a pas validé/édité.
`page_count` / `ocr_page_count` remontent au diagnostic d'extraction.
"""
sections: dict[str, str]
page_count: int
ocr_page_count: int
def to_markdown(self) -> str:
"""Assemble les sections en un markdown monolithique (## titre + contenu).
Format aligné sur `GameSystem.rulesMarkdown` côté Core (découpé par H2).
"""
blocks = [f"## {title}\n\n{content.strip()}" for title, content in self.sections.items()]
return "\n\n".join(blocks).strip() + "\n"
# ─────────────────────── Import de PDF de campagne (arbre arc→chapitre→scène) ──────────────
@dataclass(frozen=True)
class RoomProposal:
"""Pièce d'un lieu explorable (donjon) proposée pour une scène."""
name: str
description: str
enemies: str = ""
loot: str = ""
@dataclass(frozen=True)
class SceneProposal:
"""Scène proposée. `rooms` non vide => donjon/lieu explorable.
On capture aussi, quand le livre les fournit, le texte d'encadré « à lire aux
joueurs » (`player_narration`) et les secrets/développement MJ (`gm_notes`).
"""
name: str
description: str
player_narration: str = ""
gm_notes: str = ""
rooms: list[RoomProposal] = field(default_factory=list)
@dataclass(frozen=True)
class ChapterProposal:
"""Chapitre proposé : nom + synopsis + ses scènes."""
name: str
description: str
scenes: list[SceneProposal] = field(default_factory=list)
@dataclass(frozen=True)
class ArcProposal:
"""Arc proposé : nom + synopsis + type (LINEAR/HUB) + ses chapitres."""
name: str
description: str
arc_type: str = "LINEAR"
chapters: list[ChapterProposal] = field(default_factory=list)
@dataclass(frozen=True)
class CampaignImportResult:
"""Proposition d'arborescence narrative extraite d'un PDF de campagne.
PROPOSITION non persistée : l'UI laisse l'utilisateur réviser/éditer l'arbre
avant la création effective des arcs/chapitres/scènes côté Core.
"""
arcs: list[ArcProposal]
page_count: int
ocr_page_count: int
def counts(self) -> tuple[int, int, int]:
"""(nb arcs, nb chapitres, nb scènes) — pour le diagnostic / la progression."""
chapters = sum(len(a.chapters) for a in self.arcs)
scenes = sum(len(c.scenes) for a in self.arcs for c in a.chapters)
return len(self.arcs), chapters, scenes

View File

@@ -7,7 +7,10 @@ En Python moderne on privilégie Protocol (PEP 544) sur ABC pour bénéficier
du duck typing structurel : toute classe qui possède les bonnes méthodes du duck typing structurel : toute classe qui possède les bonnes méthodes
satisfait le contrat, sans héritage explicite. satisfait le contrat, sans héritage explicite.
""" """
from typing import AsyncIterator, Protocol from typing import TYPE_CHECKING, AsyncIterator, Protocol
if TYPE_CHECKING:
from app.domain.models import ExtractedDocument
class LLMProvider(Protocol): class LLMProvider(Protocol):
@@ -78,6 +81,32 @@ class LLMChatProvider(Protocol):
... ...
class PdfTextExtractor(Protocol):
"""Port sortant — extrait le texte d'un PDF (born-digital ou scan).
L'implémentation décide de sa stratégie (couche texte directe, repli OCR
page par page…). Le domaine ne connaît ni PyMuPDF ni Tesseract.
"""
def extract(self, pdf_bytes: bytes) -> "ExtractedDocument":
"""Extrait le texte du PDF fourni sous forme d'octets.
Args:
pdf_bytes: contenu binaire du fichier PDF.
Returns:
ExtractedDocument : une entrée par page (texte + flag OCR).
Raises:
PdfExtractionError: si le PDF est illisible/corrompu.
"""
...
class PdfExtractionError(Exception):
"""Erreur du domaine : un PDF n'a pas pu être lu/extrait."""
class LLMProviderError(Exception): class LLMProviderError(Exception):
"""Erreur du domaine signalant qu'un LLMProvider n'a pas pu générer. """Erreur du domaine signalant qu'un LLMProvider n'a pas pu générer.

View File

@@ -0,0 +1,178 @@
"""Adapter Google Gemini — implémente les ports LLMProvider / LLMChatProvider.
Gemini expose un endpoint COMPATIBLE OpenAI
(POST {base}/openai/chat/completions, SSE), donc cet adapter est un client
"OpenAI-compatible" — même structure que les adapters OpenRouter / Mistral.
Tier GRATUIT : clé API sur aistudio.google.com (sans CB). Atout majeur pour
l'extraction de PDF : un CONTEXTE de ~1M tokens → un livre entier tient en 1-2
appels, donc quasi aucun morceau perdu et peu de requêtes (limites jamais
atteintes). Modèle conseillé : `gemini-2.0-flash` (rapide, gros contexte, fidèle).
"""
from __future__ import annotations
import asyncio
import json
import logging
from typing import AsyncIterator
import httpx
from app.core.config import Settings
from app.domain.models import ChatMessage
from app.domain.ports import LLMProviderError
logger = logging.getLogger(__name__)
_API_URL = "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions"
# Délai max pour le PREMIER token de contenu (échec rapide si le modèle ne produit
# rien). Gemini répond vite ; 120s est large.
_FIRST_TOKEN_TIMEOUT_SECONDS = 120.0
class GeminiLLMProvider:
"""Adapter Gemini (OpenAI-compatible) — satisfait LLMProvider et LLMChatProvider."""
def __init__(self, settings: Settings) -> None:
if not settings.gemini_api_key:
raise LLMProviderError(
"Clé API Gemini manquante. Configure-la depuis l'écran Paramètres "
"(clé gratuite sur aistudio.google.com)."
)
self._api_key = settings.gemini_api_key
self._model = settings.gemini_model
self._timeout = settings.llm_timeout_seconds
def _headers(self) -> dict[str, str]:
return {
"Authorization": f"Bearer {self._api_key}",
"Content-Type": "application/json",
"Accept": "application/json",
}
async def generate(
self,
prompt: str,
*,
output_format: str | None = None,
temperature: float | None = None,
) -> str:
"""One-shot via streaming (puis recollage), avec garde-fous au temps écoulé."""
return await self._collect_with_timeouts(
[ChatMessage(role="user", content=prompt)], temperature, output_format
)
async def _collect_with_timeouts(
self,
messages: list[ChatMessage],
temperature: float | None,
output_format: str | None,
) -> str:
"""Collecte le stream avec deux garde-fous : 1er token borné (échec rapide
si rien ne sort) + ceiling global `self._timeout`."""
async def _collect() -> str:
chunks: list[str] = []
agen = self._stream(messages, None, temperature, output_format)
try:
while True:
first = _FIRST_TOKEN_TIMEOUT_SECONDS if not chunks else None
try:
token = await asyncio.wait_for(agen.__anext__(), timeout=first)
except StopAsyncIteration:
break
except asyncio.TimeoutError:
raise LLMProviderError(
f"Erreur Gemini : aucun contenu produit en "
f"{int(_FIRST_TOKEN_TIMEOUT_SECONDS)}s. Réessayez ou vérifiez "
"votre quota gratuit."
)
chunks.append(token)
finally:
await agen.aclose()
return "".join(chunks)
try:
return await asyncio.wait_for(_collect(), timeout=self._timeout)
except asyncio.TimeoutError as exc:
raise LLMProviderError(
f"Erreur Gemini : génération non terminée en {self._timeout}s. Réduisez la "
"taille des morceaux d'import ou augmentez le timeout."
) from exc
async def stream_chat(
self,
messages: list[ChatMessage],
*,
system_prompt: str | None = None,
temperature: float | None = None,
) -> AsyncIterator[str]:
async for token in self._stream(messages, system_prompt, temperature):
yield token
async def _stream(
self,
messages: list[ChatMessage],
system_prompt: str | None,
temperature: float | None,
output_format: str | None = None,
) -> AsyncIterator[str]:
payload_messages: list[dict[str, str]] = []
if system_prompt:
payload_messages.append({"role": "system", "content": system_prompt})
for m in messages:
payload_messages.append({"role": m.role, "content": m.content})
body: dict[str, object] = {
"model": self._model,
"messages": payload_messages,
"stream": True,
}
if temperature is not None:
body["temperature"] = temperature
async with httpx.AsyncClient(timeout=self._timeout) as client:
try:
async with client.stream(
"POST", _API_URL, headers=self._headers(), json=body
) as response:
if response.status_code >= 400:
detail = (await response.aread()).decode("utf-8", "replace").strip()
raise LLMProviderError(
f"Erreur Gemini (HTTP {response.status_code})"
+ (f" : {detail[:500]}" if detail else "")
)
async for token in self._parse_sse(response):
yield token
except httpx.HTTPError as exc:
raise LLMProviderError(self._format_http_error(exc)) from exc
@staticmethod
async def _parse_sse(response: httpx.Response) -> AsyncIterator[str]:
"""SSE OpenAI : lignes `data: {json}`, fin sur `data: [DONE]`."""
async for line in response.aiter_lines():
if not line or not line.startswith("data:"):
continue
data = line[len("data:"):].strip()
if data == "[DONE]":
return
try:
obj = json.loads(data)
except json.JSONDecodeError:
continue
choices = obj.get("choices")
if not choices:
continue
delta = choices[0].get("delta") or {}
content = delta.get("content")
if content:
yield content
def _format_http_error(self, exc: httpx.HTTPError) -> str:
if isinstance(exc, httpx.TimeoutException):
return (
f"Erreur Gemini : délai dépassé (timeout {self._timeout}s). Le modèle a "
"mis trop de temps — réduis la taille des morceaux d'import ou augmente le timeout."
)
detail = str(exc) or exc.__class__.__name__
return f"Erreur Gemini ({exc.__class__.__name__}) : {detail}"

View File

@@ -0,0 +1,188 @@
"""Adapter Mistral — implémente les ports LLMProvider / LLMChatProvider.
Mistral (La Plateforme) expose l'API OpenAI standard (POST {base}/chat/completions,
SSE), donc cet adapter est un client "OpenAI-compatible" — même structure que
l'adapter OpenRouter. Le `generate` one-shot passe par le streaming (puis
recollage) avec un timeout au temps écoulé pour ne jamais pendre à l'infini.
Tier GRATUIT : compte sur console.mistral.ai (tier « Experiment »), clé API à
coller dans l'écran Paramètres. Modèles conseillés pour l'extraction : un grand
contexte fidèle comme `mistral-large-latest` (128k) ou `mistral-small-latest`.
"""
from __future__ import annotations
import asyncio
import json
import logging
from typing import AsyncIterator
import httpx
from app.core.config import Settings
from app.domain.models import ChatMessage
from app.domain.ports import LLMProviderError
logger = logging.getLogger(__name__)
_API_URL = "https://api.mistral.ai/v1/chat/completions"
# Délai max pour le PREMIER token de contenu (échec rapide si le modèle est en file
# d'attente et n'envoie que des keep-alive). Généreux car la file d'un tier gratuit
# peut être longue.
_FIRST_TOKEN_TIMEOUT_SECONDS = 120.0
class MistralLLMProvider:
"""Adapter Mistral (OpenAI-compatible) — satisfait LLMProvider et LLMChatProvider."""
def __init__(self, settings: Settings) -> None:
if not settings.mistral_api_key:
raise LLMProviderError(
"Clé API Mistral manquante. Configure-la depuis l'écran Paramètres."
)
self._api_key = settings.mistral_api_key
self._model = settings.mistral_model
self._timeout = settings.llm_timeout_seconds
def _headers(self) -> dict[str, str]:
return {
"Authorization": f"Bearer {self._api_key}",
"Content-Type": "application/json",
"Accept": "application/json",
}
async def generate(
self,
prompt: str,
*,
output_format: str | None = None,
temperature: float | None = None,
) -> str:
"""One-shot via streaming (puis recollage) pour robustesse sur longues sorties.
Timeout au TEMPS ÉCOULÉ (asyncio) en plus du timeout réseau d'httpx :
si le provider envoyait des keep-alive sans contenu, l'appel pendrait à
l'infini. Ici on coupe net après `self._timeout` secondes.
"""
return await self._collect_with_timeouts(
[ChatMessage(role="user", content=prompt)], temperature, output_format
)
async def _collect_with_timeouts(
self,
messages: list[ChatMessage],
temperature: float | None,
output_format: str | None,
) -> str:
"""Collecte le stream avec deux garde-fous au temps écoulé : 1er token borné
(file d'attente → échec rapide) + ceiling global `self._timeout`."""
async def _collect() -> str:
chunks: list[str] = []
agen = self._stream(messages, None, temperature, output_format)
try:
while True:
first = _FIRST_TOKEN_TIMEOUT_SECONDS if not chunks else None
try:
token = await asyncio.wait_for(agen.__anext__(), timeout=first)
except StopAsyncIteration:
break
except asyncio.TimeoutError:
raise LLMProviderError(
f"Erreur Mistral : aucun contenu produit en "
f"{int(_FIRST_TOKEN_TIMEOUT_SECONDS)}s — le modèle est probablement "
"en file d'attente (tier gratuit, 2 req/min). Réessayez plus tard ou "
"choisissez un modèle plus disponible."
)
chunks.append(token)
finally:
await agen.aclose()
return "".join(chunks)
try:
return await asyncio.wait_for(_collect(), timeout=self._timeout)
except asyncio.TimeoutError as exc:
raise LLMProviderError(
f"Erreur Mistral : génération non terminée en {self._timeout}s. Réduisez la "
"taille des morceaux d'import, augmentez le timeout, ou changez de modèle."
) from exc
async def stream_chat(
self,
messages: list[ChatMessage],
*,
system_prompt: str | None = None,
temperature: float | None = None,
) -> AsyncIterator[str]:
async for token in self._stream(messages, system_prompt, temperature):
yield token
async def _stream(
self,
messages: list[ChatMessage],
system_prompt: str | None,
temperature: float | None,
output_format: str | None = None,
) -> AsyncIterator[str]:
payload_messages: list[dict[str, str]] = []
if system_prompt:
payload_messages.append({"role": "system", "content": system_prompt})
for m in messages:
payload_messages.append({"role": m.role, "content": m.content})
body: dict[str, object] = {
"model": self._model,
"messages": payload_messages,
"stream": True,
}
if temperature is not None:
body["temperature"] = temperature
async with httpx.AsyncClient(timeout=self._timeout) as client:
try:
async with client.stream(
"POST", _API_URL, headers=self._headers(), json=body
) as response:
if response.status_code >= 400:
# En streaming le corps n'est pas lu automatiquement : on le
# lit pour exposer le détail de Mistral (modèle inconnu, clé
# invalide 401, quota 429…), sinon on n'a que le code HTTP nu.
detail = (await response.aread()).decode("utf-8", "replace").strip()
raise LLMProviderError(
f"Erreur Mistral (HTTP {response.status_code})"
+ (f" : {detail[:500]}" if detail else "")
)
async for token in self._parse_sse(response):
yield token
except httpx.HTTPError as exc:
raise LLMProviderError(self._format_http_error(exc)) from exc
@staticmethod
async def _parse_sse(response: httpx.Response) -> AsyncIterator[str]:
"""SSE OpenAI : lignes `data: {json}`, fin sur `data: [DONE]`."""
async for line in response.aiter_lines():
if not line or not line.startswith("data:"):
continue # lignes vides ou keep-alive (`: ...`)
data = line[len("data:"):].strip()
if data == "[DONE]":
return
try:
obj = json.loads(data)
except json.JSONDecodeError:
continue
choices = obj.get("choices")
if not choices:
continue
delta = choices[0].get("delta") or {}
content = delta.get("content")
if content:
yield content
def _format_http_error(self, exc: httpx.HTTPError) -> str:
"""Message lisible (timeout, quota 429, clé invalide 401, modèle inconnu…)."""
if isinstance(exc, httpx.TimeoutException):
return (
f"Erreur Mistral : délai dépassé (timeout {self._timeout}s). Le modèle a "
"mis trop de temps — réduis la taille des morceaux d'import ou augmente le timeout."
)
detail = str(exc) or exc.__class__.__name__
return f"Erreur Mistral ({exc.__class__.__name__}) : {detail}"

View File

@@ -0,0 +1,58 @@
"""Adapter d'embeddings Mistral (cloud, EU) — POST /v1/embeddings.
Soumis au rate limit du tier gratuit : pour indexer un gros document on envoie
les textes par lots (et l'appelant peut espacer les appels si besoin).
"""
from __future__ import annotations
import httpx
from app.application.embeddings import EmbeddingError
from app.core.config import Settings
_API_URL = "https://api.mistral.ai/v1/embeddings"
# Lot raisonnable pour ne pas envoyer un payload géant d'un coup.
_BATCH_SIZE = 64
class MistralEmbeddingProvider:
"""Implémente EmbeddingProvider via l'API Mistral embeddings."""
def __init__(self, settings: Settings) -> None:
if not settings.mistral_api_key:
raise EmbeddingError(
"Clé API Mistral manquante (requise pour les embeddings Mistral). "
"Configure-la dans les Paramètres ou choisis Ollama pour les embeddings."
)
self._api_key = settings.mistral_api_key
self._model = settings.mistral_embedding_model
self._timeout = settings.llm_timeout_seconds
async def embed(self, texts: list[str]) -> list[list[float]]:
if not texts:
return []
out: list[list[float]] = []
headers = {
"Authorization": f"Bearer {self._api_key}",
"Content-Type": "application/json",
}
async with httpx.AsyncClient(timeout=self._timeout) as client:
for start in range(0, len(texts), _BATCH_SIZE):
batch = texts[start:start + _BATCH_SIZE]
try:
response = await client.post(
_API_URL, headers=headers, json={"model": self._model, "input": batch})
if response.status_code >= 400:
raise EmbeddingError(
f"Mistral embeddings HTTP {response.status_code} : "
f"{response.text.strip()[:300]}")
data = response.json()
except httpx.HTTPError as exc:
raise EmbeddingError(f"Erreur Mistral embeddings : {exc}") from exc
items = data.get("data")
if not isinstance(items, list) or len(items) != len(batch):
raise EmbeddingError("Réponse d'embeddings Mistral inattendue (taille incohérente).")
for item in items:
out.append([float(x) for x in item.get("embedding", [])])
return out

View File

@@ -61,7 +61,16 @@ class OllamaLLMProvider:
async with httpx.AsyncClient(timeout=self._timeout) as client: async with httpx.AsyncClient(timeout=self._timeout) as client:
try: try:
response = await client.post(url, json=payload) response = await client.post(url, json=payload)
response.raise_for_status() if response.status_code >= 400:
body = response.text
try:
err_obj = json.loads(body)
err_msg = err_obj.get("error") or body
except json.JSONDecodeError:
err_msg = body
raise LLMProviderError(
f"Ollama HTTP {response.status_code} : {err_msg.strip()[:500]}"
)
except httpx.HTTPError as exc: except httpx.HTTPError as exc:
raise LLMProviderError( raise LLMProviderError(
f"Erreur lors de l'appel à Ollama : {exc}" f"Erreur lors de l'appel à Ollama : {exc}"
@@ -105,7 +114,20 @@ class OllamaLLMProvider:
async with httpx.AsyncClient(timeout=self._timeout) as client: async with httpx.AsyncClient(timeout=self._timeout) as client:
try: try:
async with client.stream("POST", url, json=payload) as response: async with client.stream("POST", url, json=payload) as response:
response.raise_for_status() if response.status_code >= 400:
# On lit le body d'erreur pour le remonter a l'utilisateur,
# sinon on ne voit que "500 Internal Server Error" sans
# savoir POURQUOI Ollama refuse (modele introuvable, OOM,
# num_ctx trop grand pour la VRAM, etc.).
body = (await response.aread()).decode("utf-8", errors="replace")
try:
err_obj = json.loads(body)
err_msg = err_obj.get("error") or body
except json.JSONDecodeError:
err_msg = body
raise LLMProviderError(
f"Ollama HTTP {response.status_code} : {err_msg.strip()[:500]}"
)
async for line in response.aiter_lines(): async for line in response.aiter_lines():
if not line.strip(): if not line.strip():
continue continue

View File

@@ -0,0 +1,43 @@
"""Adapter d'embeddings Ollama (local) — endpoint /api/embed.
Gratuit et illimité (tourne sur la machine). Nécessite d'avoir pullé le modèle
d'embedding (ex. `ollama pull nomic-embed-text`).
"""
from __future__ import annotations
import httpx
from app.application.embeddings import EmbeddingError
from app.core.config import Settings
class OllamaEmbeddingProvider:
"""Implémente EmbeddingProvider via Ollama /api/embed (batch)."""
def __init__(self, settings: Settings) -> None:
self._base_url = settings.ollama_base_url
self._model = settings.ollama_embedding_model
self._timeout = settings.llm_timeout_seconds
async def embed(self, texts: list[str]) -> list[list[float]]:
if not texts:
return []
url = f"{self._base_url}/api/embed"
payload = {"model": self._model, "input": texts}
async with httpx.AsyncClient(timeout=self._timeout) as client:
try:
response = await client.post(url, json=payload)
if response.status_code >= 400:
body = response.text
raise EmbeddingError(
f"Ollama embeddings HTTP {response.status_code} : {body.strip()[:300]}. "
f"Le modèle '{self._model}' est-il installé ? (ollama pull {self._model})"
)
data = response.json()
except httpx.HTTPError as exc:
raise EmbeddingError(f"Erreur Ollama embeddings : {exc}") from exc
vectors = data.get("embeddings")
if not isinstance(vectors, list) or len(vectors) != len(texts):
raise EmbeddingError("Réponse d'embeddings Ollama inattendue (taille incohérente).")
return [[float(x) for x in v] for v in vectors]

View File

@@ -14,6 +14,7 @@ avec des marqueurs de role lisibles pour le modele.
from __future__ import annotations from __future__ import annotations
import json import json
import logging
from typing import AsyncIterator from typing import AsyncIterator
import httpx import httpx
@@ -22,6 +23,8 @@ from app.core.config import Settings
from app.domain.models import ChatMessage from app.domain.models import ChatMessage
from app.domain.ports import LLMProviderError from app.domain.ports import LLMProviderError
logger = logging.getLogger(__name__)
_API_BASE = "https://api.1min.ai/api/chat-with-ai" _API_BASE = "https://api.1min.ai/api/chat-with-ai"
_PAYLOAD_TYPE = "UNIFY_CHAT_WITH_AI" _PAYLOAD_TYPE = "UNIFY_CHAT_WITH_AI"
@@ -48,6 +51,18 @@ class OneMinAiLLMProvider:
"promptObject": {"prompt": prompt}, "promptObject": {"prompt": prompt},
} }
def _format_http_error(self, exc: httpx.HTTPError) -> str:
"""Message d'erreur lisible. Un timeout httpx a un str() vide → on le nomme."""
if isinstance(exc, httpx.TimeoutException):
return (
f"Erreur 1min.ai : délai dépassé (timeout {self._timeout}s). Le modèle a mis "
"trop de temps à répondre — typique d'un morceau d'import trop gros. "
"Réduisez « Taille des morceaux à l'import » (Paramètres → Import de PDF) "
"ou augmentez le timeout LLM."
)
detail = str(exc) or exc.__class__.__name__
return f"Erreur 1min.ai ({exc.__class__.__name__}) : {detail}"
async def generate( async def generate(
self, self,
prompt: str, prompt: str,
@@ -55,18 +70,18 @@ class OneMinAiLLMProvider:
output_format: str | None = None, # 1min.ai ne supporte pas format=json output_format: str | None = None, # 1min.ai ne supporte pas format=json
temperature: float | None = None, # idem, pas d'hyperparam expose ici temperature: float | None = None, # idem, pas d'hyperparam expose ici
) -> str: ) -> str:
"""Appel one-shot : retourne la reponse complete sous forme de string.""" """One-shot, mais via l'endpoint STREAMING (puis recollage).
async with httpx.AsyncClient(timeout=self._timeout) as client:
try:
response = await client.post(
_API_BASE, headers=self._headers(), json=self._payload(prompt)
)
response.raise_for_status()
data = response.json()
except httpx.HTTPError as exc:
raise LLMProviderError(f"Erreur 1min.ai : {exc}") from exc
return self._extract_result(data) On NE passe PAS par l'endpoint non-streame `chat-with-ai` : sur les longues
generations (gros imports), la passerelle Cloudflare de 1min.ai coupe la
connexion au bout de ~100s et renvoie un HTTP 524. En streaming, des octets
circulent en continu => pas de 524, quelle que soit la duree. On accumule
tous les fragments pour reconstituer la reponse complete.
"""
chunks: list[str] = []
async for token in self._stream_prompt(prompt):
chunks.append(token)
return "".join(chunks)
async def stream_chat( async def stream_chat(
self, self,
@@ -75,17 +90,18 @@ class OneMinAiLLMProvider:
system_prompt: str | None = None, system_prompt: str | None = None,
temperature: float | None = None, temperature: float | None = None,
) -> AsyncIterator[str]: ) -> AsyncIterator[str]:
"""Streame via SSE. """Streame une conversation : aplatit les messages puis delegue au coeur SSE."""
1min.ai expose deux evenements utiles :
- `event: content` → `data: {"content": "..."}`
- `event: done` → fin du stream
- `event: error` → erreur serveur
On yield le champ `content` au fil de l'arrivee.
"""
prompt = self._flatten_messages(messages, system_prompt) prompt = self._flatten_messages(messages, system_prompt)
url = f"{_API_BASE}?isStreaming=true" async for token in self._stream_prompt(prompt):
yield token
async def _stream_prompt(self, prompt: str) -> AsyncIterator[str]:
"""Coeur du streaming SSE 1min.ai (`?isStreaming=true`) pour un prompt brut.
1min.ai expose : `event: content` → `data: {"content": "..."}`, `event: done`,
`event: error`. On yield le champ `content` au fil de l'arrivee.
"""
url = f"{_API_BASE}?isStreaming=true"
async with httpx.AsyncClient(timeout=self._timeout) as client: async with httpx.AsyncClient(timeout=self._timeout) as client:
try: try:
async with client.stream( async with client.stream(
@@ -95,9 +111,7 @@ class OneMinAiLLMProvider:
async for token in self._parse_sse(response): async for token in self._parse_sse(response):
yield token yield token
except httpx.HTTPError as exc: except httpx.HTTPError as exc:
raise LLMProviderError( raise LLMProviderError(self._format_http_error(exc)) from exc
f"Erreur lors du streaming 1min.ai : {exc}"
) from exc
# --- Helpers ------------------------------------------------------------ # --- Helpers ------------------------------------------------------------
@@ -146,12 +160,21 @@ class OneMinAiLLMProvider:
""" """
record = payload.get("aiRecord") or {} record = payload.get("aiRecord") or {}
detail = record.get("aiRecordDetail") or {} detail = record.get("aiRecordDetail") or {}
result = detail.get("resultObject") or [] result = detail.get("resultObject")
if isinstance(result, list): if isinstance(result, list) and result:
return "".join(str(x) for x in result) return "".join(str(x) for x in result)
if isinstance(result, str): if isinstance(result, str) and result:
return result return result
raise LLMProviderError("Reponse 1min.ai inattendue : resultObject absent.")
# Schema inattendu : on remonte un EXTRAIT du vrai payload pour diagnostiquer.
# Causes frequentes : credits/quota 1min.ai epuises, moderation, modele
# indisponible, ou reponse asynchrone (record cree mais resultat pas encore
# pret). Sans ce detail, l'erreur "resultObject absent" est aveugle.
snippet = json.dumps(payload, ensure_ascii=False)
if len(snippet) > 800:
snippet = snippet[:800] + ""
logger.warning("Reponse 1min.ai inattendue (resultObject absent) : %s", snippet)
raise LLMProviderError(f"Reponse 1min.ai inattendue (resultObject absent) : {snippet}")
@staticmethod @staticmethod
def _flatten_messages( def _flatten_messages(

View File

@@ -0,0 +1,205 @@
"""Adapter OpenRouter — implémente les ports LLMProvider / LLMChatProvider.
OpenRouter expose l'API OpenAI standard (POST {base}/chat/completions, SSE), donc
cet adapter est en réalité un client "OpenAI-compatible". Le `generate` one-shot
passe lui aussi par le streaming (puis recollage) pour éviter les coupures de
passerelle sur les longues générations (cf. 1min.ai / Cloudflare 524).
Modèles GRATUITS : utiliser un id finissant par `:free` (ex.
`meta-llama/llama-3.3-70b-instruct:free`) ou le routeur `openrouter/free` (défaut)
qui choisit automatiquement un modèle gratuit — aucun crédit consommé.
"""
from __future__ import annotations
import asyncio
import json
import logging
from typing import AsyncIterator
import httpx
from app.core.config import Settings
from app.domain.models import ChatMessage
logger = logging.getLogger(__name__)
from app.domain.ports import LLMProviderError
_API_URL = "https://openrouter.ai/api/v1/chat/completions"
# Délai max pour le PREMIER token de contenu. Un modèle gratuit "en file d'attente"
# n'envoie que des keep-alive (aucun contenu) → on échoue vite et clairement au lieu
# de pendre. Généreux (2 min) car la file d'attente d'un tier gratuit peut être longue.
_FIRST_TOKEN_TIMEOUT_SECONDS = 120.0
class OpenRouterLLMProvider:
"""Adapter OpenRouter (OpenAI-compatible) — satisfait LLMProvider et LLMChatProvider."""
def __init__(self, settings: Settings) -> None:
if not settings.openrouter_api_key:
raise LLMProviderError(
"Clé API OpenRouter manquante. Configure-la depuis l'écran Paramètres."
)
self._api_key = settings.openrouter_api_key
self._model = settings.openrouter_model
self._timeout = settings.llm_timeout_seconds
def _headers(self) -> dict[str, str]:
return {
"Authorization": f"Bearer {self._api_key}",
"Content-Type": "application/json",
# Attribution facultative (classement OpenRouter) — sans impact fonctionnel.
"HTTP-Referer": "https://loremind.app",
"X-Title": "LoreMind",
}
async def generate(
self,
prompt: str,
*,
output_format: str | None = None,
temperature: float | None = None,
) -> str:
"""One-shot via streaming (puis recollage) pour robustesse sur longues sorties.
Timeout au TEMPS ÉCOULÉ (asyncio) en plus du timeout réseau d'httpx : un
modèle gratuit saturé/en file d'attente envoie des keep-alive (`: OPENROUTER
PROCESSING`) mais AUCUN contenu → httpx ne déclenche jamais son read-timeout
(des octets arrivent) et l'appel pendrait à l'infini. Ici on coupe net après
`self._timeout` secondes, quoi qu'il arrive.
"""
return await self._collect_with_timeouts(
[ChatMessage(role="user", content=prompt)], temperature, output_format, "OpenRouter"
)
async def _collect_with_timeouts(
self,
messages: list[ChatMessage],
temperature: float | None,
output_format: str | None,
provider: str,
) -> str:
"""Collecte le stream avec DEUX garde-fous au temps écoulé :
- 1er token borné (`_FIRST_TOKEN_TIMEOUT_SECONDS`) : détecte un modèle bloqué
en file d'attente (que des keep-alive, aucun contenu) → échec rapide ;
- ceiling global (`self._timeout`) : génération qui ne se termine jamais.
Le timeout réseau d'httpx ne suffit pas : des keep-alive font 'arriver des
octets' et empêchent son read-timeout de se déclencher.
"""
async def _collect() -> str:
chunks: list[str] = []
agen = self._stream(messages, None, temperature, output_format)
try:
while True:
# Borne SEULEMENT l'attente du 1er token (file d'attente) ; ensuite
# on laisse générer (le ceiling global couvre le reste).
first = _FIRST_TOKEN_TIMEOUT_SECONDS if not chunks else None
try:
token = await asyncio.wait_for(agen.__anext__(), timeout=first)
except StopAsyncIteration:
break
except asyncio.TimeoutError:
raise LLMProviderError(
f"Erreur {provider} : aucun contenu produit en "
f"{int(_FIRST_TOKEN_TIMEOUT_SECONDS)}s — le modèle gratuit est "
"probablement en file d'attente / saturé. Réessayez plus tard ou "
"choisissez un autre modèle (1min.ai, ou payant)."
)
chunks.append(token)
finally:
await agen.aclose()
return "".join(chunks)
try:
return await asyncio.wait_for(_collect(), timeout=self._timeout)
except asyncio.TimeoutError as exc:
raise LLMProviderError(
f"Erreur {provider} : génération non terminée en {self._timeout}s. Réduisez la "
"taille des morceaux d'import, augmentez le timeout, ou changez de modèle."
) from exc
async def stream_chat(
self,
messages: list[ChatMessage],
*,
system_prompt: str | None = None,
temperature: float | None = None,
) -> AsyncIterator[str]:
async for token in self._stream(messages, system_prompt, temperature):
yield token
async def _stream(
self,
messages: list[ChatMessage],
system_prompt: str | None,
temperature: float | None,
output_format: str | None = None,
) -> AsyncIterator[str]:
payload_messages: list[dict[str, str]] = []
if system_prompt:
payload_messages.append({"role": "system", "content": system_prompt})
for m in messages:
payload_messages.append({"role": m.role, "content": m.content})
body: dict[str, object] = {
"model": self._model,
"messages": payload_messages,
"stream": True,
}
if temperature is not None:
body["temperature"] = temperature
# NB : on n'impose PAS `response_format=json_object`. Beaucoup de modèles/
# providers GRATUITS ne le supportent pas et renvoient une réponse VIDE.
# On laisse le modèle répondre librement ; l'extraction JSON en aval
# (load_json_object + nettoyage du raisonnement) récupère le JSON dans la prose.
async with httpx.AsyncClient(timeout=self._timeout) as client:
try:
async with client.stream(
"POST", _API_URL, headers=self._headers(), json=body
) as response:
if response.status_code >= 400:
# En streaming, le corps n'est pas lu automatiquement : on le
# lit pour exposer le détail d'OpenRouter (ex. le 429 précise
# "free-models-per-day" vs "per-minute"), sinon on n'a que le
# code HTTP nu et le diagnostic est impossible.
detail = (await response.aread()).decode("utf-8", "replace").strip()
raise LLMProviderError(
f"Erreur OpenRouter (HTTP {response.status_code})"
+ (f" : {detail[:500]}" if detail else "")
)
async for token in self._parse_sse(response):
yield token
except httpx.HTTPError as exc:
raise LLMProviderError(self._format_http_error(exc)) from exc
@staticmethod
async def _parse_sse(response: httpx.Response) -> AsyncIterator[str]:
"""SSE OpenAI : lignes `data: {json}`, fin sur `data: [DONE]`."""
async for line in response.aiter_lines():
if not line or not line.startswith("data:"):
continue # lignes vides ou commentaires keep-alive (`: ...`)
data = line[len("data:"):].strip()
if data == "[DONE]":
return
try:
obj = json.loads(data)
except json.JSONDecodeError:
continue
choices = obj.get("choices")
if not choices:
continue
delta = choices[0].get("delta") or {}
content = delta.get("content")
if content:
yield content
def _format_http_error(self, exc: httpx.HTTPError) -> str:
"""Message lisible (timeout, quota 429, crédits 402, modèle inconnu…)."""
if isinstance(exc, httpx.TimeoutException):
return (
f"Erreur OpenRouter : délai dépassé (timeout {self._timeout}s). Le modèle a "
"mis trop de temps — réduis la taille des morceaux d'import ou augmente le timeout."
)
detail = str(exc) or exc.__class__.__name__
return f"Erreur OpenRouter ({exc.__class__.__name__}) : {detail}"

View File

@@ -0,0 +1,102 @@
"""Adapter d'extraction de texte PDF — implémente le port PdfTextExtractor.
Stratégie HYBRIDE auto :
1. On tente d'abord l'extraction de la couche texte (PyMuPDF). Les PDF
"born-digital" (livres de règles officiels type Nimble, faits dans
InDesign/Affinity : très graphiques mais avec une vraie couche texte)
passent par là → rapide, fidèle, AUCUN OCR.
2. Si une page ne rend (quasi) aucun texte → c'est probablement une image
(scan ou page 100% illustrée). On rasterise la page et on la passe à
Tesseract (OCR). Gère donc aussi les scans purs et les PDF mixtes.
Tesseract est un binaire SYSTÈME (installé dans l'image Docker du Brain). S'il
est absent (ex: exécution locale Windows sans install), l'OCR est désactivé
proprement : les pages-images ressortent vides mais l'extraction ne plante pas,
et le diagnostic le signale (used_ocr reste False, texte vide).
"""
from __future__ import annotations
import logging
import pymupdf as fitz # PyMuPDF — on importe par le nom canonique `pymupdf`
# (et NON `import fitz`) pour éviter la collision avec le faux paquet PyPI "fitz"
# qui échoue sur `from frontend import *`.
from app.domain.models import ExtractedDocument, ExtractedPage
from app.domain.ports import PdfExtractionError
logger = logging.getLogger(__name__)
# En dessous de ce nombre de caractères "significatifs" sur une page, on
# considère qu'il n'y a pas de couche texte exploitable → repli OCR.
_MIN_TEXT_CHARS = 20
# DPI de rasterisation avant OCR. 300 = bon compromis qualité/vitesse pour du
# texte de livre. Plus haut = plus lent et plus gourmand en mémoire.
_OCR_DPI = 300
# Langues Tesseract : français + anglais (la plupart des règles de JDR FR ont
# des termes anglais résiduels). Doivent être installées dans l'image Docker
# (tesseract-ocr-fra, tesseract-ocr-eng).
_OCR_LANGS = "fra+eng"
class PyMuPdfTextExtractor:
"""Extracteur PDF basé sur PyMuPDF, avec repli OCR Tesseract optionnel."""
def __init__(self) -> None:
# On détecte la disponibilité de l'OCR une seule fois (le binaire
# Tesseract ne va pas apparaître/disparaître en cours d'exécution).
self._ocr_available = self._detect_ocr()
@staticmethod
def _detect_ocr() -> bool:
"""True si pytesseract + le binaire Tesseract sont disponibles."""
try:
import pytesseract
pytesseract.get_tesseract_version()
return True
except Exception as exc: # ImportError, TesseractNotFoundError, etc.
logger.warning(
"OCR indisponible (Tesseract non installé ?) : %s. "
"Les pages sans couche texte ressortiront vides.",
exc,
)
return False
def extract(self, pdf_bytes: bytes) -> ExtractedDocument:
try:
doc = fitz.open(stream=pdf_bytes, filetype="pdf")
except Exception as exc:
raise PdfExtractionError(f"PDF illisible ou corrompu : {exc}") from exc
pages: list[ExtractedPage] = []
try:
for index, page in enumerate(doc):
text = (page.get_text() or "").strip()
used_ocr = False
if len(text) < _MIN_TEXT_CHARS and self._ocr_available:
ocr_text = self._ocr_page(page)
if ocr_text.strip():
text = ocr_text.strip()
used_ocr = True
pages.append(ExtractedPage(index=index, text=text, used_ocr=used_ocr))
finally:
doc.close()
return ExtractedDocument(pages=pages)
@staticmethod
def _ocr_page(page: "fitz.Page") -> str:
"""Rasterise une page et lui applique l'OCR Tesseract."""
import pytesseract
from PIL import Image
pix = page.get_pixmap(dpi=_OCR_DPI)
img = Image.frombytes("RGB", (pix.width, pix.height), pix.samples)
try:
return pytesseract.image_to_string(img, lang=_OCR_LANGS)
except Exception as exc:
logger.warning("Échec OCR sur la page %s : %s", page.number, exc)
return ""

View File

@@ -0,0 +1,109 @@
"""Stockage vectoriel fichier (RAG des notebooks) — sans dépendance lourde.
Chaque SOURCE est persistée en un fichier JSON sur le volume `data/` du Brain :
data/notebooks/{source_id}.json = {"dim": N, "chunks": [{"text":..., "vector":[...]}]}
À l'échelle d'un livre (quelques centaines d'extraits), une recherche cosinus en
Python pur est instantanée — inutile d'ajouter numpy/pgvector/une base vectorielle.
"""
from __future__ import annotations
import json
import math
import re
from pathlib import Path
_STORE_DIR = Path("data/notebooks")
_SAFE_ID = re.compile(r"[^A-Za-z0-9_-]")
def _path(source_id: str) -> Path:
safe = _SAFE_ID.sub("_", str(source_id))
return _STORE_DIR / f"{safe}.json"
def save(
source_id: str,
chunks: list[str],
vectors: list[list[float]],
pages: list[int] | None = None,
) -> int:
"""Persiste les (chunk, vecteur[, page]) d'une source. Renvoie le nb d'extraits."""
if len(chunks) != len(vectors):
raise ValueError("chunks et vectors de tailles différentes")
if pages is not None and len(pages) != len(chunks):
raise ValueError("pages et chunks de tailles différentes")
_STORE_DIR.mkdir(parents=True, exist_ok=True)
items = []
for i, (c, v) in enumerate(zip(chunks, vectors)):
item = {"text": c, "vector": v}
if pages is not None:
item["page"] = pages[i]
items.append(item)
payload = {"dim": len(vectors[0]) if vectors else 0, "chunks": items}
_path(source_id).write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
return len(chunks)
def exists(source_id: str) -> bool:
return _path(source_id).exists()
def delete(source_id: str) -> None:
_path(source_id).unlink(missing_ok=True)
def _load(source_id: str) -> list[dict]:
p = _path(source_id)
if not p.exists():
return []
try:
data = json.loads(p.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return []
return data.get("chunks", []) if isinstance(data, dict) else []
def all_chunks(source_id: str) -> list[dict]:
"""Tous les extraits d'une source (texte + page), sans vecteurs — pour le mode
« analyse approfondie » (map-reduce sur tout le document)."""
return [{"text": c.get("text", ""), "page": c.get("page")} for c in _load(source_id)]
def _cosine(a: list[float], b: list[float]) -> float:
if not a or not b or len(a) != len(b):
return 0.0
dot = 0.0
na = 0.0
nb = 0.0
for x, y in zip(a, b):
dot += x * y
na += x * x
nb += y * y
if na == 0.0 or nb == 0.0:
return 0.0
return dot / (math.sqrt(na) * math.sqrt(nb))
def search(
source_ids: list[str],
query_vector: list[float],
top_k: int = 6,
) -> list[dict]:
"""Renvoie les `top_k` extraits les plus proches, toutes sources confondues.
Chaque résultat : {"text": str, "score": float, "source_id": str}.
"""
scored: list[dict] = []
for sid in source_ids:
for chunk in _load(sid):
vector = chunk.get("vector") or []
score = _cosine(query_vector, vector)
scored.append({
"text": chunk.get("text", ""),
"score": score,
"source_id": sid,
"page": chunk.get("page"),
})
scored.sort(key=lambda c: c["score"], reverse=True)
return scored[:top_k]

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,8 @@ fastapi==0.115.*
uvicorn[standard]==0.32.* uvicorn[standard]==0.32.*
httpx==0.27.* httpx==0.27.*
pydantic-settings==2.6.* pydantic-settings==2.6.*
# Requis par FastAPI pour les uploads multipart (UploadFile) — import de PDF.
python-multipart==0.0.*
pydantic pydantic
@@ -10,3 +12,12 @@ pydantic
# la plupart des modeles Llama/Gemma/Mistral (~5-10% d'ecart) — suffisant # la plupart des modeles Llama/Gemma/Mistral (~5-10% d'ecart) — suffisant
# pour une jauge visuelle. # pour une jauge visuelle.
tiktoken==0.8.* tiktoken==0.8.*
# Import de PDF de regles (-> GameSystem). Extraction de la couche texte
# (born-digital) avec repli OCR par page pour les scans.
# - pymupdf : extraction texte + rasterisation des pages (pas besoin de poppler)
# - pytesseract + Pillow : OCR Tesseract sur les pages sans couche texte
# (le binaire tesseract-ocr est installe dans le Dockerfile, langues fra+eng)
pymupdf==1.24.*
pytesseract==0.3.*
Pillow==11.*

View File

@@ -0,0 +1,107 @@
"""CLI de test pour l'import de règles PDF — boucle de feedback rapide.
But : tester l'extraction + la structuration en sections sur un VRAI PDF
(ex: livre Nimble), SANS passer par HTTP, le Core ou l'UI.
Usage (depuis le dossier brain/, venv activé) :
python scripts/test_import_rules.py "chemin/vers/regles.pdf"
Le provider LLM utilisé est celui configuré (.env + overrides de l'écran
Paramètres) — donc 1min.ai si tu l'as sélectionné. Le script :
1. extrait le texte (et dit, page par page, si l'OCR s'est déclenché),
2. découpe + structure via le LLM,
3. affiche un résumé des sections,
4. écrit le markdown complet dans "<pdf>.rules.md" à côté du PDF.
"""
from __future__ import annotations
import asyncio
import logging
import sys
from pathlib import Path
# Permet `import app...` quel que soit le cwd : on ajoute la racine brain/.
_BRAIN_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(_BRAIN_ROOT))
from app.application.import_rules import ImportRulesUseCase # noqa: E402
from app.core.config import get_settings # noqa: E402
from app.domain.ports import LLMProvider, LLMProviderError, PdfExtractionError # noqa: E402
from app.infrastructure.ollama_adapter import OllamaLLMProvider # noqa: E402
from app.infrastructure.onemin_adapter import OneMinAiLLMProvider # noqa: E402
from app.infrastructure.pdf_extractor import PyMuPdfTextExtractor # noqa: E402
def _build_provider() -> LLMProvider:
"""Réplique la factory de main.py (choix provider selon les settings)."""
settings = get_settings()
print(f"→ Provider LLM : {settings.llm_provider} "
f"(modèle : {settings.onemin_model if settings.llm_provider == 'onemin' else settings.llm_model})")
if settings.llm_provider == "onemin":
return OneMinAiLLMProvider(settings)
return OllamaLLMProvider(settings)
async def _run(pdf_path: Path) -> None:
pdf_bytes = pdf_path.read_bytes()
print(f"→ PDF chargé : {pdf_path.name} ({len(pdf_bytes) // 1024} Ko)\n")
extractor = PyMuPdfTextExtractor()
# 1. Extraction seule d'abord, pour le diagnostic page/OCR avant tout LLM.
try:
doc = extractor.extract(pdf_bytes)
except PdfExtractionError as exc:
print(f"✗ Extraction impossible : {exc}")
return
print(f"=== Extraction : {doc.page_count} page(s), "
f"{doc.ocr_page_count} via OCR, "
f"{len(doc.full_text)} caractères ===")
if doc.ocr_page_count == 0:
print(" → PDF born-digital détecté (couche texte présente, OCR non nécessaire).")
else:
print(f"{doc.ocr_page_count} page(s) sans couche texte : OCR déclenché.")
if not doc.full_text.strip():
print("\n✗ Aucun texte extrait. Si le PDF est un scan, vérifie que Tesseract "
"est installé (sinon l'OCR est désactivé).")
return
# 2. Structuration via le LLM (réutilise l'extraction déjà faite indirectement :
# le use case ré-extrait, coût négligeable vs l'appel LLM).
print("\n=== Structuration via LLM (peut prendre un moment selon le nombre de morceaux)… ===")
use_case = ImportRulesUseCase(llm=_build_provider(), extractor=extractor)
try:
result = await use_case.execute(pdf_bytes)
except LLMProviderError as exc:
print(f"✗ Échec LLM : {exc}")
return
if not result.sections:
print("\n✗ Aucune section proposée (le modèle n'a rien renvoyé d'exploitable).")
return
print(f"\n=== {len(result.sections)} section(s) proposée(s) ===")
for title, content in result.sections.items():
preview = content.strip().replace("\n", " ")[:90]
print(f"{title} ({len(content)} car.) — {preview}")
out_path = pdf_path.with_suffix(".rules.md")
out_path.write_text(result.to_markdown(), encoding="utf-8")
print(f"\n✓ Markdown complet écrit dans : {out_path}")
def main() -> None:
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s%(message)s")
if len(sys.argv) != 2:
print("Usage : python scripts/test_import_rules.py \"chemin/vers/regles.pdf\"")
sys.exit(1)
pdf_path = Path(sys.argv[1]).expanduser()
if not pdf_path.is_file():
print(f"✗ Fichier introuvable : {pdf_path}")
sys.exit(1)
asyncio.run(_run(pdf_path))
if __name__ == "__main__":
main()

View File

@@ -8,13 +8,13 @@
<parent> <parent>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId> <artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.0</version> <version>3.2.12</version>
<relativePath/> <relativePath/>
</parent> </parent>
<groupId>com.loremind</groupId> <groupId>com.loremind</groupId>
<artifactId>loremind-core</artifactId> <artifactId>loremind-core</artifactId>
<version>0.6.1</version> <version>0.11.2-beta</version>
<name>LoreMind Core</name> <name>LoreMind Core</name>
<description>Backend Core - Architecture Hexagonale</description> <description>Backend Core - Architecture Hexagonale</description>
@@ -83,6 +83,28 @@
<artifactId>minio</artifactId> <artifactId>minio</artifactId>
<version>8.5.11</version> <version>8.5.11</version>
</dependency> </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>
<!-- Google Tink : runtime requis par com.nimbusds.jose.crypto.Ed25519Verifier
(depuis Nimbus 9.x, la verification EdDSA delegue a Tink.subtle.Ed25519Verify).
Tink n'est PAS une dependance transitive de nimbus-jose-jwt → il faut
l'ajouter explicitement, sinon NoClassDefFoundError au premier verify(). -->
<dependency>
<groupId>com.google.crypto.tink</groupId>
<artifactId>tink</artifactId>
<version>1.14.1</version>
</dependency>
</dependencies> </dependencies>
<build> <build>
@@ -98,6 +120,16 @@
</exclude> </exclude>
</excludes> </excludes>
</configuration> </configuration>
<executions>
<!-- Genere META-INF/build-info.properties (project.version)
consomme par Spring BuildProperties pour exposer la
version courante a l'application (UpdateCheckService). -->
<execution>
<goals>
<goal>build-info</goal>
</goals>
</execution>
</executions>
</plugin> </plugin>
<!-- JaCoCo : rapport de couverture des tests unitaires. <!-- JaCoCo : rapport de couverture des tests unitaires.

View File

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

View File

@@ -36,15 +36,29 @@ public class ArcService {
public record DeletionImpact(int chapters, int scenes) {} public record DeletionImpact(int chapters, int scenes) {}
public Arc createArc(String name, String description, String campaignId, int order) { public Arc createArc(String name, String description, String campaignId, int order) {
return createArc(name, description, campaignId, order, null);
}
public Arc createArc(String name, String description, String campaignId, int order, String icon) {
Arc arc = Arc.builder() Arc arc = Arc.builder()
.name(name) .name(name)
.description(description) .description(description)
.campaignId(campaignId) .campaignId(campaignId)
.order(order) .order(order)
.icon(icon)
.build(); .build();
return arcRepository.save(arc); return arcRepository.save(arc);
} }
/**
* Création à partir d'un Arc complet (utilisé par le controller pour faire passer
* les nouveaux champs comme type sans démultiplier les paramètres).
*/
public Arc createArc(Arc input) {
input.setId(null);
return arcRepository.save(input);
}
public Optional<Arc> getArcById(String id) { public Optional<Arc> getArcById(String id) {
return arcRepository.findById(id); return arcRepository.findById(id);
} }

View File

@@ -0,0 +1,46 @@
package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Campaign;
import com.loremind.domain.campaigncontext.ports.CampaignPdfAdvisor;
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import org.springframework.stereotype.Service;
import java.util.function.Consumer;
/**
* Service applicatif : conseils d'adaptation d'un PDF à une campagne existante.
*
* <p>Assemble un « brief » de la campagne (structure + PNJ + univers/lore) via
* {@link CampaignBriefBuilder} et délègue la génération streamée au Brain via
* {@link CampaignPdfAdvisor}. Ne persiste rien : la sortie est du conseil libre.</p>
*/
@Service
public class CampaignAdaptService {
private final CampaignRepository campaignRepository;
private final CampaignBriefBuilder briefBuilder;
private final CampaignPdfAdvisor advisor;
public CampaignAdaptService(
CampaignRepository campaignRepository,
CampaignBriefBuilder briefBuilder,
CampaignPdfAdvisor advisor) {
this.campaignRepository = campaignRepository;
this.briefBuilder = briefBuilder;
this.advisor = advisor;
}
public void adviseStreaming(
String campaignId,
byte[] pdfBytes,
String filename,
String messagesJson,
Consumer<String> onToken,
Runnable onComplete,
Consumer<Throwable> onError) {
Campaign campaign = campaignRepository.findById(campaignId)
.orElseThrow(() -> new IllegalArgumentException("Campagne introuvable : " + campaignId));
advisor.adviseStreaming(
pdfBytes, filename, briefBuilder.build(campaign), messagesJson, onToken, onComplete, onError);
}
}

View File

@@ -0,0 +1,93 @@
package com.loremind.application.campaigncontext;
import com.loremind.application.generationcontext.CampaignStructuralContextBuilder;
import com.loremind.application.generationcontext.LoreStructuralContextBuilder;
import com.loremind.domain.campaigncontext.Campaign;
import com.loremind.domain.generationcontext.CampaignStructuralContext;
import com.loremind.domain.generationcontext.CampaignStructuralContext.ArcSummary;
import com.loremind.domain.generationcontext.CampaignStructuralContext.ChapterSummary;
import com.loremind.domain.generationcontext.CampaignStructuralContext.NpcSummary;
import com.loremind.domain.generationcontext.CampaignStructuralContext.SceneSummary;
import com.loremind.domain.generationcontext.LoreStructuralContext;
import com.loremind.domain.generationcontext.LoreStructuralContext.PageSummary;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Map;
/**
* Construit un résumé markdown d'une campagne (structure arcs→chapitres→scènes +
* PNJ + univers/lore). Partagé par les fonctions IA qui doivent « voir » la
* campagne : conseils d'adaptation PDF (CampaignAdaptService) et ateliers RAG
* (NotebookService). Centralisé ici pour une seule source de vérité.
*/
@Service
public class CampaignBriefBuilder {
private final CampaignStructuralContextBuilder campaignContextBuilder;
private final LoreStructuralContextBuilder loreContextBuilder;
public CampaignBriefBuilder(
CampaignStructuralContextBuilder campaignContextBuilder,
LoreStructuralContextBuilder loreContextBuilder) {
this.campaignContextBuilder = campaignContextBuilder;
this.loreContextBuilder = loreContextBuilder;
}
public String build(Campaign campaign) {
CampaignStructuralContext cc = campaignContextBuilder.build(campaign.getId());
StringBuilder sb = new StringBuilder();
sb.append("# Campagne : ").append(cc.campaignName()).append("\n");
if (notBlank(cc.campaignDescription())) sb.append(cc.campaignDescription()).append("\n");
sb.append("\n## Structure (arcs → chapitres → scènes)\n");
if (cc.arcs().isEmpty()) {
sb.append("_(aucun arc pour le moment)_\n");
}
for (ArcSummary arc : cc.arcs()) {
sb.append("### Arc : ").append(arc.name());
if (notBlank(arc.description())) sb.append("").append(arc.description());
sb.append("\n");
for (ChapterSummary ch : arc.chapters()) {
sb.append("- Chapitre : ").append(ch.name());
if (notBlank(ch.description())) sb.append("").append(ch.description());
sb.append("\n");
for (SceneSummary sc : ch.scenes()) {
sb.append(" - Scène : ").append(sc.name());
if (notBlank(sc.description())) sb.append("").append(sc.description());
sb.append("\n");
}
}
}
if (!cc.npcs().isEmpty()) {
sb.append("\n## PNJ existants\n");
for (NpcSummary n : cc.npcs()) {
sb.append("- ").append(n.name());
if (notBlank(n.snippet())) sb.append(" : ").append(n.snippet());
sb.append("\n");
}
}
if (campaign.isLinkedToLore()) {
loreContextBuilder.buildOptional(campaign.getLoreId()).ifPresent(lore -> appendLore(sb, lore));
}
return sb.toString();
}
private void appendLore(StringBuilder sb, LoreStructuralContext lore) {
sb.append("\n## Univers (Lore) : ").append(lore.loreName()).append("\n");
if (notBlank(lore.loreDescription())) sb.append(lore.loreDescription()).append("\n");
for (Map.Entry<String, List<PageSummary>> entry : lore.folders().entrySet()) {
sb.append("### ").append(entry.getKey()).append("\n");
for (PageSummary page : entry.getValue()) {
sb.append("- ").append(page.title()).append("\n");
}
}
}
private static boolean notBlank(String s) {
return s != null && !s.isBlank();
}
}

View File

@@ -0,0 +1,181 @@
package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Arc;
import com.loremind.domain.campaigncontext.ArcType;
import com.loremind.domain.campaigncontext.CampaignImportProgress;
import com.loremind.domain.campaigncontext.CampaignImportProposal;
import com.loremind.domain.campaigncontext.CampaignImportProposal.ArcProposal;
import com.loremind.domain.campaigncontext.CampaignImportProposal.ChapterProposal;
import com.loremind.domain.campaigncontext.CampaignImportProposal.RoomProposal;
import com.loremind.domain.campaigncontext.CampaignImportProposal.SceneProposal;
import com.loremind.domain.campaigncontext.Chapter;
import com.loremind.domain.campaigncontext.Room;
import com.loremind.domain.campaigncontext.Scene;
import com.loremind.domain.campaigncontext.ports.CampaignPdfImporter;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import java.util.function.Consumer;
/**
* Service applicatif pour l'import d'un PDF de campagne.
*
* <p>Deux temps, conformes au principe « revue avant écriture » :
* 1. {@link #importStructureStreaming} génère une PROPOSITION d'arbre (rien
* n'est persisté), streamée pour l'avancement.
* 2. {@link #applyStructure} crée réellement les arcs/chapitres/scènes une fois
* l'arbre révisé/édité par l'utilisateur.</p>
*/
@Service
public class CampaignImportService {
private final CampaignPdfImporter campaignPdfImporter;
private final CampaignService campaignService;
private final ArcService arcService;
private final ChapterService chapterService;
private final SceneService sceneService;
public CampaignImportService(
CampaignPdfImporter campaignPdfImporter,
CampaignService campaignService,
ArcService arcService,
ChapterService chapterService,
SceneService sceneService) {
this.campaignPdfImporter = campaignPdfImporter;
this.campaignService = campaignService;
this.arcService = arcService;
this.chapterService = chapterService;
this.sceneService = sceneService;
}
/** Résumé de ce qui a été créé par {@link #applyStructure}. */
public record ApplyResult(int arcsCreated, int chaptersCreated, int scenesCreated) {}
/** Génère la proposition d'arbre (streamée). Ne persiste rien. */
public void importStructureStreaming(
byte[] pdfBytes,
String filename,
Consumer<CampaignImportProgress> onProgress,
Consumer<CampaignImportProposal> onDone,
Consumer<Throwable> onError) {
campaignPdfImporter.importCampaignStreaming(pdfBytes, filename, onProgress, onDone, onError);
}
/**
* Crée les arcs/chapitres/scènes de l'arbre révisé dans la campagne. Les
* arcs sont ajoutés APRÈS les arcs existants (ordre continué). Tout est créé
* dans une seule transaction (rollback si une étape échoue).
*/
@Transactional
public ApplyResult applyStructure(String campaignId, CampaignImportProposal proposal) {
if (!campaignService.campaignExists(campaignId)) {
throw new IllegalArgumentException("Campagne introuvable : " + campaignId);
}
int arcsCreated = 0, chaptersCreated = 0, scenesCreated = 0;
// Les nouveaux nœuds sont ordonnés APRÈS les frères existants (déjà comptés
// via leur existingId dans l'arbre fusionné venu de la revue).
int arcOrder = countExisting(proposal.arcs(), ArcProposal::existingId);
for (ArcProposal arcP : proposal.arcs()) {
if (isBlank(arcP.name())) continue;
String arcId;
if (!isBlank(arcP.existingId())) {
arcId = arcP.existingId(); // arc déjà présent → on s'y rattache
} else {
arcOrder++;
Arc arc = arcService.createArc(Arc.builder()
.name(arcP.name().trim())
.description(nullIfBlank(arcP.description()))
.campaignId(campaignId)
.order(arcOrder)
.type(parseArcType(arcP.type()))
.build());
arcId = arc.getId();
arcsCreated++;
}
int chapterOrder = countExisting(arcP.chapters(), ChapterProposal::existingId);
for (ChapterProposal chapP : safe(arcP.chapters())) {
if (isBlank(chapP.name())) continue;
String chapId;
if (!isBlank(chapP.existingId())) {
chapId = chapP.existingId();
} else {
chapterOrder++;
Chapter chapter = chapterService.createChapter(
chapP.name().trim(), nullIfBlank(chapP.description()), arcId, chapterOrder);
chapId = chapter.getId();
chaptersCreated++;
}
int sceneOrder = countExisting(chapP.scenes(), SceneProposal::existingId);
for (SceneProposal sceneP : safe(chapP.scenes())) {
if (isBlank(sceneP.name())) continue;
if (!isBlank(sceneP.existingId())) continue; // scène déjà présente
sceneOrder++;
sceneService.createScene(Scene.builder()
.name(sceneP.name().trim())
.description(nullIfBlank(sceneP.description()))
.playerNarration(nullIfBlank(sceneP.playerNarration()))
.gmSecretNotes(nullIfBlank(sceneP.gmNotes()))
.chapterId(chapId)
.order(sceneOrder)
.rooms(toRooms(sceneP.rooms()))
.build());
scenesCreated++;
}
}
}
return new ApplyResult(arcsCreated, chaptersCreated, scenesCreated);
}
/** Compte les nœuds déjà présents (existingId non vide) d'une liste. */
private static <T> int countExisting(List<T> list, java.util.function.Function<T, String> idOf) {
if (list == null) return 0;
int n = 0;
for (T t : list) {
if (!isBlank(idOf.apply(t))) n++;
}
return n;
}
/** "HUB" (insensible à la casse) → {@link ArcType#HUB} ; tout le reste → LINEAR. */
private static ArcType parseArcType(String type) {
return "HUB".equalsIgnoreCase(type == null ? "" : type.trim()) ? ArcType.HUB : ArcType.LINEAR;
}
/** Convertit les pièces proposées en {@link Room} (ID généré, ordre = index). */
private static List<Room> toRooms(List<RoomProposal> proposals) {
List<Room> rooms = new ArrayList<>();
if (proposals == null) return rooms;
int order = 0;
for (RoomProposal r : proposals) {
if (isBlank(r.name())) continue;
rooms.add(Room.builder()
.id(UUID.randomUUID().toString())
.name(r.name().trim())
.description(nullIfBlank(r.description()))
.enemies(nullIfBlank(r.enemies()))
.loot(nullIfBlank(r.loot()))
.order(order++)
.build());
}
return rooms;
}
private static <T> java.util.List<T> safe(java.util.List<T> list) {
return list == null ? java.util.List.of() : list;
}
private static boolean isBlank(String s) {
return s == null || s.isBlank();
}
private static String nullIfBlank(String s) {
return isBlank(s) ? null : s.trim();
}
}

View File

@@ -0,0 +1,49 @@
package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Arc;
import com.loremind.domain.campaigncontext.Chapter;
import com.loremind.domain.campaigncontext.Prerequisite;
import com.loremind.domain.campaigncontext.ports.ArcRepository;
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.TreeSet;
/**
* Service applicatif : énumère les noms de faits ({@link Prerequisite.FlagSet})
* référencés par les chapitres d'une Campagne.
*
* <p>Modèle "déclaration implicite" : il n'existe pas de table de faits déclarés
* globalement. Un fait existe dès qu'au moins une quête le référence dans ses
* prérequis. Ce service expose la liste dédupliquée pour les UIs (toggle dans
* la Partie, autocomplete dans l'éditeur de prérequis).</p>
*/
@Service
public class CampaignReferencedFlagsService {
private final ArcRepository arcRepository;
private final ChapterRepository chapterRepository;
public CampaignReferencedFlagsService(ArcRepository arcRepository,
ChapterRepository chapterRepository) {
this.arcRepository = arcRepository;
this.chapterRepository = chapterRepository;
}
/** Retourne la liste triée alphabétiquement des noms de faits référencés. */
public List<String> listForCampaign(String campaignId) {
TreeSet<String> unique = new TreeSet<>();
for (Arc arc : arcRepository.findByCampaignId(campaignId)) {
for (Chapter chapter : chapterRepository.findByArcId(arc.getId())) {
if (chapter.getPrerequisites() == null) continue;
for (Prerequisite p : chapter.getPrerequisites()) {
if (p instanceof Prerequisite.FlagSet f && f.flagName() != null && !f.flagName().isBlank()) {
unique.add(f.flagName());
}
}
}
}
return List.copyOf(unique);
}
}

View File

@@ -1,13 +1,15 @@
package com.loremind.application.campaigncontext; package com.loremind.application.campaigncontext;
import com.loremind.application.playcontext.PlaythroughService;
import com.loremind.domain.campaigncontext.Arc; import com.loremind.domain.campaigncontext.Arc;
import com.loremind.domain.campaigncontext.Campaign; import com.loremind.domain.campaigncontext.Campaign;
import com.loremind.domain.campaigncontext.Chapter; import com.loremind.domain.campaigncontext.Chapter;
import com.loremind.domain.campaigncontext.ports.ArcRepository; import com.loremind.domain.campaigncontext.ports.ArcRepository;
import com.loremind.domain.campaigncontext.ports.CampaignRepository; import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.campaigncontext.ports.ChapterRepository; import com.loremind.domain.campaigncontext.ports.ChapterRepository;
import com.loremind.domain.campaigncontext.ports.CharacterRepository;
import com.loremind.domain.campaigncontext.ports.SceneRepository; import com.loremind.domain.campaigncontext.ports.SceneRepository;
import com.loremind.domain.playcontext.Playthrough;
import com.loremind.domain.playcontext.ports.PlaythroughRepository;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@@ -15,9 +17,11 @@ import java.util.List;
import java.util.Optional; import java.util.Optional;
/** /**
* Service d'application pour le contexte Campaign. * Service d'application pour le contexte Campaign (scénario).
* Orchestre la logique métier en utilisant le Port CampaignRepository. *
* Fait partie de la couche Application de l'Architecture Hexagonale. * <p>Depuis Playthrough : les PJ et les sessions ne sont plus rattachés directement
* à la Campagne ; ils dépendent d'un Playthrough (Partie). La cascade de suppression
* d'une campagne englobe donc ses Playthroughs (qui à leur tour cascadent).</p>
*/ */
@Service @Service
public class CampaignService { public class CampaignService {
@@ -26,35 +30,27 @@ public class CampaignService {
private final ArcRepository arcRepository; private final ArcRepository arcRepository;
private final ChapterRepository chapterRepository; private final ChapterRepository chapterRepository;
private final SceneRepository sceneRepository; private final SceneRepository sceneRepository;
private final CharacterRepository characterRepository; private final PlaythroughRepository playthroughRepository;
private final PlaythroughService playthroughService;
public CampaignService( public CampaignService(
CampaignRepository campaignRepository, CampaignRepository campaignRepository,
ArcRepository arcRepository, ArcRepository arcRepository,
ChapterRepository chapterRepository, ChapterRepository chapterRepository,
SceneRepository sceneRepository, SceneRepository sceneRepository,
CharacterRepository characterRepository) { PlaythroughRepository playthroughRepository,
PlaythroughService playthroughService) {
this.campaignRepository = campaignRepository; this.campaignRepository = campaignRepository;
this.arcRepository = arcRepository; this.arcRepository = arcRepository;
this.chapterRepository = chapterRepository; this.chapterRepository = chapterRepository;
this.sceneRepository = sceneRepository; this.sceneRepository = sceneRepository;
this.characterRepository = characterRepository; this.playthroughRepository = playthroughRepository;
this.playthroughService = playthroughService;
} }
/**
* Parameter Object pour la création / mise à jour d'une Campaign.
* Évite une signature à rallonge et rend les évolutions futures (theme,
* coverImageUrl, etc.) sans casser les appelants.
*
* <p>{@code loreId} est nullable : une campagne peut exister sans univers associé.</p>
*/
public record CampaignData(String name, String description, String loreId, String gameSystemId) {} public record CampaignData(String name, String description, String loreId, String gameSystemId) {}
/** public record DeletionImpact(int arcs, int chapters, int scenes, int playthroughs) {}
* Compte des entités qui seront supprimées en cascade si la campagne est effacée.
* Utilisé par l'UI pour afficher un récapitulatif dans le dialogue de confirmation.
*/
public record DeletionImpact(int arcs, int chapters, int scenes, int characters) {}
public Campaign createCampaign(CampaignData data) { public Campaign createCampaign(CampaignData data) {
Campaign campaign = Campaign.builder() Campaign campaign = Campaign.builder()
@@ -64,7 +60,12 @@ public class CampaignService {
.gameSystemId(normalizeId(data.gameSystemId())) .gameSystemId(normalizeId(data.gameSystemId()))
.arcsCount(0) .arcsCount(0)
.build(); .build();
return campaignRepository.save(campaign); Campaign saved = campaignRepository.save(campaign);
// Une campagne sans Partie n'a pas de sens jouable : on crée d'office
// une "Partie principale" pour que l'utilisateur puisse jouer immédiatement.
playthroughService.create(saved.getId(), "Partie principale", null);
return saved;
} }
public Optional<Campaign> getCampaignById(String id) { public Optional<Campaign> getCampaignById(String id) {
@@ -89,19 +90,10 @@ public class CampaignService {
return campaignRepository.save(campaign); return campaignRepository.save(campaign);
} }
/**
* 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 normalizeId(String id) { private String normalizeId(String id) {
return (id == null || id.isBlank()) ? null : id; return (id == null || id.isBlank()) ? null : id;
} }
/**
* Calcule l'impact d'une suppression en cascade : nombre d'arcs, chapitres,
* scènes et personnages qui disparaîtront avec la campagne. Utilisé par l'UI
* pour afficher "X arcs, Y chapitres, Z scènes seront supprimés".
*/
public DeletionImpact getDeletionImpact(String id) { public DeletionImpact getDeletionImpact(String id) {
List<Arc> arcs = arcRepository.findByCampaignId(id); List<Arc> arcs = arcRepository.findByCampaignId(id);
int chapterTotal = 0; int chapterTotal = 0;
@@ -113,22 +105,21 @@ public class CampaignService {
sceneTotal += sceneRepository.findByChapterId(chapter.getId()).size(); sceneTotal += sceneRepository.findByChapterId(chapter.getId()).size();
} }
} }
int characterTotal = characterRepository.findByCampaignId(id).size(); int playthroughTotal = playthroughRepository.findByCampaignId(id).size();
return new DeletionImpact(arcs.size(), chapterTotal, sceneTotal, characterTotal); return new DeletionImpact(arcs.size(), chapterTotal, sceneTotal, playthroughTotal);
} }
/**
* 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 @Transactional
public void deleteCampaign(String id) { public void deleteCampaign(String id) {
List<Arc> arcs = arcRepository.findByCampaignId(id); // 1. Cascade des Playthroughs (qui cascadent eux-mêmes sur PJ/sessions/valeurs flags/progressions).
for (Arc arc : arcs) { for (Playthrough p : playthroughRepository.findByCampaignId(id)) {
List<Chapter> chapters = chapterRepository.findByArcId(arc.getId()); playthroughService.delete(p.getId());
for (Chapter chapter : chapters) { }
// 2. Cascade du scénario : arcs → chapitres → scènes
// (Pas de déclarations globales de faits : ils existent implicitement via les
// prérequis FLAG_SET des chapitres, qui partent avec les chapitres.)
for (Arc arc : arcRepository.findByCampaignId(id)) {
for (Chapter chapter : chapterRepository.findByArcId(arc.getId())) {
for (var scene : sceneRepository.findByChapterId(chapter.getId())) { for (var scene : sceneRepository.findByChapterId(chapter.getId())) {
sceneRepository.deleteById(scene.getId()); sceneRepository.deleteById(scene.getId());
} }
@@ -136,9 +127,6 @@ public class CampaignService {
} }
arcRepository.deleteById(arc.getId()); arcRepository.deleteById(arc.getId());
} }
for (var character : characterRepository.findByCampaignId(id)) {
characterRepository.deleteById(character.getId());
}
campaignRepository.deleteById(id); campaignRepository.deleteById(id);
} }

View File

@@ -30,15 +30,30 @@ public class ChapterService {
public record DeletionImpact(int scenes) {} public record DeletionImpact(int scenes) {}
public Chapter createChapter(String name, String description, String arcId, int order) { public Chapter createChapter(String name, String description, String arcId, int order) {
return createChapter(name, description, arcId, order, null);
}
public Chapter createChapter(String name, String description, String arcId, int order, String icon) {
Chapter chapter = Chapter.builder() Chapter chapter = Chapter.builder()
.name(name) .name(name)
.description(description) .description(description)
.arcId(arcId) .arcId(arcId)
.order(order) .order(order)
.icon(icon)
.build(); .build();
return chapterRepository.save(chapter); return chapterRepository.save(chapter);
} }
/**
* Création à partir d'un Chapter complet (utilisé par le controller pour faire passer
* les nouveaux champs comme progressionStatus / prerequisites sans démultiplier les
* paramètres). L'id est forcé à null pour laisser la DB le générer.
*/
public Chapter createChapter(Chapter input) {
input.setId(null);
return chapterRepository.save(input);
}
public Optional<Chapter> getChapterById(String id) { public Optional<Chapter> getChapterById(String id) {
return chapterRepository.findById(id); return chapterRepository.findById(id);
} }

View File

@@ -0,0 +1,104 @@
package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Chapter;
import com.loremind.domain.campaigncontext.PrerequisiteEvaluator;
import com.loremind.domain.campaigncontext.ProgressionStatus;
import com.loremind.domain.campaigncontext.QuestStatus;
import com.loremind.domain.playcontext.QuestProgression;
import com.loremind.domain.playcontext.ports.PlaythroughFlagRepository;
import com.loremind.domain.playcontext.ports.PlaythroughRepository;
import com.loremind.domain.playcontext.ports.QuestProgressionRepository;
import com.loremind.domain.playcontext.ports.SessionRepository;
import com.loremind.infrastructure.web.dto.campaigncontext.ChapterDTO;
import org.springframework.stereotype.Service;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
/**
* Service applicatif : enrichit des ChapterDTO avec leur {@link QuestStatus} effectif,
* relatif à un Playthrough donné.
*
* <p>Depuis l'introduction de Playthrough : la progression et les flags vivent au niveau
* de la Partie, plus de la Campagne. L'enrichissement nécessite donc un playthroughId.</p>
*/
@Service
public class ChapterStatusEnricher {
private final PlaythroughRepository playthroughRepository;
private final QuestProgressionRepository progressionRepository;
private final PlaythroughFlagRepository flagRepository;
private final SessionRepository sessionRepository;
private final PrerequisiteEvaluator evaluator = new PrerequisiteEvaluator();
public ChapterStatusEnricher(PlaythroughRepository playthroughRepository,
QuestProgressionRepository progressionRepository,
PlaythroughFlagRepository flagRepository,
SessionRepository sessionRepository) {
this.playthroughRepository = playthroughRepository;
this.progressionRepository = progressionRepository;
this.flagRepository = flagRepository;
this.sessionRepository = sessionRepository;
}
/** Contexte d'évaluation + map chapterId -> ProgressionStatus pour ce Playthrough. */
public record PlaythroughEvalSnapshot(
PrerequisiteEvaluator.EvaluationContext ctx,
Map<String, ProgressionStatus> progressionByChapterId
) {}
/** Construit le snapshot d'évaluation pour un Playthrough. */
public PlaythroughEvalSnapshot buildSnapshot(String playthroughId) {
if (playthroughId == null || playthroughRepository.findById(playthroughId).isEmpty()) {
return new PlaythroughEvalSnapshot(
new PrerequisiteEvaluator.EvaluationContext(Collections.emptySet(), 0, Collections.emptyMap()),
Collections.emptyMap()
);
}
Map<String, Boolean> flags = flagRepository.findByPlaythroughId(playthroughId);
Set<String> completedQuestIds = progressionRepository.findCompletedChapterIdsByPlaythroughId(playthroughId);
int sessionCount = sessionRepository.findByPlaythroughId(playthroughId).size();
Map<String, ProgressionStatus> progressionMap = new HashMap<>();
for (QuestProgression qp : progressionRepository.findByPlaythroughId(playthroughId)) {
progressionMap.put(qp.getChapterId(), qp.getStatus());
}
return new PlaythroughEvalSnapshot(
new PrerequisiteEvaluator.EvaluationContext(completedQuestIds, sessionCount, flags),
progressionMap
);
}
/** Calcule le statut effectif d'un seul chapitre relatif à un Playthrough. */
public QuestStatus computeFor(Chapter chapter, String playthroughId) {
PlaythroughEvalSnapshot snap = buildSnapshot(playthroughId);
ProgressionStatus progression = snap.progressionByChapterId()
.getOrDefault(chapter.getId(), ProgressionStatus.NOT_STARTED);
return evaluator.computeStatus(progression, chapter.getPrerequisites(), snap.ctx());
}
/**
* Injecte le {@code effectiveStatus} et le {@code progressionStatus} dans une liste de DTOs.
* Un seul build du snapshot pour toute la liste (optimal pour les vues qui listent un arc).
*/
public void enrich(List<ChapterDTO> dtos, List<Chapter> domain, String playthroughId) {
if (dtos == null || dtos.isEmpty()) return;
PlaythroughEvalSnapshot snap = buildSnapshot(playthroughId);
Map<String, Chapter> byId = domain.stream()
.collect(Collectors.toMap(Chapter::getId, c -> c));
for (ChapterDTO dto : dtos) {
Chapter c = byId.get(dto.getId());
if (c == null) continue;
ProgressionStatus progression = snap.progressionByChapterId()
.getOrDefault(c.getId(), ProgressionStatus.NOT_STARTED);
QuestStatus status = evaluator.computeStatus(progression, c.getPrerequisites(), snap.ctx());
dto.setProgressionStatus(progression.name());
dto.setEffectiveStatus(status.name());
}
}
}

View File

@@ -4,11 +4,14 @@ import com.loremind.domain.campaigncontext.Character;
import com.loremind.domain.campaigncontext.ports.CharacterRepository; import com.loremind.domain.campaigncontext.ports.CharacterRepository;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Optional; import java.util.Optional;
/** /**
* Service d'application pour les fiches de personnages (PJ). * Service d'application pour les fiches de personnages (PJ).
* Les PJ appartiennent désormais à un Playthrough (Partie), pas à la Campagne.
*/ */
@Service @Service
public class CharacterService { public class CharacterService {
@@ -19,20 +22,29 @@ public class CharacterService {
this.characterRepository = characterRepository; this.characterRepository = characterRepository;
} }
/** public record CharacterData(
* Parameter Object pour la création / mise à jour d'un Character. String name,
* `order` est fourni par le controller ; si absent, le service le calcule. String portraitImageId,
*/ String headerImageId,
public record CharacterData(String name, String markdownContent, String campaignId, Integer order) {} Map<String, String> values,
Map<String, List<String>> imageValues,
Map<String, Map<String, String>> keyValueValues,
String playthroughId,
Integer order
) {}
public Character createCharacter(CharacterData data) { public Character createCharacter(CharacterData data) {
int order = data.order() != null int order = data.order() != null
? data.order() ? data.order()
: nextOrderFor(data.campaignId()); : nextOrderFor(data.playthroughId());
Character character = Character.builder() Character character = Character.builder()
.name(data.name()) .name(data.name())
.markdownContent(data.markdownContent()) .portraitImageId(data.portraitImageId())
.campaignId(data.campaignId()) .headerImageId(data.headerImageId())
.values(data.values() != null ? new HashMap<>(data.values()) : new HashMap<>())
.imageValues(data.imageValues() != null ? new HashMap<>(data.imageValues()) : new HashMap<>())
.keyValueValues(data.keyValueValues() != null ? new HashMap<>(data.keyValueValues()) : new HashMap<>())
.playthroughId(data.playthroughId())
.order(order) .order(order)
.build(); .build();
return characterRepository.save(character); return characterRepository.save(character);
@@ -42,19 +54,23 @@ public class CharacterService {
return characterRepository.findById(id); return characterRepository.findById(id);
} }
public List<Character> getCharactersByCampaignId(String campaignId) { public List<Character> getCharactersByPlaythroughId(String playthroughId) {
return characterRepository.findByCampaignId(campaignId); return characterRepository.findByPlaythroughId(playthroughId);
} }
public Character updateCharacter(String id, CharacterData data) { public Character updateCharacter(String id, CharacterData data) {
Character existing = characterRepository.findById(id) Character existing = characterRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Character non trouvé avec l'ID: " + id)); .orElseThrow(() -> new IllegalArgumentException("Character non trouvé avec l'ID: " + id));
existing.setName(data.name()); existing.setName(data.name());
existing.setMarkdownContent(data.markdownContent()); existing.setPortraitImageId(data.portraitImageId());
existing.setHeaderImageId(data.headerImageId());
existing.setValues(data.values() != null ? new HashMap<>(data.values()) : new HashMap<>());
existing.setImageValues(data.imageValues() != null ? new HashMap<>(data.imageValues()) : new HashMap<>());
existing.setKeyValueValues(data.keyValueValues() != null ? new HashMap<>(data.keyValueValues()) : new HashMap<>());
if (data.order() != null) { if (data.order() != null) {
existing.setOrder(data.order()); existing.setOrder(data.order());
} }
// campaignId n'est pas modifiable après création (cross-campagne move hors scope MVP). // playthroughId immuable après création.
return characterRepository.save(existing); return characterRepository.save(existing);
} }
@@ -62,9 +78,8 @@ public class CharacterService {
characterRepository.deleteById(id); characterRepository.deleteById(id);
} }
/** Renvoie la prochaine position libre — append en fin de liste. */ private int nextOrderFor(String playthroughId) {
private int nextOrderFor(String campaignId) { return characterRepository.findByPlaythroughId(playthroughId).stream()
return characterRepository.findByCampaignId(campaignId).stream()
.mapToInt(Character::getOrder) .mapToInt(Character::getOrder)
.max() .max()
.orElse(-1) + 1; .orElse(-1) + 1;

View File

@@ -0,0 +1,132 @@
package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Campaign;
import com.loremind.domain.campaigncontext.Notebook;
import com.loremind.domain.campaigncontext.NotebookMessage;
import com.loremind.domain.campaigncontext.NotebookSource;
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.campaigncontext.ports.NotebookIndexer;
import com.loremind.domain.campaigncontext.ports.NotebookRepository;
import org.springframework.stereotype.Service;
import java.util.List;
/**
* Service d'application des notebooks (atelier RAG) : CRUD, indexation des sources
* (déléguée au Brain), conversation persistée, et assemblage du contexte campagne.
*/
@Service
public class NotebookService {
private final NotebookRepository repository;
private final NotebookIndexer indexer;
private final CampaignRepository campaignRepository;
private final CampaignBriefBuilder briefBuilder;
public NotebookService(
NotebookRepository repository,
NotebookIndexer indexer,
CampaignRepository campaignRepository,
CampaignBriefBuilder briefBuilder) {
this.repository = repository;
this.indexer = indexer;
this.campaignRepository = campaignRepository;
this.briefBuilder = briefBuilder;
}
// --- Notebooks ---
public Notebook createNotebook(String campaignId, String name) {
String safeName = (name == null || name.isBlank()) ? "Nouvel atelier" : name.trim();
return repository.save(Notebook.builder().campaignId(campaignId).name(safeName).build());
}
public java.util.Optional<Notebook> getNotebook(String id) {
return repository.findById(id);
}
public List<Notebook> getNotebooksByCampaign(String campaignId) {
return repository.findByCampaignId(campaignId);
}
public Notebook renameNotebook(String id, String name) {
Notebook nb = repository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Notebook introuvable: " + id));
nb.setName((name == null || name.isBlank()) ? nb.getName() : name.trim());
return repository.save(nb);
}
public void deleteNotebook(String id) {
// Supprime les vecteurs de chaque source côté Brain (best-effort) avant la BDD.
for (NotebookSource s : repository.findSourcesByNotebookId(id)) {
indexer.delete(s.getId());
}
repository.deleteById(id);
}
// --- Sources ---
public List<NotebookSource> getSources(String notebookId) {
return repository.findSourcesByNotebookId(notebookId);
}
/**
* Ajoute une source : crée la ligne (INDEXING), lance l'indexation Brain, puis
* met à jour (READY + compteurs) ou (FAILED) en cas d'échec — et relaie l'erreur.
*/
public NotebookSource addSource(String notebookId, String filename, byte[] pdfBytes) {
if (!repository.existsById(notebookId)) {
throw new IllegalArgumentException("Notebook introuvable: " + notebookId);
}
NotebookSource source = repository.saveSource(NotebookSource.builder()
.notebookId(notebookId)
.filename(filename != null && !filename.isBlank() ? filename : "source.pdf")
.status("INDEXING")
.build());
try {
NotebookIndexer.IndexResult result = indexer.index(source.getId(), pdfBytes, filename);
source.setStatus("READY");
source.setChunkCount(result.chunks());
source.setPageCount(result.pageCount());
return repository.saveSource(source);
} catch (RuntimeException e) {
source.setStatus("FAILED");
repository.saveSource(source);
throw e;
}
}
public void deleteSource(String sourceId) {
repository.findSourceById(sourceId).ifPresent(s -> indexer.delete(s.getId()));
repository.deleteSourceById(sourceId);
}
public List<String> readySourceIds(String notebookId) {
return repository.findSourcesByNotebookId(notebookId).stream()
.filter(s -> "READY".equals(s.getStatus()))
.map(NotebookSource::getId)
.toList();
}
// --- Conversation ---
public List<NotebookMessage> getMessages(String notebookId) {
return repository.findMessagesByNotebookId(notebookId);
}
public NotebookMessage addMessage(String notebookId, String role, String content) {
return repository.saveMessage(NotebookMessage.builder()
.notebookId(notebookId).role(role).content(content).build());
}
// --- Contexte campagne (oriente l'IA) ---
/** Brief COMPLET de la campagne (structure arcs/chapitres/scènes + PNJ + lore) :
* l'IA « voit » la campagne, pas seulement son nom. */
public String buildContext(String campaignId) {
if (campaignId == null) return "";
Campaign campaign = campaignRepository.findById(campaignId).orElse(null);
if (campaign == null) return "";
return briefBuilder.build(campaign);
}
}

View File

@@ -0,0 +1,95 @@
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.HashMap;
import java.util.List;
import java.util.Map;
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;
}
public record NpcData(
String name,
String portraitImageId,
String headerImageId,
Map<String, String> values,
Map<String, List<String>> imageValues,
Map<String, Map<String, String>> keyValueValues,
String campaignId,
String folder,
Integer order
) {}
public Npc createNpc(NpcData data) {
int order = data.order() != null
? data.order()
: nextOrderFor(data.campaignId());
Npc npc = Npc.builder()
.name(data.name())
.portraitImageId(data.portraitImageId())
.headerImageId(data.headerImageId())
.values(data.values() != null ? new HashMap<>(data.values()) : new HashMap<>())
.imageValues(data.imageValues() != null ? new HashMap<>(data.imageValues()) : new HashMap<>())
.keyValueValues(data.keyValueValues() != null ? new HashMap<>(data.keyValueValues()) : new HashMap<>())
.campaignId(data.campaignId())
.folder(normalizeFolder(data.folder()))
.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.setPortraitImageId(data.portraitImageId());
existing.setHeaderImageId(data.headerImageId());
existing.setValues(data.values() != null ? new HashMap<>(data.values()) : new HashMap<>());
existing.setImageValues(data.imageValues() != null ? new HashMap<>(data.imageValues()) : new HashMap<>());
existing.setKeyValueValues(data.keyValueValues() != null ? new HashMap<>(data.keyValueValues()) : new HashMap<>());
existing.setFolder(normalizeFolder(data.folder()));
if (data.order() != null) {
existing.setOrder(data.order());
}
return npcRepository.save(existing);
}
public void deleteNpc(String id) {
npcRepository.deleteById(id);
}
/** Trim le dossier ; chaîne vide → null (= non classé). */
private static String normalizeFolder(String folder) {
if (folder == null) return null;
String trimmed = folder.trim();
return trimmed.isEmpty() ? null : trimmed;
}
private int nextOrderFor(String campaignId) {
return npcRepository.findByCampaignId(campaignId).stream()
.mapToInt(Npc::getOrder)
.max()
.orElse(-1) + 1;
}
}

View File

@@ -0,0 +1,133 @@
package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Campaign;
import com.loremind.domain.campaigncontext.RandomTable;
import com.loremind.domain.campaigncontext.RandomTableEntry;
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.campaigncontext.ports.RandomTableGenerator;
import com.loremind.domain.campaigncontext.ports.RandomTableRepository;
import com.loremind.domain.gamesystemcontext.ports.GameSystemRepository;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
/**
* Service d'application pour les tables aléatoires (campagne).
*/
@Service
public class RandomTableService {
private final RandomTableRepository repository;
private final RandomTableGenerator generator;
private final CampaignRepository campaignRepository;
private final GameSystemRepository gameSystemRepository;
public RandomTableService(
RandomTableRepository repository,
RandomTableGenerator generator,
CampaignRepository campaignRepository,
GameSystemRepository gameSystemRepository) {
this.repository = repository;
this.generator = generator;
this.campaignRepository = campaignRepository;
this.gameSystemRepository = gameSystemRepository;
}
public record TableData(
String name,
String description,
String diceFormula,
String icon,
List<RandomTableEntry> entries,
String campaignId,
Integer order
) {}
public RandomTable createTable(TableData data) {
int order = data.order() != null ? data.order() : nextOrderFor(data.campaignId());
RandomTable table = RandomTable.builder()
.name(data.name())
.description(data.description())
.diceFormula(data.diceFormula())
.icon(data.icon())
.entries(copyEntries(data.entries()))
.campaignId(data.campaignId())
.order(order)
.build();
return repository.save(table);
}
public Optional<RandomTable> getTableById(String id) {
return repository.findById(id);
}
public List<RandomTable> getTablesByCampaignId(String campaignId) {
return repository.findByCampaignId(campaignId);
}
public RandomTable updateTable(String id, TableData data) {
RandomTable existing = repository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Table aléatoire introuvable: " + id));
existing.setName(data.name());
existing.setDescription(data.description());
existing.setDiceFormula(data.diceFormula());
existing.setIcon(data.icon());
existing.setEntries(copyEntries(data.entries()));
if (data.order() != null) {
existing.setOrder(data.order());
}
return repository.save(existing);
}
public void deleteTable(String id) {
repository.deleteById(id);
}
/** Génère une PROPOSITION de table (non persistée) via l'IA, contextualisée campagne. */
public RandomTable generateProposal(String campaignId, String description, String diceFormula) {
String formula = (diceFormula == null || diceFormula.isBlank()) ? "1d20" : diceFormula;
RandomTableGenerator.GeneratedTable g = generator.generate(description, formula, buildContext(campaignId));
return RandomTable.builder()
.name(g.name())
.description(g.description())
.diceFormula(formula)
.campaignId(campaignId)
.entries(g.entries() != null ? new ArrayList<>(g.entries()) : new ArrayList<>())
.build();
}
/** Brode un court récit IA sur un résultat tiré (pour la partie). */
public String improviseRoll(String campaignId, String tableName, String resultLabel, String resultDetail) {
return generator.improvise(tableName, resultLabel, resultDetail, buildContext(campaignId));
}
/** Contexte libre (nom de campagne + description + système) pour orienter l'IA. */
private String buildContext(String campaignId) {
if (campaignId == null) return "";
Campaign campaign = campaignRepository.findById(campaignId).orElse(null);
if (campaign == null) return "";
StringBuilder sb = new StringBuilder();
sb.append("Campagne : ").append(campaign.getName());
if (campaign.getDescription() != null && !campaign.getDescription().isBlank()) {
sb.append("").append(campaign.getDescription().trim());
}
if (campaign.getGameSystemId() != null && !campaign.getGameSystemId().isBlank()) {
gameSystemRepository.findById(campaign.getGameSystemId())
.ifPresent(gs -> sb.append("\nSystème de jeu : ").append(gs.getName()));
}
return sb.toString();
}
private static List<RandomTableEntry> copyEntries(List<RandomTableEntry> entries) {
return entries != null ? new ArrayList<>(entries) : new ArrayList<>();
}
private int nextOrderFor(String campaignId) {
return repository.findByCampaignId(campaignId).stream()
.mapToInt(RandomTable::getOrder)
.max()
.orElse(-1) + 1;
}
}

View File

@@ -6,6 +6,7 @@ import com.loremind.domain.campaigncontext.ports.SceneRepository;
import org.springframework.beans.BeanUtils; import org.springframework.beans.BeanUtils;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.Set; import java.util.Set;
@@ -26,15 +27,33 @@ public class SceneService {
} }
public Scene createScene(String name, String description, String chapterId, int order) { public Scene createScene(String name, String description, String chapterId, int order) {
return createScene(name, description, chapterId, order, null);
}
public Scene createScene(String name, String description, String chapterId, int order, String icon) {
Scene scene = Scene.builder() Scene scene = Scene.builder()
.name(name) .name(name)
.description(description) .description(description)
.chapterId(chapterId) .chapterId(chapterId)
.order(order) .order(order)
.icon(icon)
.build(); .build();
return sceneRepository.save(scene); return sceneRepository.save(scene);
} }
/**
* Crée une scène à partir d'un objet Scene complet (tous les champs : narration
* joueurs, notes MJ, pièces…). Utilisé par l'import de campagne. L'ID est forcé
* à null pour laisser le repo en générer un nouveau.
*/
public Scene createScene(Scene input) {
input.setId(null);
if (input.getRooms() == null) {
input.setRooms(new ArrayList<>());
}
return sceneRepository.save(input);
}
public Optional<Scene> getSceneById(String id) { public Optional<Scene> getSceneById(String id) {
return sceneRepository.findById(id); return sceneRepository.findById(id);
} }
@@ -93,7 +112,7 @@ public class SceneService {
.collect(Collectors.toSet()); .collect(Collectors.toSet());
for (SceneBranch b : branches) { for (SceneBranch b : branches) {
String target = b.getTargetSceneId(); String target = b.targetSceneId();
if (target == null || target.isBlank()) { if (target == null || target.isBlank()) {
throw new IllegalArgumentException("Une branche doit avoir une scène de destination"); throw new IllegalArgumentException("Une branche doit avoir une scène de destination");
} }

View File

@@ -45,11 +45,7 @@ public class GameSystemContextBuilder {
private GameSystemContext build(GameSystem gs, GenerationIntent intent) { private GameSystemContext build(GameSystem gs, GenerationIntent intent) {
Map<String, String> allSections = parseH2Sections(gs.getRulesMarkdown()); Map<String, String> allSections = parseH2Sections(gs.getRulesMarkdown());
Map<String, String> filtered = filterByIntent(allSections, intent); Map<String, String> filtered = filterByIntent(allSections, intent);
return GameSystemContext.builder() return new GameSystemContext(gs.getName(), gs.getDescription(), filtered);
.systemName(gs.getName())
.systemDescription(gs.getDescription())
.sections(filtered)
.build();
} }
/** /**

View File

@@ -1,7 +1,10 @@
package com.loremind.application.gamesystemcontext; package com.loremind.application.gamesystemcontext;
import com.loremind.domain.gamesystemcontext.GameSystem; import com.loremind.domain.gamesystemcontext.GameSystem;
import com.loremind.domain.gamesystemcontext.RulesImportResult;
import com.loremind.domain.gamesystemcontext.ports.GameSystemRepository; import com.loremind.domain.gamesystemcontext.ports.GameSystemRepository;
import com.loremind.domain.gamesystemcontext.ports.RulesPdfImporter;
import com.loremind.domain.shared.template.TemplateField;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.util.List; import java.util.List;
@@ -11,18 +14,46 @@ import java.util.Optional;
public class GameSystemService { public class GameSystemService {
private final GameSystemRepository gameSystemRepository; private final GameSystemRepository gameSystemRepository;
private final RulesPdfImporter rulesPdfImporter;
public GameSystemService(GameSystemRepository gameSystemRepository) { public GameSystemService(GameSystemRepository gameSystemRepository,
RulesPdfImporter rulesPdfImporter) {
this.gameSystemRepository = gameSystemRepository; this.gameSystemRepository = gameSystemRepository;
this.rulesPdfImporter = rulesPdfImporter;
}
/**
* Importe un PDF de règles et renvoie une PROPOSITION de sections (titre →
* markdown). Ne persiste rien : l'UI laisse l'utilisateur réviser/éditer
* puis enregistrer le GameSystem via {@link #updateGameSystem}/{@link #createGameSystem}.
*/
public RulesImportResult importRulesFromPdf(byte[] pdfBytes, String filename) {
return rulesPdfImporter.importRules(pdfBytes, filename);
}
/**
* Variante streamée de {@link #importRulesFromPdf} : remonte l'avancement via
* callbacks (import long → l'UI affiche une progression). Ne persiste rien.
*/
public void importRulesFromPdfStreaming(
byte[] pdfBytes,
String filename,
java.util.function.Consumer<com.loremind.domain.gamesystemcontext.RulesImportProgress> onProgress,
java.util.function.Consumer<RulesImportResult> onDone,
java.util.function.Consumer<Throwable> onError) {
rulesPdfImporter.importRulesStreaming(pdfBytes, filename, onProgress, onDone, onError);
} }
/** /**
* Parameter Object pour la création / mise à jour d'un GameSystem. * Parameter Object pour la création / mise à jour d'un GameSystem.
* Les templates peuvent etre null (interpretes comme listes vides).
*/ */
public record GameSystemData( public record GameSystemData(
String name, String name,
String description, String description,
String rulesMarkdown, String rulesMarkdown,
List<TemplateField> characterTemplate,
List<TemplateField> npcTemplate,
String author, String author,
boolean isPublic boolean isPublic
) {} ) {}
@@ -35,6 +66,8 @@ public class GameSystemService {
.author(normalize(data.author())) .author(normalize(data.author()))
.isPublic(data.isPublic()) .isPublic(data.isPublic())
.build(); .build();
gameSystem.replaceCharacterTemplate(data.characterTemplate());
gameSystem.replaceNpcTemplate(data.npcTemplate());
return gameSystemRepository.save(gameSystem); return gameSystemRepository.save(gameSystem);
} }
@@ -52,6 +85,8 @@ public class GameSystemService {
existing.setName(data.name()); existing.setName(data.name());
existing.setDescription(data.description()); existing.setDescription(data.description());
existing.setRulesMarkdown(data.rulesMarkdown()); existing.setRulesMarkdown(data.rulesMarkdown());
existing.replaceCharacterTemplate(data.characterTemplate());
existing.replaceNpcTemplate(data.npcTemplate());
existing.setAuthor(normalize(data.author())); existing.setAuthor(normalize(data.author()));
existing.setPublic(data.isPublic()); existing.setPublic(data.isPublic());
return gameSystemRepository.save(existing); return gameSystemRepository.save(existing);

View File

@@ -4,17 +4,22 @@ import com.loremind.domain.campaigncontext.Arc;
import com.loremind.domain.campaigncontext.Campaign; import com.loremind.domain.campaigncontext.Campaign;
import com.loremind.domain.campaigncontext.Chapter; import com.loremind.domain.campaigncontext.Chapter;
import com.loremind.domain.campaigncontext.Character; import com.loremind.domain.campaigncontext.Character;
import com.loremind.domain.campaigncontext.Npc;
import com.loremind.domain.campaigncontext.Scene; import com.loremind.domain.campaigncontext.Scene;
import com.loremind.domain.campaigncontext.ports.ArcRepository; import com.loremind.domain.campaigncontext.ports.ArcRepository;
import com.loremind.domain.campaigncontext.ports.CampaignRepository; import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.campaigncontext.ports.ChapterRepository; import com.loremind.domain.campaigncontext.ports.ChapterRepository;
import com.loremind.domain.campaigncontext.ports.CharacterRepository; import com.loremind.domain.campaigncontext.ports.CharacterRepository;
import com.loremind.domain.campaigncontext.ports.NpcRepository;
import com.loremind.domain.campaigncontext.ports.SceneRepository; import com.loremind.domain.campaigncontext.ports.SceneRepository;
import com.loremind.domain.generationcontext.CampaignStructuralContext; import com.loremind.domain.generationcontext.CampaignStructuralContext;
import com.loremind.domain.generationcontext.CampaignStructuralContext.ArcSummary; import com.loremind.domain.generationcontext.CampaignStructuralContext.ArcSummary;
import com.loremind.domain.generationcontext.CampaignStructuralContext.BranchHint; import com.loremind.domain.generationcontext.CampaignStructuralContext.BranchHint;
import com.loremind.domain.generationcontext.CampaignStructuralContext.ChapterSummary; import com.loremind.domain.generationcontext.CampaignStructuralContext.ChapterSummary;
import com.loremind.domain.generationcontext.CampaignStructuralContext.CharacterSummary; import com.loremind.domain.generationcontext.CampaignStructuralContext.CharacterSummary;
import com.loremind.domain.generationcontext.CampaignStructuralContext.NpcSummary;
import com.loremind.domain.generationcontext.CampaignStructuralContext.RoomBranchHint;
import com.loremind.domain.generationcontext.CampaignStructuralContext.RoomSummary;
import com.loremind.domain.generationcontext.CampaignStructuralContext.SceneSummary; import com.loremind.domain.generationcontext.CampaignStructuralContext.SceneSummary;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@@ -42,29 +47,39 @@ public class CampaignStructuralContextBuilder {
private final ChapterRepository chapterRepository; private final ChapterRepository chapterRepository;
private final SceneRepository sceneRepository; private final SceneRepository sceneRepository;
private final CharacterRepository characterRepository; private final CharacterRepository characterRepository;
private final NpcRepository npcRepository;
public CampaignStructuralContextBuilder( public CampaignStructuralContextBuilder(
CampaignRepository campaignRepository, CampaignRepository campaignRepository,
ArcRepository arcRepository, ArcRepository arcRepository,
ChapterRepository chapterRepository, ChapterRepository chapterRepository,
SceneRepository sceneRepository, SceneRepository sceneRepository,
CharacterRepository characterRepository) { CharacterRepository characterRepository,
NpcRepository npcRepository) {
this.campaignRepository = campaignRepository; this.campaignRepository = campaignRepository;
this.arcRepository = arcRepository; this.arcRepository = arcRepository;
this.chapterRepository = chapterRepository; this.chapterRepository = chapterRepository;
this.sceneRepository = sceneRepository; this.sceneRepository = sceneRepository;
this.characterRepository = characterRepository; this.characterRepository = characterRepository;
this.npcRepository = npcRepository;
} }
/** Longueur max du snippet de PJ injecté dans le contexte (coût tokens maîtrisé). */ /** Longueur max du snippet de PJ/PNJ injecté dans le contexte (coût tokens maîtrisé). */
private static final int CHARACTER_SNIPPET_MAX_LEN = 160; private static final int CHARACTER_SNIPPET_MAX_LEN = 160;
/** /**
* Construit la carte narrative d'une Campagne (arcs → chapitres → scènes, * Construit la carte narrative d'une Campagne. Sans playthroughId, les PJ
* nom + description courte à chaque niveau). * sont omis (ils sont propres à une Partie).
* @throws IllegalArgumentException si la Campagne est introuvable
*/ */
public CampaignStructuralContext build(String campaignId) { public CampaignStructuralContext build(String campaignId) {
return build(campaignId, null);
}
/**
* Variante avec playthroughId : injecte les PJ de la Partie indiquée.
* Les PNJ restent campagne-scope (donnée de scénario).
*/
public CampaignStructuralContext build(String campaignId, String playthroughId) {
Campaign campaign = campaignRepository.findById(campaignId) Campaign campaign = campaignRepository.findById(campaignId)
.orElseThrow(() -> new IllegalArgumentException( .orElseThrow(() -> new IllegalArgumentException(
"Campagne non trouvée avec l'ID: " + campaignId)); "Campagne non trouvée avec l'ID: " + campaignId));
@@ -74,17 +89,24 @@ public class CampaignStructuralContextBuilder {
.map(this::toArcSummary) .map(this::toArcSummary)
.collect(Collectors.toList()); .collect(Collectors.toList());
List<CharacterSummary> characters = characterRepository.findByCampaignId(campaignId).stream() List<CharacterSummary> characters = (playthroughId == null || playthroughId.isBlank())
.sorted(Comparator.comparingInt(Character::getOrder)) ? List.of()
.map(this::toCharacterSummary) : characterRepository.findByPlaythroughId(playthroughId).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()); .collect(Collectors.toList());
return CampaignStructuralContext.builder() return new CampaignStructuralContext(
.campaignName(campaign.getName()) campaign.getName(),
.campaignDescription(campaign.getDescription()) campaign.getDescription(),
.arcs(arcs) arcs,
.characters(characters) characters,
.build(); npcs);
} }
/** /**
@@ -93,21 +115,32 @@ public class CampaignStructuralContextBuilder {
* sans injecter toute sa fiche. * sans injecter toute sa fiche.
*/ */
private CharacterSummary toCharacterSummary(Character c) { private CharacterSummary toCharacterSummary(Character c) {
return CharacterSummary.builder() return new CharacterSummary(c.getName(), extractSnippet(c.getValues()));
.name(c.getName())
.snippet(extractSnippet(c.getMarkdownContent()))
.build();
} }
private static String extractSnippet(String markdown) { /** Symétrique à {@link #toCharacterSummary} pour les PNJ. */
if (markdown == null || markdown.isBlank()) return ""; private NpcSummary toNpcSummary(Npc n) {
String firstLine = markdown.lines() return new NpcSummary(n.getName(), extractSnippet(n.getValues()));
.map(String::strip) }
.filter(l -> !l.isEmpty() && !l.startsWith("#"))
.findFirst() /**
.orElse(""); * Snippet pour le resume IA : 1re ligne signifiante de la 1re valeur non vide
if (firstLine.length() <= CHARACTER_SNIPPET_MAX_LEN) return firstLine; * du template (refonte 2026-04-30 — remplace l'ancien parsing markdown).
return firstLine.substring(0, CHARACTER_SNIPPET_MAX_LEN - 1).stripTrailing() + ""; */
private static String extractSnippet(java.util.Map<String, String> values) {
if (values == null || values.isEmpty()) return "";
for (String value : values.values()) {
if (value == null || value.isBlank()) continue;
String firstLine = value.lines()
.map(String::strip)
.filter(l -> !l.isEmpty() && !l.startsWith("#"))
.findFirst()
.orElse("");
if (firstLine.isEmpty()) continue;
if (firstLine.length() <= CHARACTER_SNIPPET_MAX_LEN) return firstLine;
return firstLine.substring(0, CHARACTER_SNIPPET_MAX_LEN - 1).stripTrailing() + "";
}
return "";
} }
private ArcSummary toArcSummary(Arc arc) { private ArcSummary toArcSummary(Arc arc) {
@@ -115,12 +148,11 @@ public class CampaignStructuralContextBuilder {
.sorted(Comparator.comparingInt(Chapter::getOrder)) .sorted(Comparator.comparingInt(Chapter::getOrder))
.map(this::toChapterSummary) .map(this::toChapterSummary)
.collect(Collectors.toList()); .collect(Collectors.toList());
return ArcSummary.builder() return new ArcSummary(
.name(arc.getName()) arc.getName(),
.description(arc.getDescription()) arc.getDescription(),
.illustrationCount(countImages(arc.getIllustrationImageIds())) countImages(arc.getIllustrationImageIds()),
.chapters(chapters) chapters);
.build();
} }
private ChapterSummary toChapterSummary(Chapter chapter) { private ChapterSummary toChapterSummary(Chapter chapter) {
@@ -137,32 +169,59 @@ public class CampaignStructuralContextBuilder {
.map(s -> toSceneSummary(s, nameById)) .map(s -> toSceneSummary(s, nameById))
.collect(Collectors.toList()); .collect(Collectors.toList());
return ChapterSummary.builder() return new ChapterSummary(
.name(chapter.getName()) chapter.getName(),
.description(chapter.getDescription()) chapter.getDescription(),
.illustrationCount(countImages(chapter.getIllustrationImageIds())) countImages(chapter.getIllustrationImageIds()),
.scenes(summaries) summaries);
.build();
} }
private SceneSummary toSceneSummary(Scene scene, Map<String, String> nameById) { private SceneSummary toSceneSummary(Scene scene, Map<String, String> nameById) {
List<BranchHint> hints = scene.getBranches() == null List<BranchHint> hints = scene.getBranches() == null
? List.of() ? List.of()
: scene.getBranches().stream() : scene.getBranches().stream()
.map(b -> BranchHint.builder() .map(b -> new BranchHint(
.label(b.getLabel()) b.label(),
.targetSceneName(nameById.getOrDefault( nameById.getOrDefault(b.targetSceneId(), "(scène inconnue)"),
b.getTargetSceneId(), "(scène inconnue)")) b.condition()))
.condition(b.getCondition())
.build())
.collect(Collectors.toList()); .collect(Collectors.toList());
return SceneSummary.builder() List<RoomSummary> rooms = toRoomSummaries(scene);
.name(scene.getName())
.description(scene.getDescription()) return new SceneSummary(
.illustrationCount(countImages(scene.getIllustrationImageIds())) scene.getName(),
.branches(hints) scene.getDescription(),
.build(); countImages(scene.getIllustrationImageIds()),
hints,
rooms);
}
/**
* Projette les pièces d'une scène en RoomSummary pour le contexte IA.
* Pas de notes MJ, pas de loot ni pièges : le prompt reste lisible. L'IA
* connaît la structure du lieu (nom des pièces, ennemis, sorties) — c'est
* suffisant pour proposer de la narration ou anticiper les choix.
*/
private List<RoomSummary> toRoomSummaries(Scene scene) {
if (scene.getRooms() == null || scene.getRooms().isEmpty()) return List.of();
Map<String, String> nameById = scene.getRooms().stream()
.collect(Collectors.toMap(
com.loremind.domain.campaigncontext.Room::getId,
com.loremind.domain.campaigncontext.Room::getName,
(a, b) -> a));
return scene.getRooms().stream()
.map(r -> {
List<RoomBranchHint> hints = r.getBranches() == null
? List.of()
: r.getBranches().stream()
.map(b -> new RoomBranchHint(
b.label(),
nameById.getOrDefault(b.targetRoomId(), "(pièce inconnue)"),
b.condition()))
.collect(Collectors.toList());
return new RoomSummary(r.getName(), r.getFloor(), r.getDescription(), r.getEnemies(), hints);
})
.collect(Collectors.toList());
} }
/** Helper defensif : compte les illustrations attachees (null-safe). */ /** Helper defensif : compte les illustrations attachees (null-safe). */

View File

@@ -67,16 +67,15 @@ public class GeneratePageValuesUseCase {
requireNonEmptyFields(template); requireNonEmptyFields(template);
GenerationContext context = GenerationContext.builder() // Seuls les champs TEXT sont envoyes a l'IA : les champs IMAGE
.loreName(lore.getName()) // necessitent un workflow different (pas de generation LLM texte).
.loreDescription(lore.getDescription()) GenerationContext context = new GenerationContext(
.folderName(folder.getName()) lore.getName(),
.templateName(template.getName()) lore.getDescription(),
// Seuls les champs TEXT sont envoyes a l'IA : les champs IMAGE folder.getName(),
// necessitent un workflow different (pas de generation LLM texte). template.getName(),
.templateFields(template.textFieldNames()) template.textFieldNames(),
.pageTitle(page.getTitle()) page.getTitle());
.build();
GenerationResult result = aiProvider.generatePage(context); GenerationResult result = aiProvider.generatePage(context);
return result.values(); return result.values();

View File

@@ -82,12 +82,11 @@ public class LoreStructuralContextBuilder {
Map<String, String> pageTitleById = pages.stream() Map<String, String> pageTitleById = pages.stream()
.collect(Collectors.toMap(Page::getId, Page::getTitle, (a, b) -> a)); .collect(Collectors.toMap(Page::getId, Page::getTitle, (a, b) -> a));
return LoreStructuralContext.builder() return new LoreStructuralContext(
.loreName(lore.getName()) lore.getName(),
.loreDescription(lore.getDescription()) lore.getDescription(),
.folders(buildFoldersMap(nodes, pages, templateNameById, pageTitleById)) buildFoldersMap(nodes, pages, templateNameById, pageTitleById),
.tags(extractUniqueTags(pages)) extractUniqueTags(pages));
.build();
} }
private Map<String, List<PageSummary>> buildFoldersMap( private Map<String, List<PageSummary>> buildFoldersMap(
@@ -118,13 +117,12 @@ public class LoreStructuralContextBuilder {
Page page, Page page,
Map<String, String> templateNameById, Map<String, String> templateNameById,
Map<String, String> pageTitleById) { Map<String, String> pageTitleById) {
return PageSummary.builder() return new PageSummary(
.title(page.getTitle()) page.getTitle(),
.templateName(templateNameById.getOrDefault(page.getTemplateId(), "?")) templateNameById.getOrDefault(page.getTemplateId(), "?"),
.values(truncatedValues(page.getValues())) truncatedValues(page.getValues()),
.tags(page.getTags() != null ? List.copyOf(page.getTags()) : Collections.emptyList()) page.getTags() != null ? List.copyOf(page.getTags()) : Collections.emptyList(),
.relatedPageTitles(resolveRelatedTitles(page.getRelatedPageIds(), pageTitleById)) resolveRelatedTitles(page.getRelatedPageIds(), pageTitleById));
.build();
} }
/** /**

View File

@@ -3,10 +3,12 @@ package com.loremind.application.generationcontext;
import com.loremind.domain.campaigncontext.Arc; import com.loremind.domain.campaigncontext.Arc;
import com.loremind.domain.campaigncontext.Chapter; import com.loremind.domain.campaigncontext.Chapter;
import com.loremind.domain.campaigncontext.Character; import com.loremind.domain.campaigncontext.Character;
import com.loremind.domain.campaigncontext.Npc;
import com.loremind.domain.campaigncontext.Scene; import com.loremind.domain.campaigncontext.Scene;
import com.loremind.domain.campaigncontext.ports.ArcRepository; import com.loremind.domain.campaigncontext.ports.ArcRepository;
import com.loremind.domain.campaigncontext.ports.ChapterRepository; import com.loremind.domain.campaigncontext.ports.ChapterRepository;
import com.loremind.domain.campaigncontext.ports.CharacterRepository; import com.loremind.domain.campaigncontext.ports.CharacterRepository;
import com.loremind.domain.campaigncontext.ports.NpcRepository;
import com.loremind.domain.campaigncontext.ports.SceneRepository; import com.loremind.domain.campaigncontext.ports.SceneRepository;
import com.loremind.domain.generationcontext.NarrativeEntityContext; import com.loremind.domain.generationcontext.NarrativeEntityContext;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@@ -29,22 +31,25 @@ public class NarrativeEntityContextBuilder {
private final ChapterRepository chapterRepository; private final ChapterRepository chapterRepository;
private final SceneRepository sceneRepository; private final SceneRepository sceneRepository;
private final CharacterRepository characterRepository; private final CharacterRepository characterRepository;
private final NpcRepository npcRepository;
public NarrativeEntityContextBuilder( public NarrativeEntityContextBuilder(
ArcRepository arcRepository, ArcRepository arcRepository,
ChapterRepository chapterRepository, ChapterRepository chapterRepository,
SceneRepository sceneRepository, SceneRepository sceneRepository,
CharacterRepository characterRepository) { CharacterRepository characterRepository,
NpcRepository npcRepository) {
this.arcRepository = arcRepository; this.arcRepository = arcRepository;
this.chapterRepository = chapterRepository; this.chapterRepository = chapterRepository;
this.sceneRepository = sceneRepository; this.sceneRepository = sceneRepository;
this.characterRepository = characterRepository; this.characterRepository = characterRepository;
this.npcRepository = npcRepository;
} }
/** /**
* Charge l'entité narrative ciblée et la projette vers un VO du GenerationContext. * Charge l'entité narrative ciblée et la projette vers un VO du GenerationContext.
* *
* @param entityType "arc", "chapter", "scene" ou "character" (insensible à la casse) * @param entityType "arc", "chapter", "scene", "character" ou "npc" (insensible à la casse)
* @param entityId l'ID de l'entité * @param entityId l'ID de l'entité
* @throws IllegalArgumentException si le type est inconnu ou l'entité introuvable * @throws IllegalArgumentException si le type est inconnu ou l'entité introuvable
*/ */
@@ -55,6 +60,7 @@ public class NarrativeEntityContextBuilder {
case "chapter" -> fromChapter(loadChapter(entityId)); case "chapter" -> fromChapter(loadChapter(entityId));
case "scene" -> fromScene(loadScene(entityId)); case "scene" -> fromScene(loadScene(entityId));
case "character" -> fromCharacter(loadCharacter(entityId)); case "character" -> fromCharacter(loadCharacter(entityId));
case "npc" -> fromNpc(loadNpc(entityId));
default -> throw new IllegalArgumentException("Type d'entité narrative inconnu: " + entityType); default -> throw new IllegalArgumentException("Type d'entité narrative inconnu: " + entityType);
}; };
} }
@@ -81,6 +87,11 @@ public class NarrativeEntityContextBuilder {
.orElseThrow(() -> new IllegalArgumentException("Personnage non trouvé: " + id)); .orElseThrow(() -> new IllegalArgumentException("Personnage non trouvé: " + id));
} }
private Npc loadNpc(String id) {
return npcRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("PNJ non trouvé: " + id));
}
// --- Mapping entité → VO ------------------------------------------------ // --- Mapping entité → VO ------------------------------------------------
private NarrativeEntityContext fromArc(Arc a) { private NarrativeEntityContext fromArc(Arc a) {
@@ -91,11 +102,7 @@ public class NarrativeEntityContextBuilder {
putField(fields, "rewards", a.getRewards()); putField(fields, "rewards", a.getRewards());
putField(fields, "resolution", a.getResolution()); putField(fields, "resolution", a.getResolution());
putField(fields, "gmNotes", a.getGmNotes()); putField(fields, "gmNotes", a.getGmNotes());
return NarrativeEntityContext.builder() return new NarrativeEntityContext("arc", a.getName(), fields);
.entityType("arc")
.title(a.getName())
.fields(fields)
.build();
} }
private NarrativeEntityContext fromChapter(Chapter c) { private NarrativeEntityContext fromChapter(Chapter c) {
@@ -104,11 +111,7 @@ public class NarrativeEntityContextBuilder {
putField(fields, "playerObjectives", c.getPlayerObjectives()); putField(fields, "playerObjectives", c.getPlayerObjectives());
putField(fields, "narrativeStakes", c.getNarrativeStakes()); putField(fields, "narrativeStakes", c.getNarrativeStakes());
putField(fields, "gmNotes", c.getGmNotes()); putField(fields, "gmNotes", c.getGmNotes());
return NarrativeEntityContext.builder() return new NarrativeEntityContext("chapter", c.getName(), fields);
.entityType("chapter")
.title(c.getName())
.fields(fields)
.build();
} }
private NarrativeEntityContext fromScene(Scene s) { private NarrativeEntityContext fromScene(Scene s) {
@@ -122,21 +125,25 @@ public class NarrativeEntityContextBuilder {
putField(fields, "combatDifficulty", s.getCombatDifficulty()); putField(fields, "combatDifficulty", s.getCombatDifficulty());
putField(fields, "enemies", s.getEnemies()); putField(fields, "enemies", s.getEnemies());
putField(fields, "gmSecretNotes", s.getGmSecretNotes()); putField(fields, "gmSecretNotes", s.getGmSecretNotes());
return NarrativeEntityContext.builder() return new NarrativeEntityContext("scene", s.getName(), fields);
.entityType("scene")
.title(s.getName())
.fields(fields)
.build();
} }
private NarrativeEntityContext fromCharacter(Character c) { private NarrativeEntityContext fromCharacter(Character c) {
Map<String, String> fields = new LinkedHashMap<>(); Map<String, String> fields = new LinkedHashMap<>();
putField(fields, "fiche complète (markdown)", c.getMarkdownContent()); if (c.getValues() != null) {
return NarrativeEntityContext.builder() // Champs templates exposes individuellement — meilleur pour le LLM que
.entityType("character") // l'ancien blob markdown monolithique.
.title(c.getName()) c.getValues().forEach((k, v) -> putField(fields, k, v));
.fields(fields) }
.build(); return new NarrativeEntityContext("character", c.getName(), fields);
}
private NarrativeEntityContext fromNpc(Npc n) {
Map<String, String> fields = new LinkedHashMap<>();
if (n.getValues() != null) {
n.getValues().forEach((k, v) -> putField(fields, k, v));
}
return new NarrativeEntityContext("npc", n.getName(), fields);
} }
/** Null/blank devient chaîne vide — uniforme côté prompt, pas de NPE côté LLM. */ /** Null/blank devient chaîne vide — uniforme côté prompt, pas de NPE côté LLM. */

View File

@@ -0,0 +1,234 @@
package com.loremind.application.generationcontext;
import com.loremind.domain.campaigncontext.Arc;
import com.loremind.domain.campaigncontext.ArcType;
import com.loremind.domain.campaigncontext.Chapter;
import com.loremind.domain.campaigncontext.PrerequisiteEvaluator;
import com.loremind.domain.campaigncontext.ProgressionStatus;
import com.loremind.domain.campaigncontext.QuestStatus;
import com.loremind.domain.campaigncontext.ports.ArcRepository;
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
import com.loremind.domain.generationcontext.SessionContext;
import com.loremind.domain.generationcontext.SessionContext.JournalEntrySummary;
import com.loremind.domain.generationcontext.SessionContext.QuestSummary;
import com.loremind.domain.playcontext.EntryType;
import com.loremind.domain.playcontext.Playthrough;
import com.loremind.domain.playcontext.QuestProgression;
import com.loremind.domain.playcontext.Session;
import com.loremind.domain.playcontext.SessionEntry;
import com.loremind.domain.playcontext.ports.PlaythroughFlagRepository;
import com.loremind.domain.playcontext.ports.PlaythroughRepository;
import com.loremind.domain.playcontext.ports.QuestProgressionRepository;
import com.loremind.domain.playcontext.ports.SessionEntryRepository;
import com.loremind.domain.playcontext.ports.SessionRepository;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;
/**
* Construit le SessionContext injecté dans le prompt IA pendant une partie.
*
* <p>Depuis la refonte Playthrough : la session connaît son playthroughId ; la progression
* et les flags viennent du Playthrough (pas plus de la Campagne ni du Chapter).</p>
*/
@Component
public class SessionStructuralContextBuilder {
private static final int MAX_CURRENT_ENTRIES = 80;
private static final int MAX_PREVIOUS_EVENTS = 60;
private final SessionRepository sessionRepository;
private final SessionEntryRepository entryRepository;
private final PlaythroughRepository playthroughRepository;
private final ArcRepository arcRepository;
private final ChapterRepository chapterRepository;
private final PlaythroughFlagRepository playthroughFlagRepository;
private final QuestProgressionRepository questProgressionRepository;
private final PrerequisiteEvaluator prerequisiteEvaluator = new PrerequisiteEvaluator();
public SessionStructuralContextBuilder(SessionRepository sessionRepository,
SessionEntryRepository entryRepository,
PlaythroughRepository playthroughRepository,
ArcRepository arcRepository,
ChapterRepository chapterRepository,
PlaythroughFlagRepository playthroughFlagRepository,
QuestProgressionRepository questProgressionRepository) {
this.sessionRepository = sessionRepository;
this.entryRepository = entryRepository;
this.playthroughRepository = playthroughRepository;
this.arcRepository = arcRepository;
this.chapterRepository = chapterRepository;
this.playthroughFlagRepository = playthroughFlagRepository;
this.questProgressionRepository = questProgressionRepository;
}
public Optional<SessionContext> buildOptional(String sessionId) {
return sessionRepository.findById(sessionId).map(this::toContext);
}
public SessionContext build(String sessionId) {
Session session = sessionRepository.findById(sessionId)
.orElseThrow(() -> new IllegalArgumentException("Session introuvable : " + sessionId));
return toContext(session);
}
private SessionContext toContext(Session session) {
List<JournalEntrySummary> currentEntries = loadCurrentEntries(session);
List<JournalEntrySummary> previousEvents = loadPreviousEvents(session);
HubStatus hub = computeHubStatus(session.getPlaythroughId());
return new SessionContext(
session.getName(),
session.isActive(),
session.getStartedAt(),
currentEntries,
previousEvents,
hub.available(),
hub.inProgress(),
hub.lockedTitles(),
hub.activeFlags());
}
private List<JournalEntrySummary> loadCurrentEntries(Session session) {
List<SessionEntry> allEntries = entryRepository.findBySessionId(session.getId());
List<SessionEntry> kept = allEntries.size() <= MAX_CURRENT_ENTRIES
? allEntries
: allEntries.subList(allEntries.size() - MAX_CURRENT_ENTRIES, allEntries.size());
return kept.stream()
.map(e -> toSummary(e, null))
.collect(Collectors.toList());
}
/**
* EVENTs des sessions précédentes du MÊME Playthrough (même table).
* On ne mélange jamais les EVENTs de tables différentes.
*/
private List<JournalEntrySummary> loadPreviousEvents(Session current) {
if (current.getPlaythroughId() == null) return List.of();
List<Session> siblingSessions = sessionRepository.findByPlaythroughId(current.getPlaythroughId());
List<JournalEntrySummary> events = new ArrayList<>();
for (Session past : siblingSessions) {
if (past.getId().equals(current.getId())) continue;
for (SessionEntry entry : entryRepository.findBySessionId(past.getId())) {
if (entry.getType() == EntryType.EVENT) {
events.add(toSummary(entry, past.getName()));
}
}
}
events.sort(Comparator.comparing(
JournalEntrySummary::occurredAt,
Comparator.nullsLast(Comparator.naturalOrder())));
if (events.size() > MAX_PREVIOUS_EVENTS) {
return events.subList(events.size() - MAX_PREVIOUS_EVENTS, events.size());
}
return events;
}
private JournalEntrySummary toSummary(SessionEntry entry, String sourceSessionName) {
return new JournalEntrySummary(
entry.getType() != null ? entry.getType().name() : "NOTE",
entry.getContent(),
entry.getOccurredAt(),
sourceSessionName);
}
/** Agrégat interne des données Hub à injecter dans le SessionContext. */
private record HubStatus(
List<QuestSummary> available,
List<QuestSummary> inProgress,
List<String> lockedTitles,
List<String> activeFlags
) {}
/**
* Calcule l'état des quêtes Hub du Playthrough courant :
* - AVAILABLE / IN_PROGRESS → résumé complet
* - LOCKED → titre uniquement (anti-spoiler)
* - COMPLETED → omis (déjà raconté par les EVENTs)
*/
private HubStatus computeHubStatus(String playthroughId) {
if (playthroughId == null) {
return new HubStatus(List.of(), List.of(), List.of(), List.of());
}
Optional<Playthrough> maybePlaythrough = playthroughRepository.findById(playthroughId);
if (maybePlaythrough.isEmpty()) {
return new HubStatus(List.of(), List.of(), List.of(), List.of());
}
String campaignId = maybePlaythrough.get().getCampaignId();
Map<String, Boolean> flags = playthroughFlagRepository.findByPlaythroughId(playthroughId);
List<String> activeFlags = buildActiveFlags(flags);
List<Arc> arcs = arcRepository.findByCampaignId(campaignId);
Map<String, Arc> arcsById = arcs.stream()
.filter(a -> a.getId() != null)
.collect(Collectors.toMap(Arc::getId, a -> a));
// On suit comme "quêtes" les chapitres CONDITIONNELS : ceux d'un arc HUB, ET
// ceux d'un arc linéaire qui portent des prérequis. Un chapitre linéaire sans
// condition reste hors du tableau (sinon tous les chapitres deviendraient des quêtes).
// Map chapterId -> ProgressionStatus pour ce Playthrough
Map<String, ProgressionStatus> progressionByChapter = new HashMap<>();
for (QuestProgression qp : questProgressionRepository.findByPlaythroughId(playthroughId)) {
progressionByChapter.put(qp.getChapterId(), qp.getStatus());
}
// IDs des chapitres COMPLETED dans la campagne (pour les prérequis QuestCompleted)
var completedIds = questProgressionRepository.findCompletedChapterIdsByPlaythroughId(playthroughId);
int sessionCount = sessionRepository.findByPlaythroughId(playthroughId).size();
PrerequisiteEvaluator.EvaluationContext ctx =
new PrerequisiteEvaluator.EvaluationContext(completedIds, sessionCount, flags);
List<QuestSummary> available = new ArrayList<>();
List<QuestSummary> inProgress = new ArrayList<>();
List<String> lockedTitles = new ArrayList<>();
for (Arc arc : arcs) {
boolean isHub = arc.getType() == ArcType.HUB;
for (Chapter c : chapterRepository.findByArcId(arc.getId())) {
boolean hasPrereqs = c.getPrerequisites() != null && !c.getPrerequisites().isEmpty();
if (!isHub && !hasPrereqs) continue; // chapitre linéaire sans condition : ignoré
ProgressionStatus prog = progressionByChapter.getOrDefault(c.getId(), ProgressionStatus.NOT_STARTED);
QuestStatus status = prerequisiteEvaluator.computeStatus(prog, c.getPrerequisites(), ctx);
Arc parent = arcsById.get(c.getArcId());
String arcName = parent != null ? parent.getName() : null;
switch (status) {
case AVAILABLE:
available.add(new QuestSummary(c.getName(), arcName, c.getDescription()));
break;
case IN_PROGRESS:
inProgress.add(new QuestSummary(c.getName(), arcName, c.getDescription()));
break;
case LOCKED:
lockedTitles.add(c.getName());
break;
case COMPLETED:
// Omis (déjà dans le journal des EVENTs).
break;
}
}
}
return new HubStatus(available, inProgress, lockedTitles, activeFlags);
}
private List<String> buildActiveFlags(Map<String, Boolean> flags) {
return flags.entrySet().stream()
.filter(Map.Entry::getValue)
.map(Map.Entry::getKey)
.sorted()
.collect(Collectors.toList());
}
}

View File

@@ -107,11 +107,6 @@ public class StreamChatForLoreUseCase {
? page.getValues() ? page.getValues()
: Collections.emptyMap(); : Collections.emptyMap();
return PageContext.builder() return new PageContext(page.getTitle(), templateName, templateFields, values);
.title(page.getTitle())
.templateName(templateName)
.templateFields(templateFields)
.values(values)
.build();
} }
} }

View File

@@ -0,0 +1,122 @@
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.SessionContext;
import com.loremind.domain.generationcontext.ports.AiChatProvider;
import com.loremind.domain.playcontext.Playthrough;
import com.loremind.domain.playcontext.Session;
import com.loremind.domain.playcontext.ports.PlaythroughRepository;
import com.loremind.domain.playcontext.ports.SessionRepository;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.function.Consumer;
/**
* Use case applicatif : chat IA pendant une Session de jeu.
* <p>
* Orchestre la composition des contextes :
* 1. Charge la Session puis la Campagne associée (weak reference).
* 2. Construit le CampaignStructuralContext (carte narrative + PJ/PNJ).
* 3. Construit le LoreStructuralContext si la campagne est liée à un Lore.
* 4. Construit le GameSystemContext si elle a un système de JDR.
* 5. Construit le SessionContext (journal horodaté, statut).
* 6. Délègue au port {@link AiChatProvider} pour le streaming.
* </p>
*
* <p>La conversation est éphémère (pas de persistance) : pendant une partie,
* l'utilité est d'avoir une assistance immédiate, pas de garder un historique.
* Le journal de session joue déjà ce rôle de mémoire persistante.</p>
*/
@Service
public class StreamChatForSessionUseCase {
private final SessionRepository sessionRepository;
private final PlaythroughRepository playthroughRepository;
private final CampaignRepository campaignRepository;
private final CampaignStructuralContextBuilder campaignContextBuilder;
private final LoreStructuralContextBuilder loreContextBuilder;
private final GameSystemContextBuilder gameSystemContextBuilder;
private final SessionStructuralContextBuilder sessionContextBuilder;
private final AiChatProvider aiChatProvider;
public StreamChatForSessionUseCase(
SessionRepository sessionRepository,
PlaythroughRepository playthroughRepository,
CampaignRepository campaignRepository,
CampaignStructuralContextBuilder campaignContextBuilder,
LoreStructuralContextBuilder loreContextBuilder,
GameSystemContextBuilder gameSystemContextBuilder,
SessionStructuralContextBuilder sessionContextBuilder,
AiChatProvider aiChatProvider) {
this.sessionRepository = sessionRepository;
this.playthroughRepository = playthroughRepository;
this.campaignRepository = campaignRepository;
this.campaignContextBuilder = campaignContextBuilder;
this.loreContextBuilder = loreContextBuilder;
this.gameSystemContextBuilder = gameSystemContextBuilder;
this.sessionContextBuilder = sessionContextBuilder;
this.aiChatProvider = aiChatProvider;
}
public void execute(
String sessionId,
List<ChatMessage> messages,
Consumer<ChatUsage> onUsage,
Consumer<String> onToken,
Runnable onComplete,
Consumer<Throwable> onError) {
Session session = sessionRepository.findById(sessionId)
.orElseThrow(() -> new IllegalArgumentException("Session introuvable : " + sessionId));
// Chaîne de résolution : Session → Playthrough → Campaign.
Playthrough playthrough = playthroughRepository.findById(session.getPlaythroughId())
.orElseThrow(() -> new IllegalArgumentException(
"Partie associée à la session introuvable : " + session.getPlaythroughId()));
Campaign campaign = campaignRepository.findById(playthrough.getCampaignId())
.orElseThrow(() -> new IllegalArgumentException(
"Campagne associée à la partie introuvable : " + playthrough.getCampaignId()));
// Le campaign context inclut les PJ de CE Playthrough (les PJ sont par-table).
CampaignStructuralContext campaignContext = campaignContextBuilder.build(campaign.getId(), playthrough.getId());
LoreStructuralContext loreContext = loadLoreContextOrNull(campaign);
GameSystemContext gameSystemContext = loadGameSystemContextOrNull(campaign);
SessionContext sessionContext = sessionContextBuilder.build(sessionId);
ChatRequest request = ChatRequest.builder()
.messages(messages)
.loreContext(loreContext)
.campaignContext(campaignContext)
.gameSystemContext(gameSystemContext)
.sessionContext(sessionContext)
.build();
aiChatProvider.streamChat(request, onUsage, onToken, onComplete, onError);
}
private LoreStructuralContext loadLoreContextOrNull(Campaign campaign) {
if (!campaign.isLinkedToLore()) return null;
return loreContextBuilder.buildOptional(campaign.getLoreId()).orElse(null);
}
/**
* Pendant une session active, on injecte les sections les plus utiles en partie
* (combats, PNJ, mécaniques) — intent SCENE est le plus proche de ce besoin.
*/
private GameSystemContext loadGameSystemContextOrNull(Campaign campaign) {
if (!campaign.isLinkedToGameSystem()) return null;
return gameSystemContextBuilder.buildOptional(campaign.getGameSystemId(), GenerationIntent.SCENE)
.orElse(null);
}
}

View File

@@ -0,0 +1,135 @@
package com.loremind.application.licensing;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.UUID;
/**
* Orchestre la bascule de canal stable <-> beta via le sidecar `switcher`.
*
* <p>Le sidecar tourne en permanence et watch un fichier {@code command.json}
* dans un volume partage. Quand on depose une commande, il :
* <ol>
* <li>Sed la ligne IMAGE_NAMESPACE du .env</li>
* <li>Lance docker compose pull + up -d</li>
* <li>Ecrit son resultat dans {@code result.json}</li>
* </ol>
*
* <p>Le Core n'a PAS acces au socket Docker — il delegue tout au sidecar
* via fichiers, ce qui evite que la compromission du Core ne donne RCE
* sur l'hote. Le sidecar valide strictement le contenu de la commande
* (channel ∈ {stable, beta} uniquement).
*
* <p>Le canal actuel se deduit du prefixe d'image courant (recupere via
* la variable d'env {@code IMAGE_NAMESPACE} ou {@code UPDATE_CHECK_IMAGES}) :
* presence de "loremind-beta-" => canal beta, sinon stable.
*/
@Service
public class ChannelSwitcherService {
private static final Logger log = LoggerFactory.getLogger(ChannelSwitcherService.class);
public enum Channel { STABLE, BETA }
public enum SwitchStatus { IN_PROGRESS, SUCCESS, ERROR }
/** Snapshot du dernier resultat de switch ecrit par le sidecar. */
@JsonInclude(JsonInclude.Include.NON_NULL)
public record SwitchResult(
String id,
SwitchStatus status,
Channel channel,
String message,
Instant completedAt) {}
private final Path switcherDataPath;
private final String imageNamespace;
private final ObjectMapper json = new ObjectMapper();
public ChannelSwitcherService(
@Value("${SWITCHER_DATA_PATH:/shared/switcher}") String switcherDataPath,
// On lit IMAGE_NAMESPACE en priorite, puis UPDATE_CHECK_IMAGES en fallback
// (la deuxieme est toujours injectee par compose, contrairement a la premiere
// qui peut etre absente dans les .env legacy).
@Value("${IMAGE_NAMESPACE:${UPDATE_CHECK_IMAGES:}}") String imageNamespaceRaw) {
this.switcherDataPath = Path.of(switcherDataPath);
this.imageNamespace = imageNamespaceRaw != null ? imageNamespaceRaw : "";
log.info("ChannelSwitcherService initialized: dataPath={} imageNamespace={}",
switcherDataPath, this.imageNamespace);
}
/**
* Detection du canal courant a partir du prefixe d'image charge au demarrage.
* Pas de magie : si le namespace contient "beta-" on est en beta, sinon stable.
*/
public Channel getCurrentChannel() {
return imageNamespace.contains("loremind-beta-") ? Channel.BETA : Channel.STABLE;
}
/**
* Indique si le sidecar est disponible (volume partage accessible).
* Si non, on degrade en lecture seule (l'UI affichera l'ancien message
* avec instructions manuelles).
*/
public boolean isSwitcherAvailable() {
return Files.isDirectory(switcherDataPath) && Files.isWritable(switcherDataPath);
}
/**
* Depose une commande de switch dans le volume partage. Renvoie l'ID
* de la commande, que le client peut utiliser pour poller le status.
*
* @throws IllegalStateException si le sidecar n'est pas disponible
* @throws IOException si l'ecriture du fichier echoue
*/
public String requestSwitch(Channel target) throws IOException {
if (!isSwitcherAvailable()) {
throw new IllegalStateException("Switcher sidecar not available (volume mount missing)");
}
String id = UUID.randomUUID().toString();
Map<String, Object> command = new LinkedHashMap<>();
command.put("id", id);
command.put("channel", target.name().toLowerCase());
command.put("requestedAt", Instant.now().toString());
Path commandFile = switcherDataPath.resolve("command.json");
Path tmp = Files.createTempFile(switcherDataPath, "command-", ".tmp");
try {
json.writerWithDefaultPrettyPrinter().writeValue(tmp.toFile(), command);
// Atomic move : evite que le sidecar lise un fichier partiellement ecrit.
Files.move(tmp, commandFile, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
} finally {
// Cleanup au cas ou move aurait echoue avant le rename.
Files.deleteIfExists(tmp);
}
log.info("Switch command written: id={} channel={}", id, target);
return id;
}
/**
* Lit le dernier resultat ecrit par le sidecar, s'il existe.
* Renvoie null si aucun switch n'a encore ete tente sur cette instance.
*/
public SwitchResult getLastResult() {
Path resultFile = switcherDataPath.resolve("result.json");
if (!Files.exists(resultFile)) return null;
try {
return json.readValue(resultFile.toFile(), SwitchResult.class);
} catch (IOException e) {
log.warn("Cannot parse switcher result.json: {}", e.getMessage());
return null;
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -1,7 +1,7 @@
package com.loremind.application.lorecontext; package com.loremind.application.lorecontext;
import com.loremind.domain.lorecontext.Template; import com.loremind.domain.lorecontext.Template;
import com.loremind.domain.lorecontext.TemplateField; import com.loremind.domain.shared.template.TemplateField;
import com.loremind.domain.lorecontext.ports.TemplateRepository; import com.loremind.domain.lorecontext.ports.TemplateRepository;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;

View File

@@ -0,0 +1,110 @@
package com.loremind.application.playcontext;
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.campaigncontext.ports.CharacterRepository;
import com.loremind.domain.playcontext.Playthrough;
import com.loremind.domain.playcontext.Session;
import com.loremind.domain.playcontext.ports.PlaythroughFlagRepository;
import com.loremind.domain.playcontext.ports.PlaythroughRepository;
import com.loremind.domain.playcontext.ports.QuestProgressionRepository;
import com.loremind.domain.playcontext.ports.SessionRepository;
import org.springframework.beans.BeanUtils;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Optional;
/**
* Service applicatif pour le cycle de vie d'un Playthrough (Partie).
*
* <p>Cascade de suppression : un Playthrough supprime ses flags, ses progressions,
* ses PJ et ses sessions (avec leurs entrées de journal).</p>
*/
@Service
public class PlaythroughService {
private final PlaythroughRepository playthroughRepository;
private final CampaignRepository campaignRepository;
private final PlaythroughFlagRepository flagRepository;
private final QuestProgressionRepository progressionRepository;
private final CharacterRepository characterRepository;
private final SessionRepository sessionRepository;
private final SessionService sessionService;
public PlaythroughService(PlaythroughRepository playthroughRepository,
CampaignRepository campaignRepository,
PlaythroughFlagRepository flagRepository,
QuestProgressionRepository progressionRepository,
CharacterRepository characterRepository,
SessionRepository sessionRepository,
SessionService sessionService) {
this.playthroughRepository = playthroughRepository;
this.campaignRepository = campaignRepository;
this.flagRepository = flagRepository;
this.progressionRepository = progressionRepository;
this.characterRepository = characterRepository;
this.sessionRepository = sessionRepository;
this.sessionService = sessionService;
}
/** Compte des entités qui seront supprimées en cascade avec la Partie. */
public record DeletionImpact(int sessions, int characters, int flags, int progressions) {}
public Playthrough create(String campaignId, String name, String description) {
if (campaignId == null || campaignId.isBlank()) {
throw new IllegalArgumentException("campaignId requis.");
}
if (!campaignRepository.existsById(campaignId)) {
throw new IllegalArgumentException("Campagne introuvable : " + campaignId);
}
Playthrough p = Playthrough.builder()
.campaignId(campaignId)
.name((name == null || name.isBlank()) ? "Partie principale" : name.trim())
.description(description)
.build();
return playthroughRepository.save(p);
}
public Playthrough update(String id, Playthrough updated) {
Playthrough existing = playthroughRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Partie introuvable : " + id));
BeanUtils.copyProperties(updated, existing, "id", "campaignId", "createdAt");
return playthroughRepository.save(existing);
}
public Optional<Playthrough> getById(String id) {
return playthroughRepository.findById(id);
}
public List<Playthrough> getByCampaignId(String campaignId) {
return playthroughRepository.findByCampaignId(campaignId);
}
public DeletionImpact getDeletionImpact(String id) {
int sessions = sessionRepository.findByPlaythroughId(id).size();
int characters = characterRepository.findByPlaythroughId(id).size();
int flags = flagRepository.findByPlaythroughId(id).size();
int progressions = progressionRepository.findByPlaythroughId(id).size();
return new DeletionImpact(sessions, characters, flags, progressions);
}
@Transactional
public void delete(String id) {
if (!playthroughRepository.existsById(id)) {
throw new IllegalArgumentException("Partie introuvable : " + id);
}
// Cascade : sessions (et leurs entries), PJ, flags, progressions
for (Session s : sessionRepository.findByPlaythroughId(id)) {
sessionService.deleteSession(s.getId());
}
characterRepository.findByPlaythroughId(id).forEach(c -> characterRepository.deleteById(c.getId()));
flagRepository.deleteAllByPlaythroughId(id);
progressionRepository.deleteAllByPlaythroughId(id);
playthroughRepository.deleteById(id);
}
public boolean exists(String id) {
return playthroughRepository.existsById(id);
}
}

View File

@@ -0,0 +1,81 @@
package com.loremind.application.playcontext;
import com.loremind.domain.playcontext.EntryType;
import com.loremind.domain.playcontext.SessionEntry;
import com.loremind.domain.playcontext.ports.SessionEntryRepository;
import com.loremind.domain.playcontext.ports.SessionRepository;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
/**
* Service d'application pour le journal d'une Session.
* Gère le cycle CRUD des entrées (note, évènement, jet, action joueur).
*/
@Service
public class SessionEntryService {
private final SessionEntryRepository entryRepository;
private final SessionRepository sessionRepository;
public SessionEntryService(SessionEntryRepository entryRepository,
SessionRepository sessionRepository) {
this.entryRepository = entryRepository;
this.sessionRepository = sessionRepository;
}
/** Données fournies par l'API pour créer ou éditer une entrée. */
public record EntryData(EntryType type, String content, LocalDateTime occurredAt) {}
public SessionEntry createEntry(String sessionId, EntryData data) {
if (sessionId == null || sessionId.isBlank()) {
throw new IllegalArgumentException("sessionId est requis.");
}
if (!sessionRepository.existsById(sessionId)) {
throw new IllegalArgumentException("Session introuvable : " + sessionId);
}
validateContent(data.content());
LocalDateTime now = LocalDateTime.now();
SessionEntry entry = SessionEntry.builder()
.sessionId(sessionId)
.type(data.type() != null ? data.type() : EntryType.NOTE)
.content(data.content().trim())
.occurredAt(data.occurredAt() != null ? data.occurredAt() : now)
.build();
return entryRepository.save(entry);
}
public SessionEntry updateEntry(String id, EntryData data) {
validateContent(data.content());
SessionEntry existing = entryRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Entrée introuvable : " + id));
if (data.type() != null) existing.setType(data.type());
existing.setContent(data.content().trim());
if (data.occurredAt() != null) existing.setOccurredAt(data.occurredAt());
return entryRepository.save(existing);
}
public Optional<SessionEntry> getById(String id) {
return entryRepository.findById(id);
}
public List<SessionEntry> getBySessionId(String sessionId) {
return entryRepository.findBySessionId(sessionId);
}
public void deleteEntry(String id) {
if (!entryRepository.existsById(id)) {
throw new IllegalArgumentException("Entrée introuvable : " + id);
}
entryRepository.deleteById(id);
}
private void validateContent(String content) {
if (content == null || content.isBlank()) {
throw new IllegalArgumentException("Le contenu d'une entrée ne peut pas être vide.");
}
}
}

View File

@@ -0,0 +1,118 @@
package com.loremind.application.playcontext;
import com.loremind.domain.playcontext.Session;
import com.loremind.domain.playcontext.ports.PlaythroughRepository;
import com.loremind.domain.playcontext.ports.SessionEntryRepository;
import com.loremind.domain.playcontext.ports.SessionRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Optional;
/**
* Service d'application pour le Play Context.
* Orchestre le cycle de vie d'une Session (lancement, fin, renommage).
*
* <p>Règle métier : une seule Session peut être active (endedAt null) à la fois.</p>
* <p>Depuis Playthrough : une Session appartient à un Playthrough (pas directement à une Campaign).</p>
*/
@Service
public class SessionService {
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd");
private final SessionRepository sessionRepository;
private final SessionEntryRepository entryRepository;
private final PlaythroughRepository playthroughRepository;
public SessionService(SessionRepository sessionRepository,
SessionEntryRepository entryRepository,
PlaythroughRepository playthroughRepository) {
this.sessionRepository = sessionRepository;
this.entryRepository = entryRepository;
this.playthroughRepository = playthroughRepository;
}
/**
* Lance une nouvelle session sur le Playthrough donné.
* Échoue si une session est déjà active ou si le Playthrough n'existe pas.
*/
public Session startSession(String playthroughId) {
if (playthroughId == null || playthroughId.isBlank()) {
throw new IllegalArgumentException("playthroughId est requis pour démarrer une session.");
}
if (!playthroughRepository.existsById(playthroughId)) {
throw new IllegalArgumentException("Partie introuvable : " + playthroughId);
}
// Règle métier : une seule session active par Partie (pas de verrou global cross-Partie).
sessionRepository.findActiveByPlaythroughId(playthroughId).ifPresent(s -> {
throw new IllegalStateException(
"Une session est déjà en cours pour cette Partie (id=" + s.getId() +
"). Termine-la avant d'en lancer une nouvelle.");
});
LocalDateTime now = LocalDateTime.now();
Session session = Session.builder()
.name(generateDefaultName(now))
.playthroughId(playthroughId)
.startedAt(now)
.build();
return sessionRepository.save(session);
}
public Session endSession(String id) {
Session session = sessionRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Session introuvable : " + id));
if (!session.isActive()) {
throw new IllegalStateException("Cette session est déjà terminée.");
}
session.setEndedAt(LocalDateTime.now());
return sessionRepository.save(session);
}
public Session renameSession(String id, String newName) {
if (newName == null || newName.isBlank()) {
throw new IllegalArgumentException("Le nom de la session ne peut pas être vide.");
}
Session session = sessionRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Session introuvable : " + id));
session.setName(newName.trim());
return sessionRepository.save(session);
}
public Optional<Session> getById(String id) {
return sessionRepository.findById(id);
}
public Optional<Session> getActive() {
return sessionRepository.findActive();
}
public Optional<Session> getActiveByPlaythrough(String playthroughId) {
return sessionRepository.findActiveByPlaythroughId(playthroughId);
}
public List<Session> getAll() {
return sessionRepository.findAll();
}
public List<Session> getByPlaythroughId(String playthroughId) {
return sessionRepository.findByPlaythroughId(playthroughId);
}
@Transactional
public void deleteSession(String id) {
if (!sessionRepository.existsById(id)) {
throw new IllegalArgumentException("Session introuvable : " + id);
}
entryRepository.deleteBySessionId(id);
sessionRepository.deleteById(id);
}
private String generateDefaultName(LocalDateTime startedAt) {
return "Session du " + startedAt.format(DATE_FORMATTER);
}
}

View File

@@ -21,6 +21,17 @@ public class Arc {
private String campaignId; // Référence vers la Campaign parente private String campaignId; // Référence vers la Campaign parente
private int order; // Ordre de l'arc dans la campagne private int order; // Ordre de l'arc dans la campagne
/**
* Type structurel de l'arc. Détermine son rendu UI et la sémantique de ses chapitres
* (séquence narrative LINEAR vs. quêtes parallèles d'un HUB).
* Défaut LINEAR pour rétro-compatibilité avec les arcs existants.
*/
@Builder.Default
private ArcType type = ArcType.LINEAR;
/** Cle d'icone choisie par l'utilisateur (cf. CAMPAIGN_ICON_OPTIONS cote front). */
private String icon;
// Champs narratifs enrichis (voir docs/maquettes/campagne/détail/) // Champs narratifs enrichis (voir docs/maquettes/campagne/détail/)
private String themes; // Thèmes principaux explorés dans cet arc private String themes; // Thèmes principaux explorés dans cet arc
private String stakes; // Enjeux globaux pour les personnages private String stakes; // Enjeux globaux pour les personnages

View File

@@ -0,0 +1,14 @@
package com.loremind.domain.campaigncontext;
/**
* Type structurel d'un Arc.
* - LINEAR : narration séquentielle classique (chapitres joués dans l'ordre).
* - HUB : narration non linéaire ; les chapitres sont des "quêtes" satellites
* potentiellement parallèles, soumises à des prérequis pour être débloquées.
*
* Value Object du domaine (Bounded Context : Campaign).
*/
public enum ArcType {
LINEAR,
HUB
}

View File

@@ -6,8 +6,12 @@ import java.time.LocalDateTime;
/** /**
* Entité de domaine représentant une Campaign. * Entité de domaine représentant une Campaign.
* Conteneur global pour organiser la narration d'une campagne. * Conteneur du SCÉNARIO (générique, ré-utilisable par plusieurs tables).
* Entité pure du domaine, sans dépendance technique. *
* <p>Toute donnée dynamique propre à une table jouée (progression des quêtes,
* flags narratifs, sessions, PJ) vit dans un {@link com.loremind.domain.playcontext.Playthrough}.</p>
*
* <p>Entité pure du domaine, sans dépendance technique.</p>
*/ */
@Data @Data
@Builder @Builder
@@ -21,17 +25,13 @@ public class Campaign {
private int arcsCount; private int arcsCount;
/** /**
* Référence faible (weak reference) vers un Lore. * Référence faible vers un Lore. Nullable.
* Nullable : une campagne peut exister sans univers associé (one-shot, test, pitch libre). * Ce n'est qu'un ID : le Campaign Context ne dépend PAS du Lore Context.
* Ce n'est qu'un ID : le Campaign Context ne dépend PAS du Lore Context
* (respect des Bounded Contexts en DDD).
*/ */
private String loreId; private String loreId;
/** /**
* Référence faible (weak reference) vers un GameSystem. * Référence faible vers un GameSystem. Nullable.
* 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; private String gameSystemId;

View File

@@ -0,0 +1,18 @@
package com.loremind.domain.campaigncontext;
/**
* Évènement d'avancement émis pendant l'import streamé d'un PDF de campagne.
* <p>
* {@code total} = nombre de morceaux à traiter (0 pendant l'extraction).
* {@code current} = morceaux traités. Les compteurs arc/chapitre/scène donnent
* un aperçu de l'arbre trouvé jusqu'ici (affichage « au fil de l'eau »).
*/
public record CampaignImportProgress(
int current,
int total,
int pageCount,
int ocrPageCount,
int arcCount,
int chapterCount,
int sceneCount) {
}

View File

@@ -0,0 +1,40 @@
package com.loremind.domain.campaigncontext;
import java.util.List;
/**
* Proposition d'arborescence narrative extraite d'un PDF de campagne.
* <p>
* PROPOSITION non persistée : l'UI laisse l'utilisateur réviser/éditer l'arbre
* avant la création effective des arcs/chapitres/scènes. Records purs (domaine).
*/
public record CampaignImportProposal(List<ArcProposal> arcs) {
/**
* {@code existingId} (nullable) : si présent, le nœud existe DÉJÀ dans la
* campagne (rempli côté UI lors de la revue pré-chargée) → l'apply ne le
* recrée pas, il l'utilise comme parent des nouveaux enfants. Null = à créer.
*/
/** {@code type} = "LINEAR" ou "HUB" (mappé sur {@link ArcType} à l'apply). */
public record ArcProposal(
String name, String description, String type,
List<ChapterProposal> chapters, String existingId) {
}
public record ChapterProposal(
String name, String description, List<SceneProposal> scenes, String existingId) {
}
/**
* {@code rooms} non vide => lieu explorable (donjon). {@code playerNarration}
* = encadré « à lire aux joueurs », {@code gmNotes} = secrets/développement MJ.
*/
public record SceneProposal(
String name, String description, String playerNarration, String gmNotes,
List<RoomProposal> rooms, String existingId) {
}
public record RoomProposal(String name, String description, String enemies, String loot) {
}
}

View File

@@ -21,6 +21,17 @@ public class Chapter {
private String arcId; // Référence vers l'Arc parent private String arcId; // Référence vers l'Arc parent
private int order; // Ordre du chapitre dans l'arc private int order; // Ordre du chapitre dans l'arc
/**
* Conditions de déblocage (combinées en ET). Vide => quête immédiatement AVAILABLE.
* Pertinent surtout pour les chapitres d'un Arc HUB ; ignoré pour LINEAR.
* Donnée de SCÉNARIO — partagée par toutes les Parties de la campagne.
*/
@Builder.Default
private List<Prerequisite> prerequisites = new ArrayList<>();
/** Cle d'icone choisie par l'utilisateur (cf. CAMPAIGN_ICON_OPTIONS cote front). */
private String icon;
// Champs narratifs enrichis (voir docs/maquettes/campagne/détail/) // Champs narratifs enrichis (voir docs/maquettes/campagne/détail/)
private String gmNotes; // Notes privées du MJ (non exportées vers FoundryVTT) private String gmNotes; // Notes privées du MJ (non exportées vers FoundryVTT)
private String playerObjectives; // Objectifs des joueurs dans ce chapitre private String playerObjectives; // Objectifs des joueurs dans ce chapitre

View File

@@ -4,17 +4,26 @@ import lombok.Builder;
import lombok.Data; import lombok.Data;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/** /**
* Fiche de personnage joueur (PJ) d'une campagne. * Fiche de personnage joueur (PJ) d'une campagne.
* <p> * <p>
* MVP : contenu markdown libre, l'utilisateur met ce qu'il veut (stats, * Champs universels hard-codes : {@code name}, {@code portraitImageId},
* backstory, équipement). Évolution prévue vers un système templaté par * {@code headerImageId}. Tout le reste est piloté par le template PJ du
* GameSystem (la fiche Nimble n'a pas les mêmes champs qu'une fiche D&D). * GameSystem associé à la campagne (cf. {@link com.loremind.domain.gamesystemcontext.GameSystem#getCharacterTemplate}).
* <p> * <p>
* Scope strict PJ : les PNJ restent dans le Lore (pages templatées) ou * Les valeurs des champs templates sont stockées dans deux maps :
* dans les scènes elles-mêmes. Si le besoin de PNJ spécifiques à une * - {@code values} : champs TEXT et NUMBER (numérique sérialisé en string,
* campagne remonte, on étendra l'entité (ex: type enum PJ/PNJ). * parsé à l'usage cote presentation)
* - {@code imageValues} : champs IMAGE (liste ordonnée d'IDs d'images par champ)
* <p>
* Le champ historique {@code markdownContent} a été supprimé (refonte 2026-04-30).
* Le contenu pre-existant est migré dans {@code values["Notes"]} par défaut.
* <p>
* Scope strict PJ : les PNJ sont gérés par l'entité {@link Npc} (invariants divergents).
*/ */
@Data @Data
@Builder @Builder
@@ -23,18 +32,59 @@ public class Character {
private String id; private String id;
private String name; private String name;
/** ID de l'image portrait (champ universel hard-codé). Nullable. */
private String portraitImageId;
/** ID de l'image header/banniere (champ universel hard-codé). Nullable. */
private String headerImageId;
/** /**
* Contenu libre en markdown — stats + backstory + notes. Nullable à la création, * Valeurs des champs TEXT et NUMBER du template PJ. Cle = nom du champ
* renseigné progressivement par le MJ. * (sensible a la casse cote stockage mais comparaison case-insensitive
* dans le domaine GameSystem). Jamais null apres construction.
*/ */
private String markdownContent; private Map<String, String> values;
/** Référence vers la Campaign parente. */ /**
private String campaignId; * Valeurs des champs IMAGE du template PJ. Cle = nom du champ, valeur =
* liste ordonnee d'IDs d'images. Jamais null apres construction.
*/
private Map<String, List<String>> imageValues;
/** Ordre d'affichage dans la liste des PJ de la campagne. */ /**
* Valeurs des champs KEY_VALUE_LIST du template PJ. Cle externe = nom du
* champ template (ex: "Caracteristiques"), cle interne = label predefini
* dans le template (ex: "FOR"), valeur = valeur saisie (ex: "16").
* Les labels suivent l'ordre defini dans TemplateField.labels.
*/
private Map<String, Map<String, String>> keyValueValues;
/**
* Référence vers le Playthrough (= la partie / table) auquel ce PJ appartient.
* Les PJ sont propres à une table jouée, pas au scénario générique de la campagne.
* Weak reference cross-context.
*/
private String playthroughId;
/** Ordre d'affichage dans la liste des PJ de la Partie. */
private int order; private int order;
private LocalDateTime createdAt; private LocalDateTime createdAt;
private LocalDateTime updatedAt; private LocalDateTime updatedAt;
/** Garantit que les maps ne sont jamais null cote consommateur. */
public Map<String, String> getValues() {
if (values == null) values = new HashMap<>();
return values;
}
public Map<String, List<String>> getImageValues() {
if (imageValues == null) imageValues = new HashMap<>();
return imageValues;
}
public Map<String, Map<String, String>> getKeyValueValues() {
if (keyValueValues == null) keyValueValues = new HashMap<>();
return keyValueValues;
}
} }

View File

@@ -0,0 +1,23 @@
package com.loremind.domain.campaigncontext;
import lombok.Builder;
import lombok.Data;
import java.time.LocalDateTime;
/**
* Atelier d'adaptation (« notebook ») d'une campagne : une ou plusieurs sources
* PDF indexées (RAG) + une conversation, persistés pour y revenir.
* <p>
* Les SOURCES ({@link NotebookSource}) et les MESSAGES ({@link NotebookMessage})
* sont gérés comme entités liées par {@code notebookId} (chargées séparément).
*/
@Data
@Builder
public class Notebook {
private String id;
private String name;
private String campaignId;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}

View File

@@ -0,0 +1,20 @@
package com.loremind.domain.campaigncontext;
import lombok.Builder;
import lombok.Data;
import java.time.LocalDateTime;
/**
* Un message de la conversation d'un {@link Notebook}. {@code role} = "user" ou
* "assistant". Persisté pour recharger l'historique de l'atelier.
*/
@Data
@Builder
public class NotebookMessage {
private String id;
private String notebookId;
private String role;
private String content;
private LocalDateTime createdAt;
}

View File

@@ -0,0 +1,24 @@
package com.loremind.domain.campaigncontext;
import lombok.Builder;
import lombok.Data;
import java.time.LocalDateTime;
/**
* Une source (PDF) d'un {@link Notebook}. Son {@code id} sert de clé d'indexation
* vectorielle côté Brain (les vecteurs vivent sur le volume du Brain).
* <p>
* {@code status} : INDEXING (en cours), READY (interrogeable), FAILED (échec).
*/
@Data
@Builder
public class NotebookSource {
private String id;
private String notebookId;
private String filename;
private String status;
private int chunkCount;
private int pageCount;
private LocalDateTime createdAt;
}

View File

@@ -0,0 +1,72 @@
package com.loremind.domain.campaigncontext;
import lombok.Builder;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* Fiche de personnage non-joueur (PNJ) d'une campagne.
* <p>
* Entité dédiée distincte de {@link Character} (DDD assumé : invariants divergents
* à terme — faction, statut vivant/mort, visibilité côté joueurs, etc.).
* <p>
* Mêmes champs universels hard-codés et meme structure de templating que Character,
* pilotée par le template PNJ du GameSystem
* ({@link com.loremind.domain.gamesystemcontext.GameSystem#getNpcTemplate}).
* <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;
/** ID de l'image portrait (champ universel hard-code). Nullable. */
private String portraitImageId;
/** ID de l'image header/banniere (champ universel hard-code). Nullable. */
private String headerImageId;
/** Valeurs TEXT/NUMBER du template PNJ. Jamais null apres construction. */
private Map<String, String> values;
/** Valeurs IMAGE du template PNJ (listes d'IDs ordonnees par champ). Jamais null. */
private Map<String, List<String>> imageValues;
/** Valeurs KEY_VALUE_LIST : fieldName -> label -> value. Jamais null. */
private Map<String, Map<String, String>> keyValueValues;
/** Référence vers la Campaign parente (cross-aggregate via ID). */
private String campaignId;
/** Dossier de classement (texte libre, ex. « Bard's Gate »). Nullable = non classé. */
private String folder;
/** Ordre d'affichage dans la liste des PNJ de la campagne. */
private int order;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
public Map<String, String> getValues() {
if (values == null) values = new HashMap<>();
return values;
}
public Map<String, List<String>> getImageValues() {
if (imageValues == null) imageValues = new HashMap<>();
return imageValues;
}
public Map<String, Map<String, String>> getKeyValueValues() {
if (keyValueValues == null) keyValueValues = new HashMap<>();
return keyValueValues;
}
}

View File

@@ -0,0 +1,26 @@
package com.loremind.domain.campaigncontext;
/**
* Condition de déblocage d'une quête (Chapter dans un Arc HUB).
*
* Sealed : la liste des types est CLOSE et connue à la compilation. Pour ajouter
* un nouveau type (ex : NpcMet), il faudra l'ajouter ici ET dans
* {@link PrerequisiteEvaluator}.
*
* Sémantique MVP : une quête a une LISTE de prérequis, tous combinés en ET logique
* (pas de OR pour le moment).
*/
public sealed interface Prerequisite
permits Prerequisite.QuestCompleted,
Prerequisite.SessionReached,
Prerequisite.FlagSet {
/** La quête référencée par {@code questId} doit être en COMPLETED. */
record QuestCompleted(String questId) implements Prerequisite {}
/** Le compteur de sessions de la campagne doit avoir atteint {@code minSessionNumber}. */
record SessionReached(int minSessionNumber) implements Prerequisite {}
/** Le flag campagne nommé {@code flagName} doit être à true. */
record FlagSet(String flagName) implements Prerequisite {}
}

View File

@@ -0,0 +1,65 @@
package com.loremind.domain.campaigncontext;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* Service de domaine (pur, sans effet de bord) : évalue les prérequis d'une quête
* et en dérive le {@link QuestStatus} effectif.
*
* NB Java 17 : on utilise instanceof pattern matching (finalisé en Java 16) plutôt que
* switch pattern matching (preview en 17, final en 21). La perte de l'exhaustivité
* compile-time est compensée par le throw final qui fait crasher tout nouvel
* implémentant non câblé.
*/
public final class PrerequisiteEvaluator {
/**
* Contexte minimal nécessaire à l'évaluation. On ne passe pas la Campaign entière
* pour ne pas créer de couplage fort ; juste les faits nécessaires.
*/
public record EvaluationContext(
Set<String> completedQuestIds,
int currentSessionCount,
Map<String, Boolean> campaignFlags
) {}
/** True si TOUS les prérequis sont satisfaits (ET logique). Vide => true. */
public boolean areAllSatisfied(List<Prerequisite> prerequisites, EvaluationContext ctx) {
if (prerequisites == null || prerequisites.isEmpty()) return true;
return prerequisites.stream().allMatch(p -> isSatisfied(p, ctx));
}
/** Évalue un seul prérequis. */
public boolean isSatisfied(Prerequisite prereq, EvaluationContext ctx) {
if (prereq instanceof Prerequisite.QuestCompleted q) {
return ctx.completedQuestIds().contains(q.questId());
}
if (prereq instanceof Prerequisite.SessionReached s) {
return ctx.currentSessionCount() >= s.minSessionNumber();
}
if (prereq instanceof Prerequisite.FlagSet f) {
return Boolean.TRUE.equals(ctx.campaignFlags().get(f.flagName()));
}
throw new IllegalStateException("Prerequisite non géré : " + prereq.getClass().getName());
}
/** Dérive le statut effectif à partir de la progression manuelle + des prérequis. */
public QuestStatus computeStatus(
ProgressionStatus progression,
List<Prerequisite> prerequisites,
EvaluationContext ctx
) {
switch (progression) {
case COMPLETED: return QuestStatus.COMPLETED;
case IN_PROGRESS: return QuestStatus.IN_PROGRESS;
case NOT_STARTED:
return areAllSatisfied(prerequisites, ctx)
? QuestStatus.AVAILABLE
: QuestStatus.LOCKED;
default:
throw new IllegalStateException("ProgressionStatus non géré : " + progression);
}
}
}

View File

@@ -0,0 +1,17 @@
package com.loremind.domain.campaigncontext;
/**
* Statut de progression d'une quête (= Chapter dans un Arc HUB), piloté manuellement par le MJ.
*
* NOT_STARTED : pas encore commencée. Peut être visible (AVAILABLE) ou cachée (LOCKED)
* selon les prérequis — voir {@link QuestStatus}.
* IN_PROGRESS : démarrée par le MJ via le bouton "Démarrer cette quête".
* COMPLETED : marquée terminée par le MJ.
*
* NB : un Chapter d'Arc LINEAR conserve NOT_STARTED par défaut sans impact visible.
*/
public enum ProgressionStatus {
NOT_STARTED,
IN_PROGRESS,
COMPLETED
}

View File

@@ -0,0 +1,19 @@
package com.loremind.domain.campaigncontext;
/**
* Statut effectif d'une quête tel qu'affiché dans la vue Hub.
* DÉRIVÉ — jamais persisté. Calculé par {@link PrerequisiteEvaluator} à partir
* de la {@link ProgressionStatus} persistée et de l'évaluation des prérequis.
*
* Table de vérité :
* NOT_STARTED + prérequis non remplis -> LOCKED
* NOT_STARTED + prérequis remplis -> AVAILABLE
* IN_PROGRESS -> IN_PROGRESS
* COMPLETED -> COMPLETED
*/
public enum QuestStatus {
LOCKED,
AVAILABLE,
IN_PROGRESS,
COMPLETED
}

View File

@@ -0,0 +1,50 @@
package com.loremind.domain.campaigncontext;
import lombok.Builder;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
/**
* Table aléatoire d'une campagne : on jette un dé ({@code diceFormula}) et la
* valeur tombée désigne une {@link RandomTableEntry} (par sa plage) → un résultat.
* <p>
* Outil MJ classique (rencontres, butin, complications, noms…). Le JET lui-même
* est effectué côté client (instantané, comme le panneau de dés) ; le domaine ne
* fait que stocker la table et ses entrées. Scope campagne (cross-aggregate via ID).
*/
@Data
@Builder
public class RandomTable {
private String id;
private String name;
/** Description libre (à quoi sert la table). Nullable. */
private String description;
/** Formule du dé à lancer : "1d20", "2d6", "d100"… */
private String diceFormula;
/** Clé d'icône (lucide) pour la sidebar/fiche. Nullable. */
private String icon;
/** Référence vers la Campaign parente (cross-aggregate via ID). */
private String campaignId;
/** Ordre d'affichage dans la liste des tables de la campagne. */
private int order;
/** Entrées ordonnées (par plage de jet). Jamais null après construction. */
private List<RandomTableEntry> entries;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
public List<RandomTableEntry> getEntries() {
if (entries == null) entries = new ArrayList<>();
return entries;
}
}

View File

@@ -0,0 +1,29 @@
package com.loremind.domain.campaigncontext;
import lombok.Builder;
import lombok.Data;
/**
* Une entrée d'une {@link RandomTable} : une PLAGE de jet (minRoll..maxRoll, bornes
* incluses) qui mappe vers un résultat. Les plages permettent les tables PONDÉRÉES
* (un résultat couvrant 110 est plus probable qu'un couvrant 1112).
* <p>
* Value object possédé par la table (pas d'identité propre côté domaine) : à chaque
* mise à jour, les entrées sont remplacées en bloc.
*/
@Data
@Builder
public class RandomTableEntry {
/** Borne basse du jet (incluse). */
private int minRoll;
/** Borne haute du jet (incluse). Pour une entrée unitaire, min == max. */
private int maxRoll;
/** Résultat court affiché (ex. "Embuscade de gobelins"). */
private String label;
/** Détail markdown : « ce que c'est » (effet, description). Nullable. */
private String detail;
}

View File

@@ -0,0 +1,69 @@
package com.loremind.domain.campaigncontext;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.ArrayList;
import java.util.List;
/**
* Pièce d'un lieu explorable (donjon, crypte…) attaché à une Scene.
*
* <p>Une Scene devient « explorable » dès qu'elle a au moins une Room. Tant
* qu'elle n'en a pas, elle se comporte comme un beat narratif classique.</p>
*
* <p>Pas un record Java parce que la liste {@code branches} est mutable côté
* builder ; on garde la classe Lombok pour la cohérence avec le reste du
* domaine (Arc, Chapter, Scene). L'ID est généré côté front (UUID) au moment
* de la création — pas d'auto-increment DB puisque c'est un Value Object
* sérialisé en JSONB sur Scene.</p>
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Room {
/** ID stable (UUID généré côté client). Sert de cible aux {@link RoomBranch}. */
private String id;
/** Nom de la pièce (« Antichambre », « Salle du trône »). */
private String name;
/** Narration / description lue ou résumée aux joueurs en entrant. */
private String description;
/** Énemis, créatures, boss éventuels (markdown libre). */
private String enemies;
/** Loot / récompenses présentes dans la pièce. */
private String loot;
/** Pièges / dangers environnementaux. */
private String traps;
/** Notes privées du MJ (cachées des joueurs). */
private String gmNotes;
/** Étage / niveau de la pièce. 0 = rez-de-chaussée. Nullable = pas d'étage défini. */
private Integer floor;
/** Ordre d'affichage dans la liste (au sein d'un même étage le cas échéant). */
private int order;
/** IDs d'images d'illustration / ambiance. */
@Builder.Default
private List<String> illustrationImageIds = new ArrayList<>();
/** ID de l'image « plan » de la pièce (1 image dédiée, schéma tactique). */
private String mapImageId;
/**
* Sorties vers d'autres pièces. {@link RoomBranch#targetRoomId()} doit pointer
* vers une Room de la même Scene.
*/
@Builder.Default
private List<RoomBranch> branches = new ArrayList<>();
}

View File

@@ -0,0 +1,22 @@
package com.loremind.domain.campaigncontext;
/**
* Sortie d'une pièce vers une autre pièce de la même Scene explorable.
* Équivalent inter-pièces de {@link SceneBranch}.
*
* <p>Record Java immuable, sérialisé via Jackson dans la liste JSONB
* {@code rooms} de la Scene.</p>
*
* <p>Règle métier : {@code targetRoomId} doit pointer vers une Room de la
* MÊME Scene (validation côté service).</p>
*
* @param label Libellé visible (« Porte nord », « Trappe au sol »).
* @param targetRoomId ID stable de la Room de destination (UUID Room.id).
* @param condition Condition optionnelle (« si les PJ ont la clé en argent »).
*/
public record RoomBranch(String label, String targetRoomId, String condition) {
public static RoomBranch of(String label, String targetRoomId) {
return new RoomBranch(label, targetRoomId, null);
}
}

View File

@@ -21,6 +21,9 @@ public class Scene {
private String chapterId; // Référence vers le Chapter parent private String chapterId; // Référence vers le Chapter parent
private int order; // Ordre de la scène dans le chapitre private int order; // Ordre de la scène dans le chapitre
/** Cle d'icone choisie par l'utilisateur (cf. CAMPAIGN_ICON_OPTIONS cote front). */
private String icon;
// === Contexte et ambiance === // === Contexte et ambiance ===
private String location; // Lieu de la scène (ex: Taverne du Dragon d'Or) private String location; // Lieu de la scène (ex: Taverne du Dragon d'Or)
private String timing; // Moment (ex: Soir, à la tombée de la nuit) private String timing; // Moment (ex: Soir, à la tombée de la nuit)
@@ -69,6 +72,15 @@ public class Scene {
@Builder.Default @Builder.Default
private List<SceneBranch> branches = new ArrayList<>(); private List<SceneBranch> branches = new ArrayList<>();
/**
* Pièces du lieu explorable représenté par cette scène (donjon, crypte, manoir…).
* Vide => scène classique « beat narratif » (comportement inchangé).
* Non vide => la scène devient explorable, l'UI affiche un layout dédié pièce-par-pièce.
* Sérialisé en JSONB.
*/
@Builder.Default
private List<Room> rooms = new ArrayList<>();
private LocalDateTime createdAt; private LocalDateTime createdAt;
private LocalDateTime updatedAt; private LocalDateTime updatedAt;
} }

View File

@@ -1,31 +1,25 @@
package com.loremind.domain.campaigncontext; package com.loremind.domain.campaigncontext;
import lombok.Builder;
import lombok.Value;
import lombok.extern.jackson.Jacksonized;
/** /**
* Value Object représentant une "sortie" narrative depuis une Scene. * Value Object représentant une "sortie" narrative depuis une Scene.
* Décrit un choix offert aux joueurs et la scène de destination associée. * Décrit un choix offert aux joueurs et la scène de destination associée.
* <p> * <p>
* Immuable (@Value) : pour "modifier" une branche on la remplace. * Record Java : immuable par construction, sans aucune dépendance technique
* @Jacksonized : permet à Jackson (sérialisation JSON via le converter JPA) * (pas de Lombok, pas de Jackson). Jackson 2.12+ sait sérialiser/désérialiser
* de reconstruire l'objet en passant par le builder malgré l'absence de setters. * les records nativement via le constructeur canonique — c'est ce dont
* dépend le SceneBranchListJsonConverter pour le stockage JSONB.
* <p> * <p>
* Règle métier : targetSceneId DOIT pointer vers une Scene du MÊME Chapter * Règle métier : targetSceneId DOIT pointer vers une Scene du MÊME Chapter
* (validation portée par SceneService). * (validation portée par SceneService).
*
* @param label Libellé du choix (ex: "Si les joueurs attaquent le garde").
* @param targetSceneId Id de la Scene de destination, intra-chapitre uniquement.
* @param condition Notes MJ privées sur la condition de déclenchement (optionnel).
*/ */
@Value public record SceneBranch(String label, String targetSceneId, String condition) {
@Builder
@Jacksonized
public class SceneBranch {
/** Libellé du choix (ex: "Si les joueurs attaquent le garde"). */ /** Raccourci pour construire une branche sans condition (cas le plus courant). */
String label; public static SceneBranch of(String label, String targetSceneId) {
return new SceneBranch(label, targetSceneId, null);
/** Id de la Scene de destination, intra-chapitre uniquement. */ }
String targetSceneId;
/** Notes MJ privées sur la condition de déclenchement (optionnel). */
String condition;
} }

View File

@@ -0,0 +1,16 @@
package com.loremind.domain.campaigncontext.ports;
/**
* Erreur de domaine : l'import d'un PDF de campagne a échoué (PDF illisible,
* Brain injoignable, LLM en erreur...).
*/
public class CampaignImportException extends RuntimeException {
public CampaignImportException(String message) {
super(message);
}
public CampaignImportException(String message, Throwable cause) {
super(message, cause);
}
}

View File

@@ -0,0 +1,32 @@
package com.loremind.domain.campaigncontext.ports;
import java.util.function.Consumer;
/**
* Port de sortie : produit des CONSEILS d'adaptation d'un PDF à une campagne
* existante, streamés token par token. Délègue au Brain (LLM + extraction PDF).
* <p>
* Contrairement à {@link CampaignPdfImporter} (qui structure pour créer), ici la
* sortie est du texte libre (markdown) : l'utilisateur applique à la main.
*/
public interface CampaignPdfAdvisor {
/**
* @param pdfBytes contenu du PDF à adapter.
* @param filename nom d'origine (diagnostic ; peut être null).
* @param brief description de la campagne existante (structure + PNJ + lore).
* @param messagesJson JSON de l'échange conversationnel ([{role, content}, …]) ;
* "[]" au 1er tour. Permet à l'utilisateur de répondre/corriger.
* @param onToken invoqué à chaque fragment de texte généré.
* @param onComplete invoqué à la fin normale du flux.
* @param onError invoqué en cas d'échec (PDF illisible, Brain/LLM en erreur).
*/
void adviseStreaming(
byte[] pdfBytes,
String filename,
String brief,
String messagesJson,
Consumer<String> onToken,
Runnable onComplete,
Consumer<Throwable> onError);
}

View File

@@ -0,0 +1,28 @@
package com.loremind.domain.campaigncontext.ports;
import com.loremind.domain.campaigncontext.CampaignImportProgress;
import com.loremind.domain.campaigncontext.CampaignImportProposal;
import java.util.function.Consumer;
/**
* Port de sortie : extrait et structure un PDF de campagne en arbre
* arc → chapitre → scène. L'implémentation délègue au Brain Python.
*/
public interface CampaignPdfImporter {
/**
* Variante streamée : l'import peut durer plusieurs minutes, on remonte
* l'avancement au fil de l'eau, puis la proposition finale.
*
* @param onProgress invoqué à chaque étape (extraction, puis par morceau).
* @param onDone invoqué une fois avec l'arbre proposé (non persisté).
* @param onError invoqué si l'extraction/structuration échoue.
*/
void importCampaignStreaming(
byte[] pdfBytes,
String filename,
Consumer<CampaignImportProgress> onProgress,
Consumer<CampaignImportProposal> onDone,
Consumer<Throwable> onError);
}

View File

@@ -14,7 +14,7 @@ public interface CharacterRepository {
Optional<Character> findById(String id); Optional<Character> findById(String id);
List<Character> findByCampaignId(String campaignId); List<Character> findByPlaythroughId(String playthroughId);
void deleteById(String id); void deleteById(String id);

View File

@@ -0,0 +1,36 @@
package com.loremind.domain.campaigncontext.ports;
import java.util.List;
import java.util.function.Consumer;
/**
* Port de sortie : chat ANCRÉ (RAG) sur les sources d'un notebook, streamé.
* Le Brain récupère les passages pertinents puis streame la réponse token par token.
*/
public interface NotebookChatStreamer {
/** Un message de la conversation transmis au Brain. */
record Msg(String role, String content) {}
/** Avancement de l'analyse approfondie (lecture du document par lots). */
record Progress(int current, int total) {}
/**
* Streame la réponse ancrée sur les sources. Les callbacks sont invoqués au fil
* de l'eau : {@code onToken} par fragment, {@code onProgress} (mode approfondi
* uniquement) pendant la lecture du document, {@code onDone} à la fin,
* {@code onError} en cas d'échec.
*
* @param deep true = analyse approfondie (map-reduce sur tout le document) ;
* false = chat RAG (top-k).
*/
void stream(
List<String> sourceIds,
List<Msg> messages,
String context,
boolean deep,
Consumer<String> onToken,
Consumer<Progress> onProgress,
Runnable onDone,
Consumer<Throwable> onError);
}

View File

@@ -0,0 +1,15 @@
package com.loremind.domain.campaigncontext.ports;
/**
* Échec d'indexation/chat d'un notebook (Brain injoignable, erreur du modèle…).
* Mappée en HTTP 502 par le contrôleur.
*/
public class NotebookException extends RuntimeException {
public NotebookException(String message) {
super(message);
}
public NotebookException(String message, Throwable cause) {
super(message, cause);
}
}

View File

@@ -0,0 +1,17 @@
package com.loremind.domain.campaigncontext.ports;
/**
* Port de sortie : indexation RAG d'une source de notebook (déléguée au Brain).
* Les vecteurs vivent côté Brain, keyés par {@code sourceId}.
*/
public interface NotebookIndexer {
/** Récapitulatif d'indexation renvoyé par le Brain. */
record IndexResult(int chunks, int pageCount, int ocrPageCount) {}
/** Indexe une source (extraction + embeddings + stockage vectoriel). */
IndexResult index(String sourceId, byte[] pdfBytes, String filename);
/** Supprime les vecteurs d'une source (au DELETE d'une source/notebook). */
void delete(String sourceId);
}

View File

@@ -0,0 +1,32 @@
package com.loremind.domain.campaigncontext.ports;
import com.loremind.domain.campaigncontext.Notebook;
import com.loremind.domain.campaigncontext.NotebookMessage;
import com.loremind.domain.campaigncontext.NotebookSource;
import java.util.List;
import java.util.Optional;
/**
* Port de sortie pour la persistance des notebooks (atelier), de leurs sources
* et de leur conversation. Port unique (3 agrégats liés) pour rester compact.
*/
public interface NotebookRepository {
// --- Notebook ---
Notebook save(Notebook notebook);
Optional<Notebook> findById(String id);
List<Notebook> findByCampaignId(String campaignId);
void deleteById(String id);
boolean existsById(String id);
// --- Sources ---
NotebookSource saveSource(NotebookSource source);
Optional<NotebookSource> findSourceById(String id);
List<NotebookSource> findSourcesByNotebookId(String notebookId);
void deleteSourceById(String id);
// --- Messages (conversation) ---
NotebookMessage saveMessage(NotebookMessage message);
List<NotebookMessage> findMessagesByNotebookId(String notebookId);
}

View File

@@ -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);
}

View File

@@ -0,0 +1,15 @@
package com.loremind.domain.campaigncontext.ports;
/**
* Échec de génération/improvisation IA d'une table (Brain injoignable, erreur du
* modèle, réponse inexploitable…). Mappée en HTTP 502 par le contrôleur.
*/
public class RandomTableGenerationException extends RuntimeException {
public RandomTableGenerationException(String message) {
super(message);
}
public RandomTableGenerationException(String message, Throwable cause) {
super(message, cause);
}
}

View File

@@ -0,0 +1,26 @@
package com.loremind.domain.campaigncontext.ports;
import com.loremind.domain.campaigncontext.RandomTableEntry;
import java.util.List;
/**
* Port de sortie : génération IA d'une table aléatoire et improvisation narrative
* sur un résultat tiré. Implémenté par un client du Brain (service IA Python).
*/
public interface RandomTableGenerator {
/** Table proposée (non persistée) à partir d'une description + formule de dé. */
record GeneratedTable(String name, String description, List<RandomTableEntry> entries) {}
/**
* Génère une proposition de table couvrant la formule de dé, sur le sujet
* donné, en s'appuyant sur le contexte (campagne, système…) s'il est fourni.
*/
GeneratedTable generate(String description, String diceFormula, String context);
/**
* Brode un court récit (2-3 phrases) sur un résultat tiré, pour lancer la scène.
*/
String improvise(String tableName, String resultLabel, String resultDetail, String context);
}

View File

@@ -0,0 +1,22 @@
package com.loremind.domain.campaigncontext.ports;
import com.loremind.domain.campaigncontext.RandomTable;
import java.util.List;
import java.util.Optional;
/**
* Port de sortie pour la persistance des {@link RandomTable}.
*/
public interface RandomTableRepository {
RandomTable save(RandomTable table);
Optional<RandomTable> findById(String id);
List<RandomTable> findByCampaignId(String campaignId);
void deleteById(String id);
boolean existsById(String id);
}

View File

@@ -1,9 +1,13 @@
package com.loremind.domain.gamesystemcontext; package com.loremind.domain.gamesystemcontext;
import com.loremind.domain.shared.template.TemplateField;
import lombok.Builder; import lombok.Builder;
import lombok.Data; import lombok.Data;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
/** /**
* Entité de domaine représentant un GameSystem (système de JDR). * Entité de domaine représentant un GameSystem (système de JDR).
@@ -12,6 +16,10 @@ import java.time.LocalDateTime;
* d'un markdown monolithique structuré par titres H2. Les sections sont extraites * 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). * à la volée lors de l'injection dans les prompts IA (cf. GameSystemContextSelector).
* <p> * <p>
* Porte aussi deux templates piloтant la structure des fiches PJ et PNJ d'une
* campagne adossée à ce système. Les fiches markdown libres ont laissé place à
* un système de champs typés (TEXT/IMAGE/NUMBER) défini ici.
* <p>
* {@code author} et {@code isPublic} sont des champs pensés pour un futur marketplace * {@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 * de rulesets partagés — non exploités au MVP mais persistés dès maintenant pour
* éviter une migration ultérieure. * éviter une migration ultérieure.
@@ -27,6 +35,21 @@ public class GameSystem {
/** Markdown monolithique. Sections découpées par titres H2 (## Combat, ## Classes, etc.). */ /** Markdown monolithique. Sections découpées par titres H2 (## Combat, ## Classes, etc.). */
private String rulesMarkdown; private String rulesMarkdown;
/**
* Template de fiche PJ : champs typés affichés pour chaque personnage joueur.
* Hors champs universels hard-codés (nom, portrait, header). Jamais null après
* persistance — un template vide est représenté par une liste vide.
*/
private List<TemplateField> characterTemplate;
/**
* Template de fiche PNJ. Mêmes règles que {@link #characterTemplate}.
* Distinct du template PJ car les invariants métier divergent (un PNJ peut
* n'avoir qu'un nom + une motivation, un PJ porte généralement une feuille
* de stats complète).
*/
private List<TemplateField> npcTemplate;
/** Auteur déclaré — futur marketplace. Nullable. */ /** Auteur déclaré — futur marketplace. Nullable. */
private String author; private String author;
@@ -35,4 +58,88 @@ public class GameSystem {
private LocalDateTime createdAt; private LocalDateTime createdAt;
private LocalDateTime updatedAt; private LocalDateTime updatedAt;
// --- Méthodes métier : templates PJ/PNJ --------------------------------
/**
* Ajoute un champ au template PJ. Refuse les doublons de nom (insensible à la casse)
* pour éviter les collisions de clés dans {@code Character.values}.
*/
public void addCharacterField(TemplateField field) {
characterTemplate = appendField(characterTemplate, field);
}
/** Pendant PNJ de {@link #addCharacterField}. */
public void addNpcField(TemplateField field) {
npcTemplate = appendField(npcTemplate, field);
}
/**
* Retire un champ du template PJ par nom (insensible à la casse).
* No-op silencieux si le champ n'existe pas — appelant n'a pas à pré-vérifier.
*/
public void removeCharacterField(String fieldName) {
characterTemplate = removeFieldByName(characterTemplate, fieldName);
}
public void removeNpcField(String fieldName) {
npcTemplate = removeFieldByName(npcTemplate, fieldName);
}
/**
* Remplace intégralement le template PJ. Utilisé pour le réordonnancement
* et l'édition en bloc côté UI. Valide l'unicité des noms.
*/
public void replaceCharacterTemplate(List<TemplateField> fields) {
characterTemplate = validateAndCopy(fields);
}
public void replaceNpcTemplate(List<TemplateField> fields) {
npcTemplate = validateAndCopy(fields);
}
// --- Helpers privés ----------------------------------------------------
private static List<TemplateField> appendField(List<TemplateField> current, TemplateField field) {
if (field == null || field.getName() == null || field.getName().isBlank()) {
throw new IllegalArgumentException("Field name is required");
}
List<TemplateField> next = current == null ? new ArrayList<>() : new ArrayList<>(current);
if (containsName(next, field.getName())) {
throw new IllegalArgumentException("Duplicate field name: " + field.getName());
}
next.add(field);
return next;
}
private static List<TemplateField> removeFieldByName(List<TemplateField> current, String fieldName) {
if (current == null || fieldName == null) return current;
List<TemplateField> next = new ArrayList<>(current);
next.removeIf(f -> equalsIgnoreCase(f.getName(), fieldName));
return next;
}
private static List<TemplateField> validateAndCopy(List<TemplateField> fields) {
if (fields == null) return new ArrayList<>();
List<TemplateField> copy = new ArrayList<>(fields.size());
for (TemplateField f : fields) {
if (f == null || f.getName() == null || f.getName().isBlank()) {
throw new IllegalArgumentException("Field name is required");
}
if (containsName(copy, f.getName())) {
throw new IllegalArgumentException("Duplicate field name: " + f.getName());
}
copy.add(f);
}
return copy;
}
private static boolean containsName(List<TemplateField> fields, String name) {
return fields.stream().anyMatch(f -> equalsIgnoreCase(f.getName(), name));
}
private static boolean equalsIgnoreCase(String a, String b) {
if (a == null || b == null) return a == b;
return a.toLowerCase(Locale.ROOT).equals(b.toLowerCase(Locale.ROOT));
}
} }

View File

@@ -0,0 +1,19 @@
package com.loremind.domain.gamesystemcontext;
import java.util.List;
/**
* Évènement d'avancement émis pendant l'import streamé d'un PDF de règles.
* <p>
* {@code total} = nombre de morceaux à traiter (0 tant que l'extraction n'est
* pas finie). {@code current} = morceaux déjà traités. {@code newSectionTitles}
* = titres de sections nouvellement trouvés/complétés par le dernier morceau
* (pour un affichage « au fil de l'eau »).
*/
public record RulesImportProgress(
int current,
int total,
int pageCount,
int ocrPageCount,
List<String> newSectionTitles) {
}

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