Compare commits

...

30 Commits

Author SHA1 Message Date
587e0fe097 passage 1.0.1-beta
All checks were successful
Build & Push Images / build (brain) (push) Successful in 1m29s
Build & Push Images / build (web) (push) Successful in 1m58s
Build & Push Images / build (core) (push) Successful in 3m39s
Build & Push Images / build-switcher (push) Successful in 20s
2026-07-06 15:25:48 +02:00
5c3c58265f Première fournée correction dette technique.
Refactoring du code pour améliorer la lisibilité de ce dernier coté core
Correction sur l'export / import : les images n'étaient pas bien liées à la campagne lors de l'export / import et provoquait un bug d'affichage des images
lorsqu'on voulait ouvrir une campagne importée.
2026-07-06 14:39:55 +02:00
f72161e41e Correction sur l'export PDF : problème d'échappement de caractères qui faisait péter un test unitaire
Some checks failed
Build & Push Images / build (core) (push) Successful in 3m45s
Build & Push Images / build (web) (push) Successful in 1m53s
E2E Tests / e2e (push) Failing after 9m2s
Build & Push Images / build-switcher (push) Successful in 42s
Tests unitaires / Core (Java · mvn test + JaCoCo) (push) Successful in 2m18s
Tests unitaires / Brain (Python · pytest + couverture) (push) Successful in 18s
Tests unitaires / Web (Angular · vitest + couverture) (push) Successful in 26s
Build & Push Images / build (brain) (push) Successful in 1m13s
2026-07-04 11:42:18 +02:00
4d05aa5118 Passage à la version 1.0.0
Some checks failed
Tests unitaires / Brain (Python · pytest + couverture) (push) Successful in 22s
Tests unitaires / Web (Angular · vitest + couverture) (push) Successful in 29s
Tests unitaires / Core (Java · mvn test + JaCoCo) (push) Failing after 2m54s
Build & Push Images / build (brain) (push) Successful in 1m21s
Build & Push Images / build (core) (push) Successful in 3m37s
Build & Push Images / build (web) (push) Successful in 2m1s
E2E Tests / e2e (push) Failing after 9m37s
Build & Push Images / build-switcher (push) Successful in 42s
2026-07-04 11:21:46 +02:00
beafbc2fa9 Livret PDF v2, graphe des liens côté campagne, et refactor de l'import
Livret PDF (export campagne) :
- refonte lisibilité façon livre de JdR : sommaire paginé, libellés de champs
  en tête de ligne, encadrés « À lire aux joueurs » / secrets MJ / combat,
  ligne de contexte des scènes, en-tête courant, tables zébrées
- reflète la fusion quête/conteneur : arc SYSTEM masqué, quêtes de hub
  fusionnées dans leur chapitre, partie « Quêtes » sans doublon
- contenu complété : sorties de scènes, ennemis liés au bestiaire, pièces
  explorables, partie « Tables aléatoires »
- polices DejaVu embarquées (couverture Unicode, licence incluse) et harness
  visuel PdfExportPreviewTest (Mockito pur, tourne sans Postgres)

Graphe des liens :
- déplacé du Lore vers la campagne (les PNJ sont des entités de campagne) ;
  endpoint /api/npcs/lore/{id} supprimé
- layout retravaillé (anti-chevauchement des libellés, éventail des feuilles
  autour des hubs), scènes et quêtes ajoutées, libellés sur 2 lignes,
  filtres de légende mémorisés, focus au survol
- positions drag & drop persistées sur la campagne (migration V23,
  PUT /api/campaigns/{id}/graph-positions, bouton « Disposition auto »)

Divers :
- ImportService découpé en classes dédiées (parser d'archive, remapper d'ids,
  inserters campagne/lore/état de jeu, conversion legacy des quêtes)
- fuites d'abonnements corrigées (takeUntilDestroyed sur paramMap) sur ~15 écrans
- garde-fou i18n fragments/consolidés branché sur npm run build (check-i18n.mjs),
  budget SCSS anyComponentStyle relevé à 12 ko
2026-07-04 11:15:16 +02:00
b2c3800bf8 Co-MJ v1 : quêtes de première classe, guidage de préparation, assistant IA,
All checks were successful
Build & Push Images / build (brain) (push) Successful in 1m38s
Build & Push Images / build (web) (push) Successful in 1m53s
Build & Push Images / build (core) (push) Successful in 3m40s
Build & Push Images / build-switcher (push) Successful in 16s
mode séance, battlemaps multiples et export Foundry ciblé

- Quêtes (Niveau 1) : entité Quest orthogonale à l'arbre, rattachée à un arc HUB
  ou libre (migrations V9-V12, V18, V20 ; réconciliation des jumeaux V10).
  Fusion quête/conteneur dans la sidebar, progression par partie (statuts
  disponible/en cours/terminée), et quêtes libres avec espace de scènes créé
  automatiquement (arc technique « Quêtes libres », V21 : levée de la
  contrainte arcs.type héritée du baseline).
- Guidage (Pilier B) : bilan de préparation 100 % dérivé (règles arc/chapitre/
  scène/quête), panneau « Prochaines étapes » avec boutons « Corriger », et
  pastilles détaillées (tooltip des manques) dans l'arbre.
- Assistant IA (Pilier A) : étoffage champ par champ (scène, chapitre, arc) et
  brouillons de scènes en propose→applique (brain : narrative-fields,
  scene-drafts).
- Horloges & menaces (V15-V17) : clocks à segments avec déclencheurs, fronts.
- Mode séance : préparation de séance (readiness + quêtes dispo), scène
  épinglée (V19), récap « précédemment » (brain : session-recap), onglet
  « Partie » du panneau de référence, graphe amélioré (pan/zoom, éditeur de
  liens).
- Perf : endpoint agrégé GET /api/campaigns/{id}/tree — la sidebar charge en
  1 requête au lieu de ~15.
- Battlemaps multiples par scène (variantes jour/nuit, étages…) : liste JSON
  étiquetée (V22, reprise automatique de la carte existante), rendu PDF avec
  légendes, export/import rétro-compatible.
- Export Foundry ciblé : modale de périmètre (cartes+ennemis / journaux /
  tables), bundle filtré côté serveur (zip allégé), module Foundry à jour
  (respect du périmètre + une Scene Foundry par variante de carte,
  rétro-compatible anciens bundles).
2026-07-03 15:29:23 +02:00
268f4721ce Ajout de fichiers de logs et relèvement de la limite à 2Go pour l'import d'une sauvegarde
All checks were successful
Build & Push Images / build (brain) (push) Successful in 1m27s
Build & Push Images / build (web) (push) Successful in 1m56s
Build & Push Images / build (core) (push) Successful in 3m26s
Build & Push Images / build-switcher (push) Successful in 18s
2026-06-27 10:10:50 +02:00
11a33b1cf0 Pages de lore en blocs (grille 2D libre) + bloc image recadrable, et import dd2vtt sur les scènes
All checks were successful
Build & Push Images / build (brain) (push) Successful in 1m23s
Build & Push Images / build (web) (push) Successful in 1m56s
Build & Push Images / build (core) (push) Successful in 3m24s
Build & Push Images / build-switcher (push) Successful in 20s
Refonte du système de templates de lore en assemblage de blocs (façon Lore
d'Amsel), plus le support des cartes DungeonDraft (.dd2vtt) sur les scènes.

Templates de lore — éditeur de blocs en grille :
- Builder partagé `block-grid-builder` : palette de types de blocs à glisser sur
  une grille 12 colonnes, placement LIBRE (x/y) et redimensionnement en largeur
  ET hauteur par poignées, renommage en place. Remplace la liste + flèches de
  template-create/template-edit (dédupliqués).
- Rendu : page-view (aperçu) honore largeur ET hauteur (grille à lignes fixes) ;
  page-edit garde les colonnes (côte-à-côte) avec des hauteurs naturelles pour
  l'édition. Repli empilé pour les templates sans mise en page.
- Modèle : `TemplateField` gagne `id` (clé stable) et `pos {x,y,w,h}`. Les valeurs
  de page sont ancrées sur l'`id` (repli sur le nom) → renommer un bloc ne perd
  plus son contenu. Sérialisation JSON additive, aucune migration ; correction au
  passage de la non-relecture de `foundryPath` dans le converter.

Bloc image :
- Nouveau composant `image-block` (remplace la galerie pour les pages de lore) :
  plusieurs images plein cadre (carrousel), déplacement (recadrage) et zoom
  persistés PAR image et PAR page. Rendu `object-fit: contain` (image entière,
  bordures noires) avec zoom 0,4×–4×.
- Stockage : `Page.imageFraming` (fieldKey → imageId → {x,y,scale}), colonne
  `image_framing` (migration V8), DTO/mapper + passthrough export/import.

Scènes — source de carte au choix :
- Sélecteur Dungeon Alchemist (image/vidéo + .json, inchangé) / DungeonDraft
  (.dd2vtt). Le .dd2vtt embarque l'image en base64 : extraction à l'upload
  (→ média) + sidecar allégé (→ données) pour que l'export Foundry et le module
  fonctionnent sans changement. Mode déduit à la relecture de l'extension.
  Front-only, sans migration.

i18n fr/en, tests (converters back + helper front) et specs e2e mis à jour.
2026-06-26 17:38:27 +02:00
f670735100 Export/import par campagne + espace de jeu (v2), fix du nombre de joueurs, et ergonomie de jeu
Export / import de contenu (format v2)
- Export SÉLECTIF : on peut exporter une seule campagne (sa clôture : arcs →
  chapitres → scènes, PNJ, ennemis, catalogues, tables, système de jeu lié) au
  lieu de tout exporter. Options par export : inclure le lore, l'espace de jeu,
  les images. L'export global devient « Sauvegarde complète ».
- ESPACE DE JEU désormais exporté/importé : parties (Playthrough), séances,
  journal, flags, progression de quêtes, et feuilles de perso (Character.playthroughId
  préservé au lieu d'être annulé). Remapping complet des références à l'import.
- Format v2 rétro-compatible : un export v1 (sans section jeu) s'importe toujours
  (champs absents → null → traités comme vides).
- Correction d'un bug latent : collectReferencedStorageKeys traitait les références
  d'images comme des clés de stockage alors que ce sont des IDs → les binaires
  d'images n'étaient en réalité pas embarqués dans le zip. Résolution ID → clé.
- Images des Rooms d'une scène (illustration + plan) désormais collectées (elles
  étaient oubliées des deux collecteurs).
- Endpoint : GET /api/admin/data/export?campaignId=&includeLore=&includePlay=&includeImages=
  ; écran « Sauvegarde des données » avec choix campagne + cases à cocher.
- Tests : round-trip étendu au jeu + binaire image réel, export sélectif par
  campagne, import d'archive v1, et DataTransferController.

Correction : nombre de joueurs non persisté
- playerCount était saisi à la création mais jeté par le backend (absent du
  domaine/entité/DTO/mapper) → affichage « 0 joueurs ». Ajouté sur toute la stack
  + migration V7 (défaut 4 pour les campagnes existantes) + intégré à l'export.

Ergonomie de l'espace de jeu et des listes
- Description de campagne repliable (clamp 3 lignes + « Voir plus / moins »).
- Dossiers repliables (chevron + compteur) dans les listes PNJ et Ennemis, sur la
  page campagne ET les pages dédiées.
- Renommage en place des parties (« Mes parties ») via un crayon.
- Panneau de session : onglets sur 2 rangs (« Scènes » n'est plus rogné) ; scènes
  triées par ordre manuel ; arcs repliables (repliés par défaut) ; PJ/PNJ repliables
  et PNJ regroupés par dossier.
2026-06-26 14:38:39 +02:00
c1e3a20e5a Ajout de tests unitaires pour améliorer la couverture globale des tests 2026-06-26 09:40:01 +02:00
a9242ba1e1 Nettoyage post-revue : factorisation, code mort et fiabilité (reorder/lore/foundry/PDF)
All checks were successful
Build & Push Images / build (brain) (push) Successful in 1m27s
Build & Push Images / build (web) (push) Successful in 1m50s
Build & Push Images / build (core) (push) Successful in 3m29s
Build & Push Images / build-switcher (push) Successful in 18s
Corrections fonctionnelles
- order initialisé à la création des pages et dossiers de lore (nextOrderFor) :
  un nouvel élément se place désormais en dernier de sa fratrie au lieu de 0
- storePortrait : type MIME canonique dérivé de l'extension du fichier
  (un data URL "image/jpg" n'est plus rejeté silencieusement)
- takeUntilDestroyed ajouté sur les abonnements paramMap de arc-view,
  folder-view et campaign-detail (fuites mémoire)

Factorisation (suppression de duplication)
- shared/folder-grouping.util.ts : groupByFolder + byOrder + byFolderName
  mutualisés entre npc-list, enemy-list, campaign-detail et la sidebar
  (folderChildren). Le tri des dossiers est désormais cohérent entre la
  sidebar et les vues cartes (insensible casse/accents partout)
- DataSyncService.onChange()/persist() remplacent le câblage
  changed$ + reorder recopié dans 5 vues
- campaign-detail : loadCampaignBundle()/applyCampaignBundle() éliminent
  le forkJoin dupliqué entre ngOnInit et reload
- ReorderSupport (domain/shared) : squelette générique remplaçant les
  8 boucles de réordonnancement copiées dans les services
- FoundryExportService : GameSystem résolu une seule fois dans buildBundle
- module Foundry : walkScalars mutualise la récursion de flattenStats et
  flattenStructure (comportements préservés) ; esc() de l'importer aligné
  sur foundry.utils.escapeHTML

Suppression de code mort
- characterService/characters retirés de loadCampaignTreeData et de ses
  16 appelants (arguments, injections, imports et littéraux CampaignTreeData)
- CSS orphelin : .tree-row.cdk-drop-list-receiving et .btn-back
- BottomPanel.initiallyOpen (jamais lu) retiré de l'interface et de l'appelant
- commentaire trompeur du PDF corrigé

Tests
- mise à jour de 3 tests obsolètes de campaign-tree.helper.spec qui
  vérifiaient encore l'ancien comportement (tri alphabétique, non-classés
  en enfants directs) → alignés sur le modèle actuel (tri par order +
  pseudo-dossier "Sans dossier")
2026-06-26 09:10:20 +02:00
3896c5b4cb Ajout de la possibilité d'importer les images des monstres issus de foundry
All checks were successful
Build & Push Images / build (brain) (push) Successful in 1m28s
Build & Push Images / build (web) (push) Successful in 1m52s
Build & Push Images / build (core) (push) Successful in 3m34s
Build & Push Images / build-switcher (push) Successful in 18s
2026-06-25 17:28:09 +02:00
b999aa54ad Possibilité d'importer un template de monstre depuis le systeme qu'on utilise sur Foundry pour forcer le template. Lors de l'export, le monstre est automatiquement adapté comme ça.
All checks were successful
Build & Push Images / build (brain) (push) Successful in 1m25s
Build & Push Images / build (web) (push) Successful in 1m54s
Build & Push Images / build (core) (push) Successful in 3m29s
Build & Push Images / build-switcher (push) Successful in 17s
à l'import des monstres depuis foundry : on garde la structure originelle utilisée sur foundry également
2026-06-25 16:50:27 +02:00
9bd66613b6 Ajout de drag'n drop là ou c'est possible ; ajout de l'import des ennemis depuis foundryVTT
All checks were successful
Build & Push Images / build (brain) (push) Successful in 1m24s
Build & Push Images / build (web) (push) Successful in 1m56s
Build & Push Images / build (core) (push) Successful in 3m33s
Build & Push Images / build-switcher (push) Successful in 17s
2026-06-25 15:21:25 +02:00
18f5a260b0 passage v1.0.0-beta 2026-06-25 12:18:19 +02:00
c0e1da78c8 Mise en place de l'export foundry ; modification des Arc, chapitres et scènes..... => Suppression de la section maps pour arc, chapitres ; et dans scène on importe les maps format foundry (image / vidéo + json)
All checks were successful
Build & Push Images / build (brain) (push) Successful in 1m35s
Build & Push Images / build (web) (push) Successful in 1m56s
Build & Push Images / build (core) (push) Successful in 3m37s
Build & Push Images / build-switcher (push) Successful in 19s
2026-06-25 12:16:27 +02:00
5bc038acd8 Suppression de la sélection du langage au démarrage de l'appli : maintenant c'est le système qui décide de la langue.
Some checks failed
Tests unitaires / Brain (Python · pytest + couverture) (push) Successful in 20s
Tests unitaires / Web (Angular · vitest + couverture) (push) Successful in 33s
Tests unitaires / Core (Java · mvn test + JaCoCo) (push) Successful in 2m30s
Build & Push Images / build (brain) (push) Successful in 1m22s
E2E Tests / e2e (push) Failing after 5m47s
Build & Push Images / build (core) (push) Successful in 3m29s
Build & Push Images / build-switcher (push) Successful in 48s
Build & Push Images / build (web) (push) Successful in 1m55s
Changement relicat Loremind -> DM Loremind
Passage en 0.18.0 stable publique
2026-06-22 16:59:55 +02:00
f347dcd357 Passage à la 0.17.3 et ajout d'un splash screen pour le démarrage de l'application
Some checks failed
E2E Tests / e2e (push) Has been cancelled
Tests unitaires / Brain (Python · pytest + couverture) (push) Successful in 18s
Tests unitaires / Web (Angular · vitest + couverture) (push) Successful in 28s
Build & Push Images / build (brain) (push) Successful in 1m11s
Tests unitaires / Core (Java · mvn test + JaCoCo) (push) Successful in 2m9s
Build & Push Images / build (web) (push) Successful in 1m48s
Build & Push Images / build-switcher (push) Successful in 44s
Build & Push Images / build (core) (push) Successful in 3m2s
2026-06-22 11:34:55 +02:00
46fea8d53c On arrête de mettre appimagetool dans la release - actif après la release 0.17.2
Some checks failed
E2E Tests / e2e (push) Has been cancelled
Tests unitaires / Brain (Python · pytest + couverture) (push) Successful in 22s
Tests unitaires / Web (Angular · vitest + couverture) (push) Successful in 25s
Tests unitaires / Core (Java · mvn test + JaCoCo) (push) Successful in 1m55s
2026-06-22 11:15:41 +02:00
e05b26563f passage v0.17.2 release officielle
Some checks failed
Tests unitaires / Brain (Python · pytest + couverture) (push) Successful in 20s
Tests unitaires / Web (Angular · vitest + couverture) (push) Successful in 28s
Tests unitaires / Core (Java · mvn test + JaCoCo) (push) Successful in 2m22s
Build & Push Images / build (brain) (push) Successful in 1m20s
E2E Tests / e2e (push) Has been cancelled
Build & Push Images / build (web) (push) Successful in 1m52s
Build & Push Images / build (core) (push) Successful in 3m20s
Build & Push Images / build-switcher (push) Successful in 43s
2026-06-22 10:37:00 +02:00
dc66199177 Passage v0.17.2-beta suite au changement de l'installeur
All checks were successful
Build & Push Images / build (brain) (push) Successful in 1m25s
Build & Push Images / build (core) (push) Successful in 3m20s
Build & Push Images / build (web) (push) Successful in 1m56s
Build & Push Images / build-switcher (push) Successful in 19s
2026-06-22 09:51:26 +02:00
b133d0f16d Correction dans l'installeur : ajout d'une case pour pouvoir lancer le programme automatiquement à la fin de l'installation (sous windows) afin d'éviter que l'utilisateur ne soit perturbé 2026-06-22 09:49:33 +02:00
024d37dea6 Correction des worflow pour linux
All checks were successful
Build & Push Images / build (brain) (push) Successful in 1m20s
Build & Push Images / build (web) (push) Successful in 1m44s
Build & Push Images / build (core) (push) Successful in 3m12s
Build & Push Images / build-switcher (push) Successful in 16s
2026-06-20 11:16:46 +02:00
997aadf5b5 Mise en place de la distribution en .appimage pour les distro linux
All checks were successful
Build & Push Images / build (brain) (push) Successful in 1m21s
Build & Push Images / build (web) (push) Successful in 1m49s
Build & Push Images / build (core) (push) Successful in 3m22s
Build & Push Images / build-switcher (push) Successful in 19s
2026-06-20 11:04:15 +02:00
560c07d5c3 Changement du logo pour l'application et changement du nom en "DM Loremind" pour le tittre de l'application.
Some checks failed
Tests unitaires / Brain (Python · pytest + couverture) (push) Successful in 20s
Tests unitaires / Web (Angular · vitest + couverture) (push) Successful in 29s
Tests unitaires / Core (Java · mvn test + JaCoCo) (push) Successful in 2m17s
Build & Push Images / build (brain) (push) Successful in 1m13s
E2E Tests / e2e (push) Failing after 5m22s
Build & Push Images / build (core) (push) Successful in 3m25s
Build & Push Images / build-switcher (push) Successful in 51s
Build & Push Images / build (web) (push) Successful in 1m57s
2026-06-19 10:00:13 +02:00
5aa08a3a27 Correction du worflow release : on ne fait plus les tests (évite le doublons de test avec le ci.yml).
Some checks failed
Tests unitaires / Brain (Python · pytest + couverture) (push) Successful in 21s
Tests unitaires / Web (Angular · vitest + couverture) (push) Successful in 30s
Tests unitaires / Core (Java · mvn test + JaCoCo) (push) Successful in 2m22s
Build & Push Images / build (brain) (push) Successful in 1m16s
E2E Tests / e2e (push) Failing after 5m24s
Build & Push Images / build (core) (push) Successful in 3m5s
Build & Push Images / build-switcher (push) Successful in 50s
Build & Push Images / build (web) (push) Successful in 1m56s
Correction de tests coté java + config
Suppression de classes inutilisées coté Angular
2026-06-19 00:36:30 +02:00
13f4b994ab Ajout de postgres sur git et gitea en conteneur docker pour pouvoir exécuter les tests unitaires concernant la BDD
Some checks failed
E2E Tests / e2e (push) Has been cancelled
Tests unitaires / Core (Java · mvn test + JaCoCo) (push) Successful in 1m51s
Build & Push Images / tests (push) Successful in 2m25s
Tests unitaires / Brain (Python · pytest + couverture) (push) Successful in 21s
Tests unitaires / Web (Angular · vitest + couverture) (push) Successful in 22s
Build & Push Images / build (brain) (push) Successful in 1m16s
Build & Push Images / build (core) (push) Successful in 3m20s
Build & Push Images / build (web) (push) Successful in 1m41s
Build & Push Images / build-switcher (push) Successful in 39s
2026-06-18 16:28:10 +02:00
d1653b8bea Ajout du mvn wrapper dans le workflow ; ajout d'un gate keeper coté workflow git afin d'éviter de build le .msi si des tests échoues
Some checks failed
Tests unitaires / Core (Java · mvn test + JaCoCo) (push) Failing after 1m40s
Tests unitaires / Brain (Python · pytest + couverture) (push) Successful in 19s
Build & Push Images / tests (push) Failing after 1m26s
Build & Push Images / build (brain) (push) Has been skipped
Build & Push Images / build (core) (push) Has been skipped
Build & Push Images / build (web) (push) Has been skipped
E2E Tests / e2e (push) Has been cancelled
Tests unitaires / Web (Angular · vitest + couverture) (push) Successful in 24s
Build & Push Images / build-switcher (push) Has been skipped
2026-06-18 16:13:36 +02:00
4d049274f9 Refacto du code coté Python, java et coté angular afin de mieux séparer les responsabilité et d'avoir moins de répétitivité dans le code.
Some checks failed
E2E Tests / e2e (push) Waiting to run
Tests unitaires / Web (Angular · vitest + couverture) (push) Successful in 26s
Build & Push Images / tests (push) Failing after 13s
Build & Push Images / build (brain) (push) Has been skipped
Build & Push Images / build (core) (push) Has been skipped
Build & Push Images / build (web) (push) Has been skipped
Build & Push Images / build-switcher (push) Has been skipped
Tests unitaires / Brain (Python · pytest + couverture) (push) Successful in 1m12s
Tests unitaires / Core (Java · mvn test + JaCoCo) (push) Failing after 1m28s
Mise en place de tests unitaires coté Python et Angular
Mise en place de la couverture de test directement dans le workflow : le programme ne build pas si jamais un test échoue
Passage en v0.16.2 en conséquence
2026-06-18 15:59:10 +02:00
eb78a75621 Ajout du binaire tesseract pour la reconnaissance OCR des PDF pour l'installation local
Some checks failed
E2E Tests / e2e (push) Has been cancelled
Build & Push Images / build (brain) (push) Successful in 1m10s
Build & Push Images / build (core) (push) Successful in 3m8s
Build & Push Images / build (web) (push) Successful in 1m41s
Build & Push Images / build-switcher (push) Successful in 39s
Correction d'un problème d'écrasement de BDD à la réinstallation
2026-06-18 10:28:31 +02:00
716 changed files with 36852 additions and 5718 deletions

86
.gitea/workflows/ci.yml Normal file
View File

@@ -0,0 +1,86 @@
name: Tests unitaires
# Gate de qualité : lance les 3 suites unitaires (Java / Python / Angular) à
# chaque push sur main et sur chaque PR. Une suite rouge fait échouer la CI
# (et, via la branch protection Gitea, peut bloquer le merge).
# Le build/push des images (release.yml) dépend AUSSI de ces tests via `needs`.
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
core:
name: Core (Java · mvn test + JaCoCo)
runs-on: ubuntu-latest
# Les tests Core utilisent une VRAIE base PostgreSQL (cf.
# src/test/resources/application.properties, ddl-auto=create-drop).
# On en fournit une en service container. Sur Gitea (job en conteneur),
# le service est joignable par son NOM d'hôte `postgres`.
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_DB: loremind_test
POSTGRES_USER: loremind_test
POSTGRES_PASSWORD: loremind_test
options: >-
--health-cmd "pg_isready -U loremind_test -d loremind_test"
--health-interval 10s
--health-timeout 5s
--health-retries 10
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '17'
cache: maven
# Maven wrapper (./mvnw) : le runner Gitea n'a pas `mvn` préinstallé et
# setup-java n'installe que le JDK → le wrapper bootstrappe Maven lui-même.
# `mvn test` exécute aussi jacoco:report + jacoco:check (plancher 60%).
- name: mvn test (via wrapper)
working-directory: core
env:
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/loremind_test
SPRING_DATASOURCE_USERNAME: loremind_test
SPRING_DATASOURCE_PASSWORD: loremind_test
run: |
chmod +x ./mvnw
./mvnw -B test
brain:
name: Brain (Python · pytest + couverture)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: pip
cache-dependency-path: brain/requirements-dev.txt
- name: Install deps (test)
working-directory: brain
run: pip install -r requirements-dev.txt
- name: pytest (+ plancher couverture 50%)
working-directory: brain
run: pytest --cov=app --cov-report=term-missing --cov-fail-under=50
web:
name: Web (Angular · vitest + couverture)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: npm
cache-dependency-path: web/package-lock.json
- name: npm ci
working-directory: web
run: npm ci --no-audit --no-fund
# vitest run --coverage applique les seuils définis dans vitest.config.ts.
- name: vitest (+ seuils couverture)
working-directory: web
run: npm run test:unit:coverage

View File

@@ -12,6 +12,9 @@ env:
GHCR_NAMESPACE: igmlcreation
jobs:
# NB : pas de job de test ici. Le gate qualité vit dans ci.yml (push main + PR) ;
# avec la branch protection Gitea (checks requis sur main), on ne tague donc que
# du code déjà vert. release.yml ne fait que builder/pousser les images.
build:
runs-on: ubuntu-latest
strategy:

View File

@@ -1,12 +1,12 @@
name: Desktop installers
# Produit les installeurs de BUREAU (.msi Windows pour l'instant) et les publie
# Produit les installeurs de BUREAU (.msi Windows + AppImage Linux) et les publie
# en tant qu'assets d'une GitHub Release, sur tag `v*`.
#
# Complementaire au pipeline Gitea Actions (.gitea/workflows/release.yml) qui,
# lui, build et pousse les IMAGES Docker. Ici on est sur GitHub car jpackage et
# PyInstaller ne savent PAS cross-compiler : le .msi DOIT etre construit sur un
# runner Windows, et GitHub en fournit gratuitement (windows-latest).
# lui, build et pousse les IMAGES Docker. Ici on est sur GitHub car jpackage ne
# sait PAS cross-compiler : le .msi DOIT etre construit sur un runner Windows et
# l'AppImage sur un runner Linux — GitHub fournit les deux gratuitement.
#
# Prerequis : le depot Gitea doit etre mirrore vers GitHub (push mirror, tags
# inclus) pour que le tag declenche ce workflow.
@@ -35,7 +35,76 @@ permissions:
contents: write # requis pour creer la Release et y attacher le .msi
jobs:
# GATE : les 3 suites unitaires (Java / Python / Angular) doivent passer avant
# de construire le .msi. Tourne sur ubuntu-latest (moins cher/plus rapide que
# windows) ; le build natif lui-meme reste sur windows-latest via `needs`.
# Auto-suffisant : ne depend PAS du resultat de Gitea (CI separee), il rejoue
# les memes tests ici. Un test rouge => pas d'installeur publie.
tests:
runs-on: ubuntu-latest
# Base PostgreSQL réelle pour les tests Core. Sur les runners GitHub (job sur
# la VM, pas en conteneur), le service est joignable via localhost + le port mappé.
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_DB: loremind_test
POSTGRES_USER: loremind_test
POSTGRES_PASSWORD: loremind_test
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U loremind_test -d loremind_test"
--health-interval 10s
--health-timeout 5s
--health-retries 10
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event_name == 'workflow_dispatch' && format('v{0}', inputs.version) || github.ref }}
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '17'
cache: maven
- name: Core — mvn test (+ JaCoCo check)
working-directory: core
env:
SPRING_DATASOURCE_URL: jdbc:postgresql://localhost:5432/loremind_test
SPRING_DATASOURCE_USERNAME: loremind_test
SPRING_DATASOURCE_PASSWORD: loremind_test
run: |
chmod +x ./mvnw
./mvnw -B test
- name: Set up Python 3.12
uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: pip
cache-dependency-path: brain/requirements-dev.txt
- name: Brain — pytest (+ couverture)
working-directory: brain
run: |
pip install -r requirements-dev.txt
pytest --cov=app --cov-report=term-missing --cov-fail-under=50
- name: Set up Node 20
uses: actions/setup-node@v4
with:
node-version: '20'
cache: npm
cache-dependency-path: web/package-lock.json
- name: Web — vitest (+ couverture)
working-directory: web
run: |
npm ci --no-audit --no-fund
npm run test:unit:coverage
windows:
needs: tests
runs-on: windows-latest
steps:
# En declenchement manuel, on checkout le TAG correspondant a la version
@@ -70,6 +139,13 @@ jobs:
shell: pwsh
run: choco install wixtoolset -y --no-progress
# Tesseract OCR : non preinstalle sur windows-latest. Requis pour que
# build-windows.ps1 embarque l'OCR des PDF scannes (sinon il skip en
# degradation gracieuse). Installe dans %ProgramFiles%\Tesseract-OCR.
- name: Install Tesseract OCR
shell: pwsh
run: choco install tesseract -y --no-progress
# Version de l'installeur = version du tag (push) OU de l'input (manuel).
# Sorties : version (numerique X.Y.Z pour le MSI), tag (vX.Y.Z[-beta]),
# isbeta (true/false) — independant du nom de ref (qui est une branche en manuel).
@@ -115,6 +191,72 @@ jobs:
path: core/target/dist-out/*.msi
retention-days: 90
# TODO (plus tard) : job `linux` sur ubuntu-latest produisant un AppImage
# (jpackage --type app-image + appimagetool) + PyInstaller Linux du Brain,
# attache a la MEME release. Reutilise la meme matrice / les memes etapes.
# LINUX : AppImage (1 fichier, toutes distros) attache a la MEME release.
# Tourne sur ubuntu-latest (jpackage ne cross-compile pas -> build natif Linux).
# Brain empaquete via python-build-standalone (pas de Python embeddable Linux
# officiel ; pas de PyInstaller -> pas de faux positif AV). Memes regles de
# publication que windows : stable -> release publique ; beta -> artefact prive.
linux:
needs: tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event_name == 'workflow_dispatch' && format('v{0}', inputs.version) || github.ref }}
# Apporte jpackage (empaquetage natif) dans le PATH.
- name: Set up JDK 21
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '21'
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: '20'
# Pas de setup-python : le Brain utilise SON python-build-standalone
# (telecharge par le script, avec son propre pip). Le host n'en a pas besoin.
# Version (numerique X.Y.Z) + tag + isbeta, comme le job windows.
- name: Derive version
id: ver
run: |
if [ '${{ github.event_name }}' = 'workflow_dispatch' ]; then
raw='${{ inputs.version }}'
else
raw='${{ github.ref_name }}'
fi
raw="${raw#v}" # 0.15.0 ou 0.15.0-beta
num="${raw%%-*}" # 0.15.0
case "$raw" in *-beta*) isbeta=true ;; *) isbeta=false ;; esac
echo "version=$num" >> "$GITHUB_OUTPUT"
echo "tag=v$raw" >> "$GITHUB_OUTPUT"
echo "isbeta=$isbeta" >> "$GITHUB_OUTPUT"
- name: Build Linux AppImage
env:
# Authentifie l'appel API GitHub (resolution python-build-standalone) :
# evite le rate limit 60/h anonyme des runners partages.
GITHUB_TOKEN: ${{ github.token }}
run: bash ./installers/desktop/build-linux.sh --version ${{ steps.ver.outputs.version }}
# STABLE uniquement : attache l'AppImage a la release publique (meme tag que
# le .msi -> les deux installeurs sur la meme release).
- name: Publish AppImage to GitHub Release (stable)
if: ${{ steps.ver.outputs.isbeta == 'false' }}
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.ver.outputs.tag }}
files: core/target/dist-out/DM_Loremind*.AppImage
fail_on_unmatched_files: true
# BETA uniquement : artefact PRIVE (pas de release publique).
- name: Upload AppImage as private artifact (beta)
if: ${{ steps.ver.outputs.isbeta == 'true' }}
uses: actions/upload-artifact@v4
with:
name: loremind-beta-${{ steps.ver.outputs.version }}-appimage
path: core/target/dist-out/DM_Loremind*.AppImage
retention-days: 90

6
.gitignore vendored
View File

@@ -123,3 +123,9 @@ brain/data/notebooks/5.json
# ============================================================================
docusaurus/loremind-patreon/
installers/desktop/README.md
# Rapports de couverture de tests
web/coverage/
brain/htmlcov/
brain/.coverage
foundry-module/

View File

@@ -1,4 +1,4 @@
# LoreMind
# DM Loremind
[English](README.md) · **Français**
@@ -10,33 +10,45 @@
[![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)
## Découvrir LoreMind en vidéo
## Découvrir DM Loremind en vidéo
[![Présentation LoreMind](https://img.youtube.com/vi/llJkmlotbB8/maxresdefault.jpg)](https://www.youtube.com/watch?v=llJkmlotbB8)
[![Présentation DM Loremind](https://img.youtube.com/vi/llJkmlotbB8/maxresdefault.jpg)](https://www.youtube.com/watch?v=llJkmlotbB8)
![Tableau de bord](https://raw.githubusercontent.com/IGMLcreation/loremind-docs/main/static/img/screenshots/dashboard.png)
## Ce que ça fait
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.
DM 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.
### Lore
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.
### Game System
### Système de JDR
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.
### Campaign
### Campagne
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.
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 système de JDR retenu.
### Assistant IA
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.
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.
L'IA s'exécute **en local via [Ollama](https://ollama.com/)** — vos données ne quittent jamais votre machine — ou dans le **cloud** avec votre propre clé API via [1min.ai](https://1min.ai/), [Mistral](https://mistral.ai/), [Google AI Studio (Gemini)](https://aistudio.google.com/) ou [OpenRouter](https://openrouter.ai/).
## Démarrage rapide
**Bureau (le plus simple)** — récupérez le dernier installeur sur la [page Releases](https://github.com/IGMLcreation/LoreMind/releases) : Windows `.msi` ou Linux `.AppImage`, puis lancez-le. Vos données restent en local.
**Auto-hébergement Docker** — sous Linux :
```bash
curl -fsSL https://raw.githubusercontent.com/IGMLcreation/LoreMind/main/installers/install.sh | bash
```
Sous Windows, suivez le [guide d'installation](https://loremind-docs.igmlcreation.fr/).
## Documentation
@@ -49,20 +61,20 @@ Une instance de démonstration est disponible sur **[loremind-demo.igmlcreation.
Quelques limites à connaître :
- 10 utilisateurs maximum simultanés (instances isolées)
- Session limitée à 20 minutes avant réinitialisation
- Partie IA non incluse dans la démo (nécessite Ollama ou 1min.ai côté serveur)
- Fonctions IA non incluses dans la démo (elles nécessitent un fournisseur d'IA — Ollama en local ou une clé API cloud — configuré côté serveur)
## Soutenir le projet
LoreMind est **et restera gratuit en auto-hébergement**. Le développement avance plus vite avec votre soutien :
DM 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)**.
DM Loremind est distribué sous licence **[GNU AGPL v3](LICENSE)**.
En pratique :
- Vous pouvez l'utiliser gratuitement, l'héberger, la modifier, la redistribuer.
- 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 vous créez avec LoreMind **vous appartiennent entièrement** — la licence ne couvre que le code de l'application.
- Les univers (Lore) et campagnes que vous créez avec DM Loremind **vous appartiennent entièrement** — la licence ne couvre que le code de l'application.

View File

@@ -1,4 +1,4 @@
# LoreMind
# DM Loremind
**English** · [Français](README.fr.md)
@@ -10,15 +10,15 @@
[![Patreon](https://img.shields.io/badge/Patreon-support-red)](https://www.patreon.com/c/IGMLCreation)
[![Discord](https://img.shields.io/badge/Discord-join-5865F2)](https://discord.gg/cPpFzCjEzQ)
## See LoreMind in action
## See DM Loremind in action
[![LoreMind overview](https://img.youtube.com/vi/llJkmlotbB8/maxresdefault.jpg)](https://www.youtube.com/watch?v=llJkmlotbB8)
[![DM Loremind overview](https://img.youtube.com/vi/llJkmlotbB8/maxresdefault.jpg)](https://www.youtube.com/watch?v=llJkmlotbB8)
![Dashboard](https://raw.githubusercontent.com/IGMLcreation/loremind-docs/main/static/img/screenshots/dashboard.png)
## What it does
LoreMind brings together what a game master usually scatters across several tools. The app is built around three core modules, augmented by an AI assistant that draws on all of your content.
DM Loremind brings together what a game master usually scatters across several tools. The app is built around three core modules, augmented by an AI assistant that draws on all of your content.
### Lore
@@ -36,7 +36,19 @@ Structure your campaigns as Arcs → Chapters → Scenes, with a clear split bet
A context-aware assistant that pulls from your Lore, rules and campaigns to answer your questions, suggest consistent content, or improvise around an unexpected situation at the table.
The AI runs **locally via [Ollama](https://ollama.com/)** or via **[1min.ai](https://1min.ai/)**. More engines will be supported in the future.
The AI runs **locally via [Ollama](https://ollama.com/)** — your data stays on your machine — or in the **cloud** with your own API key via [1min.ai](https://1min.ai/), [Mistral](https://mistral.ai/), [Google AI Studio (Gemini)](https://aistudio.google.com/) or [OpenRouter](https://openrouter.ai/).
## Quick start
**Desktop (easiest)** — grab the latest installer from the [Releases page](https://github.com/IGMLcreation/LoreMind/releases): Windows `.msi` or Linux `.AppImage`, then run it. Your data stays local.
**Self-host with Docker** — on Linux:
```bash
curl -fsSL https://raw.githubusercontent.com/IGMLcreation/LoreMind/main/installers/install.sh | bash
```
On Windows, follow the [installation guide](https://loremind-docs.igmlcreation.fr/en/).
## Documentation
@@ -49,20 +61,20 @@ A demo instance is available at **[loremind-demo.igmlcreation.fr](https://loremi
A few limitations to be aware of:
- 10 concurrent users maximum (isolated instances)
- Sessions limited to 20 minutes before reset
- The AI part is not included in the demo (requires Ollama or 1min.ai server-side)
- The AI features are not included in the demo (they require an AI provider — local Ollama or a cloud API key — configured server-side)
## Support the project
LoreMind is **and will remain free when self-hosted**. Development moves faster with your support:
DM Loremind is **and will remain free when self-hosted**. Development moves faster with your support:
- **[Patreon](https://www.patreon.com/c/IGMLCreation)** — early access to features, roadmap voting, exclusive devlogs
- **[Discord](https://discord.gg/cPpFzCjEzQ)** — announcements, support, user feedback
## License
LoreMind is distributed under the **[GNU AGPL v3](LICENSE)** license.
DM Loremind is distributed under the **[GNU AGPL v3](LICENSE)** license.
In practice:
- You can use it for free, host it, modify it, and redistribute it.
- If you modify the code and expose the modified app over a network (even as a private SaaS), you must make your changes public under the same license.
- The worlds (Lore) and campaigns you create with LoreMind **belong entirely to you** — the license only covers the application's code.
- The worlds (Lore) and campaigns you create with DM Loremind **belong entirely to you** — the license only covers the application's code.

15
brain/.coveragerc Normal file
View File

@@ -0,0 +1,15 @@
# Configuration de couverture (coverage.py / pytest-cov).
# Rapport HTML (équivalent JaCoCo) : pytest --cov=app --cov-report=html → htmlcov/
# Plancher anti-régression appliqué en CI : --cov-fail-under=50
[run]
source = app
branch = false
[report]
show_missing = true
skip_covered = false
# Lignes jamais comptées comme « à couvrir ».
exclude_lines =
pragma: no cover
if __name__ == .__main__.:
raise NotImplementedError

5
brain/.gitignore vendored
View File

@@ -2,3 +2,8 @@
__pycache__/
*.pyc
.env
# Couverture de tests (pytest-cov)
htmlcov/
.coverage
.pytest_cache/

View File

@@ -6,7 +6,12 @@ from pydantic import BaseModel, Field
from app.api.deps import get_generate_page_use_case, get_llm_provider
from app.application.generate_page import GeneratePageUseCase
from app.application.llm_json import load_json_object
from app.application.llm_retry import generate_with_retry
from app.application.prompts import conversation_title as title_prompts
from app.application.prompts import narrative_fields as narrative_fields_prompts
from app.application.prompts import scene_drafts as scene_drafts_prompts
from app.application.prompts import session_recap as session_recap_prompts
from app.core.config import Settings, get_settings
from app.core.language import get_user_language
from app.domain.models import PageGenerationContext
@@ -131,3 +136,157 @@ async def summarize_conversation_title(
if not title:
title = title_prompts.TITLE_FALLBACK.get(language, title_prompts.TITLE_FALLBACK["fr"])
return SummarizeTitleResponseDTO(title=title)
# --- Étoffer une entité narrative (Pilier A : co-MJ propose → l'humain valide) ----------
class NarrativeFieldSpecDTO(BaseModel):
"""Un champ autorisé : clé technique + libellé lisible (fourni par le Core)."""
key: str
label: str = Field(default="")
class NarrativeFieldsRequestDTO(BaseModel):
"""Contexte envoyé par le Core pour proposer des valeurs de champs (arc/chapitre/scène)."""
entity_type: str = Field(default="")
context: str = Field(default="")
instruction: str = Field(default="")
# Whitelist (clé + libellé) fournie par le Core, source de vérité.
fields: list[NarrativeFieldSpecDTO] = Field(default_factory=list)
class NarrativeFieldsResponseDTO(BaseModel):
"""Retour : une valeur proposée par clé (uniquement des clés autorisées, non vides)."""
fields: dict[str, str]
@router.post("/generate/narrative-fields", response_model=NarrativeFieldsResponseDTO)
async def generate_narrative_fields(
body: NarrativeFieldsRequestDTO,
llm: Annotated[LLMProvider, Depends(get_llm_provider)],
language: Annotated[str, Depends(get_user_language)],
) -> NarrativeFieldsResponseDTO:
"""Propose des valeurs pour ÉTOFFER une entité narrative (patch champ par champ, non appliqué).
Whitelist stricte : on ne retient que les clés autorisées, non vides. Un objet vide
est une réponse VALIDE (le modèle n'a rien de pertinent à proposer — l'entité est
peut-être déjà complète) ; seule une sortie non-JSON est une erreur.
"""
allowed = {f.key for f in body.fields if f.key}
prompt = narrative_fields_prompts.narrative_fields_prompt(
body.entity_type, body.context, body.instruction,
[{"key": f.key, "label": f.label} for f in body.fields], language)
try:
raw = await generate_with_retry(llm, prompt, output_format="json", temperature=0.7)
except LLMProviderError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
parsed, _ = load_json_object(raw)
if not isinstance(parsed, dict):
raise HTTPException(status_code=502, detail="Le modèle n'a pas renvoyé de champs exploitables.")
out: dict[str, str] = {}
raw_fields = parsed.get("fields")
if isinstance(raw_fields, dict):
for key, value in raw_fields.items():
if key not in allowed:
continue
if not isinstance(value, (str, int, float)):
continue
text = str(value).strip()
if text:
out[str(key)] = text
return NarrativeFieldsResponseDTO(fields=out)
# --- Peupler un chapitre en scènes (Pilier A : capacité « create ») ----------
class SceneDraftsRequestDTO(BaseModel):
"""Contexte envoyé par le Core pour ébaucher des scènes d'un chapitre."""
context: str = Field(default="")
instruction: str = Field(default="")
count: int = Field(default=4)
class SceneDraftDTO(BaseModel):
name: str
description: str = Field(default="")
playerNarration: str = Field(default="")
class SceneDraftsResponseDTO(BaseModel):
scenes: list[SceneDraftDTO]
@router.post("/generate/scene-drafts", response_model=SceneDraftsResponseDTO)
async def generate_scene_drafts(
body: SceneDraftsRequestDTO,
llm: Annotated[LLMProvider, Depends(get_llm_provider)],
language: Annotated[str, Depends(get_user_language)],
) -> SceneDraftsResponseDTO:
"""Propose des ébauches de scènes pour un chapitre (non créées). Un titre par scène
est obligatoire ; on borne le nombre. Seule une sortie non-JSON est une erreur."""
n = max(1, min(8, body.count))
prompt = scene_drafts_prompts.scene_drafts_prompt(body.context, body.instruction, n, language)
try:
raw = await generate_with_retry(llm, prompt, output_format="json", temperature=0.8)
except LLMProviderError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
parsed, _ = load_json_object(raw)
if not isinstance(parsed, dict):
raise HTTPException(status_code=502, detail="Le modèle n'a pas renvoyé de scènes exploitables.")
scenes: list[SceneDraftDTO] = []
for s in (parsed.get("scenes") or [])[:n]:
if not isinstance(s, dict):
continue
name = str(s.get("name") or "").strip()
if not name:
continue
scenes.append(SceneDraftDTO(
name=name[:200],
description=str(s.get("description") or "").strip(),
playerNarration=str(s.get("playerNarration") or "").strip(),
))
return SceneDraftsResponseDTO(scenes=scenes)
# --- Récap « précédemment… » d'une séance (mode cockpit) ---------------------
class SessionRecapRequestDTO(BaseModel):
"""Journal chronologique de la séance précédente + méta courte."""
transcript: str
context: str = Field(default="")
class SessionRecapResponseDTO(BaseModel):
recap: str
@router.post("/generate/session-recap", response_model=SessionRecapResponseDTO)
async def generate_session_recap(
body: SessionRecapRequestDTO,
llm: Annotated[LLMProvider, Depends(get_llm_provider)],
language: Annotated[str, Depends(get_user_language)],
) -> SessionRecapResponseDTO:
"""Rédige le récap « Précédemment… » à lire aux joueurs (texte libre, pas de JSON)."""
if not body.transcript.strip():
raise HTTPException(status_code=422, detail="Journal vide : rien à résumer.")
prompt = session_recap_prompts.session_recap_prompt(body.transcript, body.context, language)
try:
raw = await generate_with_retry(llm, prompt, temperature=0.7)
except LLMProviderError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
recap = raw.strip()
if not recap:
raise HTTPException(status_code=502, detail="Le modèle n'a renvoyé aucun récit.")
return SessionRecapResponseDTO(recap=recap)

View File

@@ -0,0 +1,50 @@
"""Prompt d'étoffage des champs d'une entité narrative (arc / chapitre / scène) — Pilier A.
Générique : le Core (Java) est la SOURCE DE VÉRITÉ des champs (clé + libellé) et les
passe en entrée ; ce module ne fait que formuler le prompt. Le modèle ne renvoie que les
clés fournies et OMET celles pour lesquelles il n'a rien de pertinent (pas de remplissage forcé).
"""
from app.core.language import language_name
# Étiquette lisible du type d'entité, pour la formulation du prompt.
ENTITY_LABEL: dict[str, str] = {
"arc": "cet arc narratif",
"chapter": "ce chapitre",
"scene": "cette scène",
}
def narrative_fields_prompt(entity_type: str, entity_context: str, instruction: str,
fields: list[dict], language: str) -> str:
"""Construit le prompt d'étoffage. `fields` = [{key, label}] (whitelist du Core)."""
label = ENTITY_LABEL.get(entity_type or "", "cette entité narrative")
lines = []
for f in fields or []:
key = str(f.get("key") or "").strip()
if not key:
continue
flabel = str(f.get("label") or key).strip()
lines.append(f'- "{key}" : {flabel}')
fields_list = "\n".join(lines)
instruction_block = (
f"\nConsigne particulière du MJ : {instruction.strip()}\n"
if instruction and instruction.strip() else ""
)
return (
f"Tu es un co-Maître de Jeu. On te donne l'état ACTUEL d'{label} de jeu de rôle. "
"Propose des valeurs pour l'ÉTOFFER, cohérentes avec ce qui existe déjà.\n\n"
f"{entity_context.strip()}\n"
f"{instruction_block}\n"
"Champs que tu peux remplir (n'utilise QUE ces clés) :\n"
f"{fields_list}\n\n"
"Règles IMPÉRATIVES :\n"
"- Réponds UNIQUEMENT par un objet JSON valide, sans texte autour.\n"
'- Format exact : {"fields": {"cle": "valeur proposée", ...}}\n'
"- N'inclus QUE des clés de la liste ci-dessus. N'invente AUCUNE autre clé.\n"
"- Si un champ est déjà bien rempli ou si tu n'as rien de pertinent, OMETS-le "
"(ne le renvoie pas) plutôt que de le remplir de force.\n"
"- Reste cohérent avec le contexte : n'invente pas d'élément qui contredit "
"l'entité ou la campagne.\n"
f"- Rédige les valeurs en {language_name(language)}.\n"
"Renvoie maintenant le JSON."
)

View File

@@ -0,0 +1,30 @@
"""Prompt d'ébauche de scènes pour un chapitre (Pilier A — capacité « create »).
Le co-MJ propose plusieurs scènes cohérentes pour PEUPLER un chapitre vide (ou en manque).
JSON structuré, une liste de scènes ; l'humain révise et ne crée que celles qu'il retient.
"""
from app.core.language import language_name
def scene_drafts_prompt(context: str, instruction: str, count: int, language: str) -> str:
instruction_block = (
f"\nConsigne particulière du MJ : {instruction.strip()}\n"
if instruction and instruction.strip() else ""
)
return (
f"Tu es un co-Maître de Jeu. Propose {count} SCÈNES de jeu de rôle pour PEUPLER ce "
"chapitre, cohérentes entre elles et avec le contexte.\n\n"
f"{context.strip()}\n"
f"{instruction_block}\n"
"Règles IMPÉRATIVES :\n"
"- Réponds UNIQUEMENT par un objet JSON valide, sans texte autour.\n"
'- Format exact : {"scenes": [{"name": "...", "description": "...", "playerNarration": "..."}]}\n'
f"- Propose AU PLUS {count} scènes, distinctes et complémentaires (une progression du chapitre).\n"
"- 'name' : titre court et évocateur (OBLIGATOIRE).\n"
"- 'description' : un résumé bref (une phrase).\n"
"- 'playerNarration' : 2-3 phrases de mise en scène lues aux joueurs.\n"
"- Ne DUPLIQUE pas les scènes déjà présentes ; reste cohérent avec le chapitre et la campagne "
"(n'invente pas d'élément qui les contredit).\n"
f"- Rédige en {language_name(language)}.\n"
"Renvoie maintenant le JSON."
)

View File

@@ -0,0 +1,25 @@
"""Prompt du récap « précédemment dans… » (mode séance).
Le Core envoie le journal chronologique de la séance PRÉCÉDENTE ; le modèle rédige un
récapitulatif court, à lire aux joueurs à l'ouverture de la séance suivante. Texte libre
(pas de JSON) : c'est de la narration.
"""
from app.core.language import language_name
def session_recap_prompt(transcript: str, context: str, language: str) -> str:
context_block = f"\n{context.strip()}\n" if context and context.strip() else ""
return (
"Tu es le Maître du Jeu. Voici le journal de la SÉANCE PRÉCÉDENTE de ta table "
"(entrées chronologiques : notes, évènements, jets de dés, actions des joueurs).\n"
f"{context_block}\n"
"Journal :\n"
f"{transcript.strip()}\n\n"
"Rédige un récapitulatif « Précédemment… » à LIRE AUX JOUEURS pour ouvrir la "
"nouvelle séance :\n"
"- 4 à 8 phrases, ton narratif et vivant, au passé.\n"
"- Uniquement ce qui s'est réellement passé dans le journal — n'invente RIEN, "
"ne révèle aucun secret du MJ.\n"
"- Termine sur la situation où les joueurs se sont arrêtés (le « cliffhanger »).\n"
f"- Rédige en {language_name(language)}. Pas de préambule ni de méta : juste le récit."
)

View File

@@ -0,0 +1,226 @@
"""Socle commun aux adapters LLM « OpenAI-compatible » (OpenRouter, Gemini,
Mistral) — ils exposent tous `POST {base}/chat/completions` en SSE avec le même
schéma de payload et de flux.
Cette classe de base porte la mécanique partagée (construction du payload, appel
HTTP streamé, parsing SSE, garde-fous de timeout au temps écoulé, traduction des
erreurs). Chaque adapter concret ne fournit plus que ses spécificités :
URL, en-têtes, support du mode JSON natif, messages d'erreur, lecture de la config.
`generate` one-shot passe lui aussi par le streaming (puis recollage) pour éviter
les coupures de passerelle sur les longues générations (cf. Cloudflare 524).
"""
from __future__ import annotations
import asyncio
import json
import logging
from typing import AsyncIterator
import httpx
from app.domain.models import ChatMessage
from app.domain.ports import LLMGenerationTimeout, LLMProviderError
logger = logging.getLogger(__name__)
# Délai max pour le PREMIER token de contenu. Un modèle « en file d'attente »
# n'envoie que des keep-alive (aucun contenu) → on échoue vite et clairement au
# lieu de pendre. 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.
_FIRST_TOKEN_TIMEOUT_SECONDS = 120.0
class BaseOpenAICompatibleAdapter:
"""Base des adapters clients d'une API OpenAI-compatible (chat/completions SSE).
Satisfait par duck typing les ports LLMProvider et LLMChatProvider. Les
sous-classes définissent : ``_provider_label``, ``_api_url``,
``_supports_json_object`` (mode JSON natif), et surchargent au besoin
``_headers`` / ``_error_for_status`` / les messages de timeout.
"""
# Surchargés par les sous-classes.
_provider_label: str = "LLM"
_api_url: str = ""
_supports_json_object: bool = False
def __init__(self, api_key: str, model: str, timeout: int) -> None:
self._api_key = api_key
self._model = model
self._timeout = timeout
# --- Spécificités surchargeables ----------------------------------------
def _headers(self) -> dict[str, str]:
return {
"Authorization": f"Bearer {self._api_key}",
"Content-Type": "application/json",
}
def _first_token_timeout_message(self) -> str:
return (
f"Erreur {self._provider_label} : aucun contenu produit en "
f"{int(_FIRST_TOKEN_TIMEOUT_SECONDS)}s — le modèle est probablement en "
"file d'attente / saturé. Réessayez plus tard ou choisissez un autre modèle."
)
def _generation_timeout_message(self) -> str:
return (
f"Erreur {self._provider_label} : 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."
)
def _error_for_status(self, status_code: int, detail: str) -> LLMProviderError:
"""Erreur de domaine pour une réponse HTTP >= 400 (détail déjà lu)."""
return LLMProviderError(
f"Erreur {self._provider_label} (HTTP {status_code})"
+ (f" : {detail[:500]}" if detail else "")
)
# --- API publique (ports) -----------------------------------------------
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 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
# --- Mécanique partagée -------------------------------------------------
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é (`_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.
"""
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 ; 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(self._first_token_timeout_message())
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 LLMGenerationTimeout(self._generation_timeout_message()) from exc
def _build_body(
self,
messages: list[ChatMessage],
system_prompt: str | None,
temperature: float | None,
output_format: str | None,
) -> dict[str, object]:
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
# Mode JSON natif : supprime les fences ```json et le JSON invalide (retours
# à la ligne bruts), principale cause de morceaux d'import ignorés. Un SCHÉMA
# (dict) est traduit en json_object — suffisant, les grands modèles cloud
# respectent la structure demandée par le prompt. Désactivé pour les
# providers/modèles gratuits qui ne le supportent pas (réponse vide).
if self._supports_json_object and output_format is not None:
body["response_format"] = {"type": "json_object"}
return body
async def _stream(
self,
messages: list[ChatMessage],
system_prompt: str | None,
temperature: float | None,
output_format: str | None = None,
) -> AsyncIterator[str]:
body = self._build_body(messages, system_prompt, temperature, output_format)
async with httpx.AsyncClient(timeout=self._timeout) as client:
try:
async with client.stream(
"POST", self._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 du provider (le 429 précise le
# type de quota, le 401 la clé invalide…), sinon on n'a que
# le code HTTP nu et le diagnostic est impossible.
detail = (await response.aread()).decode("utf-8", "replace").strip()
raise self._error_for_status(response.status_code, detail)
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 {self._provider_label} : 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 {self._provider_label} ({exc.__class__.__name__}) : {detail}"

View File

@@ -1,194 +1,66 @@
"""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.
(POST {base}/openai/chat/completions, SSE) : client "OpenAI-compatible" qui hérite
de BaseOpenAICompatibleAdapter et ne fournit que ses spécificités (dont un message
dédié quand Google refuse la clé en 401/403).
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).
appels. 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 LLMGenerationTimeout, 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
from app.domain.ports import LLMProviderError
from app.infrastructure.base_openai_adapter import (
_FIRST_TOKEN_TIMEOUT_SECONDS,
BaseOpenAICompatibleAdapter,
)
class GeminiLLMProvider:
class GeminiLLMProvider(BaseOpenAICompatibleAdapter):
"""Adapter Gemini (OpenAI-compatible) — satisfait LLMProvider et LLMChatProvider."""
_provider_label = "Gemini"
_api_url = "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions"
_supports_json_object = True
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
super().__init__(
settings.gemini_api_key, settings.gemini_model, settings.llm_timeout_seconds
)
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)
def _headers(self) -> dict[str, str]:
return {**super()._headers(), "Accept": "application/json"}
try:
return await asyncio.wait_for(_collect(), timeout=self._timeout)
except asyncio.TimeoutError as exc:
raise LLMGenerationTimeout(
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
def _first_token_timeout_message(self) -> str:
return (
f"Erreur Gemini : aucun contenu produit en "
f"{int(_FIRST_TOKEN_TIMEOUT_SECONDS)}s. Réessayez ou vérifiez "
"votre quota gratuit."
)
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
def _generation_timeout_message(self) -> str:
return (
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."
)
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
# Mode JSON natif (supporté par l'endpoint OpenAI-compatible de Gemini) :
# supprime fences ```json et JSON invalide, principale cause de morceaux
# ignorés. Un SCHÉMA (dict) est traduit en json_object : suffisant, les
# grands modèles cloud respectent la structure demandée par le prompt.
if output_format is not None:
body["response_format"] = {"type": "json_object"}
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()
# 401/403 = clé rejetée par GOOGLE (pas un problème LoreMind) :
# message actionnable plutôt que le JSON brut de l'API.
if response.status_code in (401, 403):
raise LLMProviderError(
"Erreur Gemini : clé API refusée par Google "
f"(HTTP {response.status_code}). Vérifiez que la clé vient bien "
"de aistudio.google.com (« Get API key ») et qu'elle n'a pas de "
"restrictions (API ou adresse IP) dans la Google Cloud Console. "
f"Détail : {detail[:300]}"
)
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."
def _error_for_status(self, status_code: int, detail: str) -> LLMProviderError:
# 401/403 = clé rejetée par GOOGLE (pas un problème LoreMind) : message
# actionnable plutôt que le JSON brut de l'API.
if status_code in (401, 403):
return LLMProviderError(
"Erreur Gemini : clé API refusée par Google "
f"(HTTP {status_code}). Vérifiez que la clé vient bien "
"de aistudio.google.com (« Get API key ») et qu'elle n'a pas de "
"restrictions (API ou adresse IP) dans la Google Cloud Console. "
f"Détail : {detail[:300]}"
)
detail = str(exc) or exc.__class__.__name__
return f"Erreur Gemini ({exc.__class__.__name__}) : {detail}"
return super()._error_for_status(status_code, detail)

View File

@@ -1,195 +1,48 @@
"""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.
SSE) : client "OpenAI-compatible" qui hérite de BaseOpenAICompatibleAdapter et ne
fournit que ses spécificités.
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`.
Mode JSON natif : TOUS les modèles Mistral le supportent (`_supports_json_object`).
"""
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 LLMGenerationTimeout, 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
from app.domain.ports import LLMProviderError
from app.infrastructure.base_openai_adapter import (
_FIRST_TOKEN_TIMEOUT_SECONDS,
BaseOpenAICompatibleAdapter,
)
class MistralLLMProvider:
class MistralLLMProvider(BaseOpenAICompatibleAdapter):
"""Adapter Mistral (OpenAI-compatible) — satisfait LLMProvider et LLMChatProvider."""
_provider_label = "Mistral"
_api_url = "https://api.mistral.ai/v1/chat/completions"
_supports_json_object = True
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
super().__init__(
settings.mistral_api_key, settings.mistral_model, settings.llm_timeout_seconds
)
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)
def _headers(self) -> dict[str, str]:
return {**super()._headers(), "Accept": "application/json"}
try:
return await asyncio.wait_for(_collect(), timeout=self._timeout)
except asyncio.TimeoutError as exc:
raise LLMGenerationTimeout(
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
# Mode JSON natif : TOUS les modèles Mistral le supportent → plus de fences
# ```json ni de JSON invalide (retours à la ligne bruts dans les chaînes),
# principale cause de morceaux d'import ignorés. Un SCHÉMA (dict) est
# traduit en json_object : suffisant ici, les grands modèles cloud
# respectent la structure demandée par le prompt.
if output_format is not None:
body["response_format"] = {"type": "json_object"}
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}"
def _first_token_timeout_message(self) -> str:
return (
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."
)

View File

@@ -1,205 +1,57 @@
"""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).
cet adapter est un client "OpenAI-compatible" : il hérite de toute la mécanique de
BaseOpenAICompatibleAdapter et ne fournit que ses spécificités (URL, en-têtes
d'attribution, messages, lecture de config).
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é.
NB : on n'impose PAS `response_format=json_object` (`_supports_json_object=False`).
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.
"""
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 LLMGenerationTimeout, 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
from app.domain.ports import LLMProviderError
from app.infrastructure.base_openai_adapter import (
_FIRST_TOKEN_TIMEOUT_SECONDS,
BaseOpenAICompatibleAdapter,
)
class OpenRouterLLMProvider:
class OpenRouterLLMProvider(BaseOpenAICompatibleAdapter):
"""Adapter OpenRouter (OpenAI-compatible) — satisfait LLMProvider et LLMChatProvider."""
_provider_label = "OpenRouter"
_api_url = "https://openrouter.ai/api/v1/chat/completions"
_supports_json_object = False
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
super().__init__(
settings.openrouter_api_key, settings.openrouter_model, settings.llm_timeout_seconds
)
def _headers(self) -> dict[str, str]:
return {
"Authorization": f"Bearer {self._api_key}",
"Content-Type": "application/json",
**super()._headers(),
# 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"
def _first_token_timeout_message(self) -> str:
return (
f"Erreur OpenRouter : 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)."
)
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 LLMGenerationTimeout(
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

@@ -26,7 +26,7 @@ from app.infrastructure.ollama_model_installer import ensure_ollama_embedding_mo
app = FastAPI(
title="LoreMind Brain",
description="Backend IA pour la génération de contenu narratif.",
version="0.16.0",
version="1.0.1-beta",
)
logger = logging.getLogger(__name__)

7
brain/pytest.ini Normal file
View File

@@ -0,0 +1,7 @@
[pytest]
# Tests unitaires du brain. asyncio_mode=auto : les coroutines de test sont
# exécutées sans décorateur @pytest.mark.asyncio explicite.
asyncio_mode = auto
# Ajoute la racine du brain au sys.path pour `import app...` sans installation.
pythonpath = .
testpaths = tests

View File

@@ -0,0 +1,11 @@
# Dépendances de TEST uniquement (non embarquées dans l'image / le bundle desktop).
# Installer avec : .venv/Scripts/python -m pip install -r requirements-dev.txt
-r requirements.txt
pytest>=8,<9
pytest-asyncio>=0.24,<1
# Mock du transport httpx (intercepte les appels aux API LLM dans les tests).
respx>=0.21,<1
# Couverture de tests (équivalent JaCoCo) : `pytest --cov=app --cov-report=html`
# → rapport HTML dans htmlcov/. La CI ajoute --cov-fail-under pour le plancher.
pytest-cov>=5,<7

View File

@@ -16,7 +16,25 @@ ailleurs, sous ~/.loremind/brain, pour y ecrire le dossier data/).
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
_HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, _HERE)
# OCR : si un Tesseract est bundlé à côté (mode desktop), on y pointe pytesseract
# AVANT que l'app n'importe le pdf_extractor (qui détecte la version au chargement).
# tessdata (fra+eng) est embarqué dans tesseract/tessdata. Sans ce bloc, l'OCR
# reste désactivé en dégradation gracieuse (PDF born-digital OK, scans signalés).
# Binaire selon l'OS : tesseract.exe (Windows embeddable) ou tesseract (Linux/Mac).
# Si aucun Tesseract n'est bundlé (cas Linux/AppImage par défaut), le bloc est
# sauté et pytesseract retombe sur le tesseract SYSTÈME du PATH (ex. apt install
# tesseract-ocr) — sinon OCR désactivé en dégradation gracieuse.
_TESS = os.path.join(_HERE, "tesseract", "tesseract.exe" if os.name == "nt" else "tesseract")
if os.path.exists(_TESS):
os.environ.setdefault("TESSDATA_PREFIX", os.path.join(_HERE, "tesseract"))
try:
import pytesseract
pytesseract.pytesseract.tesseract_cmd = _TESS
except ImportError:
pass
import uvicorn # noqa: E402

View File

@@ -0,0 +1,72 @@
"""Tests du use case de conseils d'adaptation (app.application.adapt_campaign)."""
from __future__ import annotations
import pytest
from app.application.adapt_campaign import AdaptCampaignUseCase
from app.domain.models import ChatMessage, ExtractedDocument, ExtractedPage
from app.domain.ports import PdfExtractionError
class FakeExtractor:
def __init__(self, doc: ExtractedDocument) -> None:
self._doc = doc
def extract(self, pdf_bytes: bytes) -> ExtractedDocument:
return self._doc
class FakeChatLLM:
def __init__(self, tokens: list[str]) -> None:
self._tokens = tokens
self.system_prompt: str | None = None
self.messages: list[ChatMessage] | None = None
async def stream_chat(self, messages, *, system_prompt=None, temperature=None):
self.messages = messages
self.system_prompt = system_prompt
for t in self._tokens:
yield t
def _doc(text: str) -> ExtractedDocument:
return ExtractedDocument(pages=[ExtractedPage(index=0, text=text, used_ocr=False)])
async def test_stream_yields_tokens_and_builds_context():
llm = FakeChatLLM(["con", "seil"])
uc = AdaptCampaignUseCase(llm, FakeExtractor(_doc("contenu du pdf")))
out = [t async for t in uc.stream(b"x", "mon brief de campagne",
[ChatMessage(role="user", content="aide")])]
assert out == ["con", "seil"]
assert "mon brief de campagne" in llm.system_prompt
assert "contenu du pdf" in llm.system_prompt
async def test_stream_empty_pdf_text_raises():
uc = AdaptCampaignUseCase(FakeChatLLM([]), FakeExtractor(_doc(" ")))
with pytest.raises(PdfExtractionError):
[t async for t in uc.stream(b"x", "brief", [])]
async def test_stream_injects_default_request_when_no_messages():
llm = FakeChatLLM(["ok"])
uc = AdaptCampaignUseCase(llm, FakeExtractor(_doc("texte du pdf")))
_ = [t async for t in uc.stream(b"x", "", [])]
assert llm.messages[0].role == "user"
assert "campagne" in llm.messages[0].content.lower()
def test_fit_pdf_short_text_not_truncated():
uc = AdaptCampaignUseCase(None, None, max_input_tokens=10000)
text, truncated = uc._fit_pdf_to_budget("court texte", "brief")
assert truncated is False
assert text == "court texte"
def test_fit_pdf_long_text_is_truncated():
uc = AdaptCampaignUseCase(None, None, max_input_tokens=2100)
long_text = "mot " * 5000
text, truncated = uc._fit_pdf_to_budget(long_text, "")
assert truncated is True
assert len(text) < len(long_text)

129
brain/tests/test_chat.py Normal file
View File

@@ -0,0 +1,129 @@
"""Tests de la construction du system prompt du chat (app.application.chat).
Assertions par INCLUSION (présence des données/sections clés) plutôt que sur le
texte exact des consignes : robuste aux retouches de formulation, tout en
vérifiant que chaque contexte est bien injecté.
"""
from __future__ import annotations
from app.application.chat import ChatUseCase
from app.domain.models import (
ArcSummary,
CampaignStructuralContext,
ChapterSummary,
CharacterSummary,
ChatMessage,
GameSystemContext,
JournalEntrySummary,
LoreStructuralContext,
NarrativeEntityContext,
NpcSummary,
PageContext,
PageSummary,
SceneSummary,
SessionContext,
)
def _build(**kw) -> str:
return ChatUseCase(None).build_system_prompt(**kw)
def _empty_lore() -> LoreStructuralContext:
return LoreStructuralContext(lore_name="L", lore_description=None, folders={}, tags=[])
def test_base_prompt_without_context_is_non_empty():
assert len(_build()) > 0
def test_lore_block_renders_pages_with_values_tags_and_links():
lore = LoreStructuralContext(
lore_name="Eldoria", lore_description="un monde sombre",
folders={"PNJ": [PageSummary(
title="Aragorn", template_name="Personnage",
values={"apparence": "grand et noble"}, tags=["héros"],
related_page_titles=["Gondor"])]},
tags=["dark-fantasy"])
p = _build(lore_context=lore)
assert "Eldoria" in p
assert "un monde sombre" in p
assert "Aragorn" in p
assert "apparence" in p and "grand et noble" in p
assert "héros" in p
assert "Gondor" in p
def test_empty_lore_signals_vide():
assert "Lore vide" in _build(lore_context=_empty_lore())
def test_page_context_block_lists_fields_and_empty_marker():
page = PageContext(title="Aragorn", template_name="Personnage",
template_fields=["apparence", "histoire"],
values={"apparence": "grand"})
p = _build(page_context=page)
assert "PAGE EN COURS" in p
assert "Aragorn" in p
assert "apparence" in p
assert "(vide)" in p # 'histoire' sans valeur
def test_campaign_block_with_arc_and_empty_pj_npc_and_no_lore_note():
camp = CampaignStructuralContext(
campaign_name="La Malédiction", campaign_description="horreur gothique",
arcs=[ArcSummary(name="Acte I", description="intro",
chapters=[ChapterSummary(name="Ch1", description="",
scenes=[SceneSummary(name="Sc1", description="")])])],
characters=[], npcs=[])
p = _build(campaign_context=camp)
assert "CAMPAGNE COURANTE" in p
assert "La Malédiction" in p
assert "Acte I" in p
assert "aucune fiche" in p # pas de PJ
assert "aucun univers" in p # pas de lore lié
def test_campaign_with_characters_npcs_and_lore_present_note():
camp = CampaignStructuralContext(
campaign_name="C", campaign_description=None, arcs=[],
characters=[CharacterSummary(name="Tav", snippet="roublarde")],
npcs=[NpcSummary(name="Strahd", snippet="vampire de Barovia")])
p = _build(campaign_context=camp, lore_context=_empty_lore())
assert "Tav" in p and "roublarde" in p
assert "Strahd" in p and "vampire de Barovia" in p
assert "liée à l'univers" in p
def test_game_system_narrative_and_session_sections_injected():
gs = GameSystemContext(system_name="Nimble", system_description=None,
sections={"Combat": "règles de combat"})
narr = NarrativeEntityContext(entity_type="scene", title="L'auberge du Portail",
fields={"ambiance": "tendue"})
sess = SessionContext(
session_name="Séance 3", active=True, started_at=None,
entries=[JournalEntrySummary(type="EVENT", content="Le pont s'effondre", occurred_at=None)],
previous_events=[])
p = _build(lore_context=_empty_lore(), game_system_context=gs,
narrative_entity=narr, session_context=sess)
assert "Nimble" in p
assert "L'auberge du Portail" in p
assert "Séance 3" in p
assert "Le pont s'effondre" in p
async def test_stream_passes_built_prompt_to_llm():
class FakeChatLLM:
def __init__(self) -> None:
self.system_prompt = None
async def stream_chat(self, messages, *, system_prompt=None, temperature=None):
self.system_prompt = system_prompt
yield "tok"
llm = FakeChatLLM()
out = [t async for t in ChatUseCase(llm).stream(
[ChatMessage(role="user", content="salut")],
lore_context=LoreStructuralContext("Eldoria", None, {}, []))]
assert out == ["tok"]
assert "Eldoria" in llm.system_prompt

View File

@@ -0,0 +1,72 @@
"""Tests du découpage de texte (app.application.chunking).
Vérifie le découpage par paragraphes vers une cible de tokens, le découpage des
paragraphes géants, le recouvrement (overlap), et le split_in_half du repli
anti-troncature. tiktoken (cl100k_base) est déterministe → assertions stables.
"""
from __future__ import annotations
from app.application.chunking import chunk_text, split_in_half
def test_empty_text_returns_no_chunk():
assert chunk_text("") == []
assert chunk_text(" \n\n ") == []
def test_short_text_stays_single_chunk():
chunks = chunk_text("Paragraphe un.\n\nParagraphe deux.", target_tokens=1000)
assert len(chunks) == 1
assert "Paragraphe un." in chunks[0]
assert "Paragraphe deux." in chunks[0]
def test_splits_into_several_chunks_when_exceeding_target():
paras = [f"Paragraphe numero {i} avec un peu de contenu." for i in range(20)]
full = "\n\n".join(paras)
chunks = chunk_text(full, target_tokens=20)
assert len(chunks) > 1
# Aucun paragraphe perdu : tous présents quelque part.
joined = "\n\n".join(chunks)
for p in paras:
assert p in joined
def test_oversized_single_paragraph_is_split():
# Un seul paragraphe (aucun "\n\n") plus gros que la cible → plusieurs sous-blocs.
huge = "mot " * 500
chunks = chunk_text(huge, target_tokens=50)
assert len(chunks) > 1
def test_overlap_repeats_content_without_losing_paragraphs():
paras = [f"Bloc {i} de texte distinct." for i in range(12)]
full = "\n\n".join(paras)
chunks = chunk_text(full, target_tokens=20, overlap_tokens=10)
assert len(chunks) > 1
joined = "\n\n".join(chunks)
for p in paras:
assert p in joined
# --- split_in_half -------------------------------------------------------------
def test_split_in_half_too_short_returns_empty():
assert split_in_half("court") == ("", "")
def test_split_in_half_splits_on_newline_near_middle():
text = "A" * 300 + "\n" + "B" * 300
left, right = split_in_half(text)
assert left and right
assert left.startswith("A")
assert right.startswith("B")
def test_split_in_half_halves_cover_all_content():
text = "\n".join(f"ligne {i} " + "x" * 20 for i in range(40))
left, right = split_in_half(text)
assert left and right
# Le découpage ne perd rien : la concaténation contient début et fin.
assert "ligne 0" in left
assert "ligne 39" in right

View File

@@ -0,0 +1,102 @@
"""Tests des adapters d'embeddings (Mistral cloud + Ollama local)."""
from __future__ import annotations
import json
import httpx
import pytest
import respx
from app.application.embeddings import EmbeddingError
from app.core.config import Settings
from app.infrastructure.mistral_embedding_adapter import MistralEmbeddingProvider
from app.infrastructure.ollama_embedding_adapter import OllamaEmbeddingProvider
_MISTRAL = "https://api.mistral.ai/v1/embeddings"
_OLLAMA = "http://ollama:11434/api/embed"
def _settings(**kw) -> Settings:
base = dict(_env_file=None, llm_timeout_seconds=30, ollama_base_url="http://ollama:11434")
base.update(kw)
return Settings(**base)
# --- Mistral -------------------------------------------------------------------
def test_mistral_missing_key_raises_at_construction():
with pytest.raises(EmbeddingError):
MistralEmbeddingProvider(_settings(mistral_api_key=""))
async def test_mistral_empty_texts_short_circuits():
svc = MistralEmbeddingProvider(_settings(mistral_api_key="k"))
assert await svc.embed([]) == []
@respx.mock
async def test_mistral_returns_vectors():
respx.post(_MISTRAL).mock(return_value=httpx.Response(200, json={
"data": [{"embedding": [0.1, 0.2]}, {"embedding": [0.3, 0.4]}]
}))
svc = MistralEmbeddingProvider(_settings(mistral_api_key="k", mistral_embedding_model="mistral-embed"))
vectors = await svc.embed(["texte un", "texte deux"])
assert vectors == [[0.1, 0.2], [0.3, 0.4]]
@respx.mock
async def test_mistral_http_error_raises():
respx.post(_MISTRAL).mock(return_value=httpx.Response(429, text="rate limit"))
svc = MistralEmbeddingProvider(_settings(mistral_api_key="k"))
with pytest.raises(EmbeddingError) as exc:
await svc.embed(["x"])
assert "429" in str(exc.value)
@respx.mock
async def test_mistral_size_mismatch_raises():
respx.post(_MISTRAL).mock(return_value=httpx.Response(200, json={"data": [{"embedding": [0.1]}]}))
svc = MistralEmbeddingProvider(_settings(mistral_api_key="k"))
with pytest.raises(EmbeddingError):
await svc.embed(["a", "b"]) # 2 demandés, 1 reçu
# --- Ollama --------------------------------------------------------------------
async def test_ollama_empty_texts_short_circuits():
svc = OllamaEmbeddingProvider(_settings(ollama_embedding_model="nomic-embed-text"))
assert await svc.embed([]) == []
@respx.mock
async def test_ollama_returns_vectors():
respx.post(_OLLAMA).mock(return_value=httpx.Response(200, json={"embeddings": [[0.1], [0.2]]}))
svc = OllamaEmbeddingProvider(_settings(ollama_embedding_model="mxbai-embed-large"))
assert await svc.embed(["a", "b"]) == [[0.1], [0.2]]
@respx.mock
async def test_ollama_applies_nomic_task_prefix():
route = respx.post(_OLLAMA).mock(return_value=httpx.Response(200, json={"embeddings": [[0.0]]}))
svc = OllamaEmbeddingProvider(_settings(ollama_embedding_model="nomic-embed-text"))
await svc.embed(["question ?"], kind="query")
sent = json.loads(route.calls.last.request.content)["input"]
assert sent == ["search_query: question ?"]
@respx.mock
async def test_ollama_no_prefix_for_non_nomic_model():
route = respx.post(_OLLAMA).mock(return_value=httpx.Response(200, json={"embeddings": [[0.0]]}))
svc = OllamaEmbeddingProvider(_settings(ollama_embedding_model="mxbai-embed-large"))
await svc.embed(["doc"], kind="document")
sent = json.loads(route.calls.last.request.content)["input"]
assert sent == ["doc"]
@respx.mock
async def test_ollama_http_error_mentions_pull_hint():
respx.post(_OLLAMA).mock(return_value=httpx.Response(404, text="model not found"))
svc = OllamaEmbeddingProvider(_settings(ollama_embedding_model="nomic-embed-text"))
with pytest.raises(EmbeddingError) as exc:
await svc.embed(["x"])
assert "ollama pull" in str(exc.value)

View File

@@ -0,0 +1,65 @@
"""Tests du use case de génération de page (app.application.generate_page)."""
from __future__ import annotations
import pytest
from app.application.generate_page import GeneratePageUseCase
from app.domain.models import PageGenerationContext
from app.domain.ports import LLMProviderError
_CTX = PageGenerationContext(
lore_name="Eldoria",
folder_name="PNJ",
template_name="Personnage",
template_fields=["apparence", "histoire"],
page_title="Aragorn",
lore_description="un monde sombre",
)
def test_build_prompt_includes_context_and_fields():
p = GeneratePageUseCase._build_prompt(_CTX, "fr")
assert "Eldoria" in p
assert "Aragorn" in p
assert '"apparence"' in p
assert "un monde sombre" in p
def test_build_prompt_omits_lore_description_when_absent():
ctx = PageGenerationContext("L", "F", "T", ["a"], "Titre", None)
assert "Description de l'univers" not in GeneratePageUseCase._build_prompt(ctx)
def test_parse_values_keeps_only_expected_fields():
out = GeneratePageUseCase._parse_values(
'{"apparence":"grand","histoire":"longue","extra":"ignoré"}',
["apparence", "histoire"])
assert out == {"apparence": "grand", "histoire": "longue"}
def test_parse_values_missing_field_becomes_empty_string():
out = GeneratePageUseCase._parse_values('{"apparence":"grand"}', ["apparence", "histoire"])
assert out == {"apparence": "grand", "histoire": ""}
def test_parse_values_casts_to_str_and_strips():
out = GeneratePageUseCase._parse_values('{"n": 42, "s": " x "}', ["n", "s"])
assert out == {"n": "42", "s": "x"}
def test_parse_values_bad_json_raises():
with pytest.raises(LLMProviderError):
GeneratePageUseCase._parse_values("pas du json", ["a"])
def test_parse_values_non_object_raises():
with pytest.raises(LLMProviderError):
GeneratePageUseCase._parse_values("[1, 2]", ["a"])
async def test_execute_returns_filtered_result():
class FakeLLM:
async def generate(self, prompt, *, output_format=None, temperature=None):
return '{"apparence":"grand","histoire":"épique","parasite":"x"}'
result = await GeneratePageUseCase(FakeLLM()).execute(_CTX)
assert result.values == {"apparence": "grand", "histoire": "épique"}

View File

@@ -0,0 +1,95 @@
"""Tests des parseurs robustes des use cases d'import (méthodes statiques).
_parse_payload (campagne), _parse_sections / _parse_anchors (règles) : transforment
la réponse brute du LLM en structure exploitable + un drapeau « tronqué » qui
déclenche le re-découpage.
"""
from __future__ import annotations
from app.application.import_campaign import ImportCampaignUseCase
from app.application.import_rules import ImportRulesUseCase
_parse_payload = ImportCampaignUseCase._parse_payload
_parse_sections = ImportRulesUseCase._parse_sections
_parse_anchors = ImportRulesUseCase._parse_anchors
# --- campagne : _parse_payload -------------------------------------------------
def test_parse_payload_valid():
payload, truncated = _parse_payload('{"arcs":[{"name":"A"}],"npcs":[{"name":"N"}]}', index=0)
assert truncated is False
assert payload == {"arcs": [{"name": "A"}], "npcs": [{"name": "N"}]}
def test_parse_payload_truncated_flags_recut():
payload, truncated = _parse_payload('{"arcs":[{"name":"A"', index=0)
assert truncated is True
assert payload == {"arcs": [], "npcs": []}
def test_parse_payload_prose_is_empty_not_truncated():
payload, truncated = _parse_payload('juste de la prose sans json', index=0)
assert truncated is False
assert payload == {"arcs": [], "npcs": []}
def test_parse_payload_recovers_truncated_array():
raw = '{"arcs":[{"name":"A"},{"name":"B"},{"name":'
payload, truncated = _parse_payload(raw, index=0)
assert truncated is True
assert payload == {"arcs": [{"name": "A"}, {"name": "B"}], "npcs": []}
def test_parse_payload_coerces_non_list_fields():
payload, _ = _parse_payload('{"arcs":"oops","npcs":null}', index=0)
assert payload == {"arcs": [], "npcs": []}
# --- règles : _parse_sections --------------------------------------------------
def test_parse_sections_valid_and_normalized():
sections, truncated = _parse_sections('{"sections":{"Combat":"texte"}}', index=0)
assert truncated is False
assert sections == {"Combat": "texte"}
def test_parse_sections_truncated():
sections, truncated = _parse_sections('{"Combat":"texte non termin', index=0)
assert truncated is True
assert sections == {}
def test_parse_sections_prose_is_empty():
sections, truncated = _parse_sections('pas de json ici', index=0)
assert truncated is False
assert sections == {}
# --- règles (mode segmentation) : _parse_anchors -------------------------------
def test_parse_anchors_locates_and_splits_text():
text = "Préambule.\nLE COMBAT commence ici, brutal.\nLA MAGIE ensuite, subtile."
raw = ('{"sections":[{"titre":"Combat","debut":"LE COMBAT commence"},'
'{"titre":"Magie","debut":"LA MAGIE ensuite"}]}')
sections, truncated = _parse_anchors(raw, text, index=0)
assert truncated is False
assert "Combat" in sections and "Magie" in sections
# La 1re section absorbe le préambule (avant la 1re ancre).
assert "Préambule." in sections["Combat"]
assert "LE COMBAT commence ici, brutal." in sections["Combat"]
assert "LA MAGIE ensuite, subtile." in sections["Magie"]
def test_parse_anchors_unparseable_returns_empty():
sections, truncated = _parse_anchors("pas du json", "texte", index=0)
assert sections == {}
assert truncated is False
def test_parse_anchors_anchor_not_found_is_dropped():
text = "Seulement ce paragraphe existe."
raw = '{"sections":[{"titre":"Fantôme","debut":"ancre absente du texte"}]}'
sections, _ = _parse_anchors(raw, text, index=0)
# Aucune ancre localisée → aucune section.
assert sections == {}

View File

@@ -0,0 +1,30 @@
"""Tests du canal de statut d'import (app.application.import_status)."""
from __future__ import annotations
import asyncio
from app.application import import_status
def test_notify_is_noop_without_queue():
# Hors import (aucune queue installée) : ne lève pas, ne fait rien.
import_status.notify_status("personne n'écoute") # ne doit pas lever
def test_notify_publishes_when_queue_installed():
queue: asyncio.Queue = asyncio.Queue()
token = import_status.set_status_queue(queue)
try:
import_status.notify_status("morceau re-découpé")
assert queue.get_nowait() == "morceau re-découpé"
finally:
import_status.reset_status_queue(token)
def test_reset_restores_noop():
queue: asyncio.Queue = asyncio.Queue()
token = import_status.set_status_queue(queue)
import_status.reset_status_queue(token)
# Après reset : plus de queue active → no-op, la queue reste vide.
import_status.notify_status("ignoré")
assert queue.empty()

View File

@@ -0,0 +1,157 @@
"""Tests des use cases d'import via FAKES (ports LLM + extracteur PDF).
Exerce la chaîne map-reduce complète (extraction → chunking → MAP → REDUCE →
streaming d'événements) SANS réseau ni vrai PDF. `chunk_text` est monkeypatché
pour un découpage déterministe (le chunking est testé à part). `asyncio.sleep`
est neutralisé pour que les backoffs de retry n'imposent aucune attente.
"""
from __future__ import annotations
import pytest
from app.application.import_campaign import ImportCampaignUseCase
from app.application.import_rules import ImportRulesUseCase
from app.domain.models import ExtractedDocument, ExtractedPage
from app.domain.ports import LLMProviderError
# --- fakes ---------------------------------------------------------------------
class FakeExtractor:
def __init__(self, doc: ExtractedDocument) -> None:
self._doc = doc
def extract(self, pdf_bytes: bytes) -> ExtractedDocument:
return self._doc
class ScriptedLLM:
"""Rejoue une réponse par appel (la dernière est répétée si on dépasse)."""
def __init__(self, responses: list) -> None:
self._responses = list(responses)
self.calls = 0
async def generate(self, prompt: str, *, output_format=None, temperature=None) -> str:
r = self._responses[min(self.calls, len(self._responses) - 1)]
self.calls += 1
if isinstance(r, Exception):
raise r
return r
class ContentLLM:
"""Répond selon le CONTENU du prompt (chunk) : (sous-chaîne → réponse/exception)."""
def __init__(self, rules: list) -> None:
self._rules = rules
async def generate(self, prompt: str, *, output_format=None, temperature=None) -> str:
for sub, r in self._rules:
if sub in prompt:
if isinstance(r, Exception):
raise r
return r
raise AssertionError(f"aucune règle ContentLLM ne matche : {prompt[:60]!r}")
def _doc(text: str = "Texte du PDF.", *, ocr: bool = False) -> ExtractedDocument:
return ExtractedDocument(pages=[ExtractedPage(index=0, text=text, used_ocr=ocr)])
@pytest.fixture
def no_sleep(monkeypatch):
async def _noop(_d):
return None
monkeypatch.setattr("asyncio.sleep", _noop)
@pytest.fixture
def one_chunk(monkeypatch):
monkeypatch.setattr("app.application.import_rules.chunk_text", lambda *a, **k: ["chunk"])
monkeypatch.setattr("app.application.import_campaign.chunk_text", lambda *a, **k: ["chunk"])
# --- import de règles ----------------------------------------------------------
async def test_rules_execute_returns_merged_sections(one_chunk):
llm = ScriptedLLM(['{"Combat":"## Combat\\nrègles de combat"}'])
uc = ImportRulesUseCase(llm, FakeExtractor(_doc(ocr=True)))
result = await uc.execute(b"pdf")
assert result.sections == {"Combat": "## Combat\nrègles de combat"}
assert result.page_count == 1
assert result.ocr_page_count == 1
async def test_rules_stream_emits_extracting_start_progress_done(one_chunk):
llm = ScriptedLLM(['{"Magie":"sorts"}'])
uc = ImportRulesUseCase(llm, FakeExtractor(_doc()))
events = [e async for e in uc.stream(b"pdf")]
types = [e["type"] for e in events]
assert types[0] == "extracting"
assert types[1] == "start"
assert "progress" in types
done = events[-1]
assert done["type"] == "done"
assert done["sections"] == {"Magie": "sorts"}
async def test_rules_stream_skips_failed_chunk_but_continues(monkeypatch, no_sleep):
monkeypatch.setattr("app.application.import_rules.chunk_text",
lambda *a, **k: ["AAA premier", "BBB second"])
llm = ContentLLM([
("AAA premier", LLMProviderError("HTTP 503 saturé")),
("BBB second", '{"Magie":"sorts"}'),
])
uc = ImportRulesUseCase(llm, FakeExtractor(_doc()))
events = [e async for e in uc.stream(b"pdf")]
types = [e["type"] for e in events]
assert "chunk_failed" in types
done = events[-1]
assert done["type"] == "done"
assert done["sections"] == {"Magie": "sorts"}
assert done["skipped"] == 1
async def test_rules_stream_all_chunks_fail_emits_error(one_chunk, no_sleep):
llm = ScriptedLLM([LLMProviderError("HTTP 500 panne")])
uc = ImportRulesUseCase(llm, FakeExtractor(_doc()))
events = [e async for e in uc.stream(b"pdf")]
assert events[-1]["type"] == "error"
assert "échoué" in events[-1]["message"]
# --- import de campagne --------------------------------------------------------
_TREE = ('{"arcs":[{"name":"Acte I","description":"intro",'
'"chapters":[{"name":"Ch1","scenes":[{"name":"Sc1"}]}]}],'
'"npcs":[{"name":"Gandalf","description":"magicien"}]}')
async def test_campaign_execute_builds_tree_and_npcs(one_chunk):
uc = ImportCampaignUseCase(ScriptedLLM([_TREE]), FakeExtractor(_doc()))
result = await uc.execute(b"pdf")
assert result.counts() == (1, 1, 1)
assert result.arcs[0].name == "Acte I"
assert result.arcs[0].chapters[0].scenes[0].name == "Sc1"
assert [n.name for n in result.npcs] == ["Gandalf"]
async def test_campaign_stream_emits_done_with_serialized_tree(one_chunk):
uc = ImportCampaignUseCase(ScriptedLLM([_TREE]), FakeExtractor(_doc()))
events = [e async for e in uc.stream(b"pdf")]
types = [e["type"] for e in events]
assert types[0] == "extracting"
assert types[1] == "start"
assert "progress" in types
done = events[-1]
assert done["type"] == "done"
assert done["arcs"][0]["name"] == "Acte I"
assert done["arcs"][0]["chapters"][0]["scenes"][0]["name"] == "Sc1"
assert done["npcs"] == [{"name": "Gandalf", "description": "magicien"}]
async def test_campaign_stream_all_fail_emits_error(one_chunk, no_sleep):
uc = ImportCampaignUseCase(ScriptedLLM([LLMProviderError("502")]), FakeExtractor(_doc()))
events = [e async for e in uc.stream(b"pdf")]
assert events[-1]["type"] == "error"

View File

@@ -0,0 +1,39 @@
"""Tests de la normalisation de langue (app.core.language)."""
from __future__ import annotations
import pytest
from app.core import language
@pytest.mark.parametrize("raw, expected", [
("fr", "fr"),
("en", "en"),
("EN", "en"),
("en-US", "en"),
("fr-FR,fr;q=0.9,en;q=0.8", "fr"),
("en-GB,en;q=0.9", "en"),
("de", "fr"), # non supporté → défaut
("", "fr"),
(None, "fr"),
(" EN-gb ", "en"), # casse + espaces tolérés
])
def test_normalize(raw, expected):
assert language.normalize(raw) == expected
def test_language_name_known_and_fallback():
assert language.language_name("fr") == "français"
assert language.language_name("en") == "anglais"
# Code inconnu → nom de la langue par défaut.
assert language.language_name("xx") == "français"
def test_instruction_mentions_target_language():
assert "anglais" in language.instruction("en")
assert "français" in language.instruction("fr")
def test_get_user_language_uses_normalize():
assert language.get_user_language("en-US") == "en"
assert language.get_user_language(None) == "fr"

View File

@@ -0,0 +1,130 @@
"""Tests de la lecture robuste de JSON depuis une réponse LLM (app.application.llm_json).
Couvre l'extraction du premier objet équilibré (en ignorant les accolades dans
les chaînes), la réparation d'un JSON tronqué, la détection « ça ressemble à du
JSON coupé », et le strip des blocs de raisonnement <think>…</think>.
"""
from __future__ import annotations
from app.application.llm_json import (
extract_json_object,
load_json_object,
looks_like_truncated_json,
repair_truncated_json,
)
# --- extract_json_object -------------------------------------------------------
def test_extract_simple_object():
assert extract_json_object('{"a": 1}') == '{"a": 1}'
def test_extract_ignores_surrounding_prose_and_fences():
raw = 'Voici le JSON :\n```json\n{"a": 1}\n```\nMerci.'
assert extract_json_object(raw) == '{"a": 1}'
def test_extract_stops_at_first_balanced_object():
assert extract_json_object('{"a": 1} et puis {"b": 2}') == '{"a": 1}'
def test_extract_keeps_nested_object_whole():
assert extract_json_object('{"a": {"b": 1}}') == '{"a": {"b": 1}}'
def test_extract_ignores_braces_inside_strings():
raw = '{"a": "}{ pas du json "}'
assert extract_json_object(raw) == raw
def test_extract_handles_escaped_quote_in_string():
raw = '{"a": "x\\"y"}'
assert extract_json_object(raw) == raw
def test_extract_returns_none_when_unclosed():
assert extract_json_object('{"a": 1') is None
def test_extract_returns_none_without_brace():
assert extract_json_object('aucune accolade ici') is None
def test_extract_returns_none_on_empty():
assert extract_json_object('') is None
# --- load_json_object ----------------------------------------------------------
def test_load_valid_object_not_recovered():
obj, recovered = load_json_object('{"x": 42}')
assert obj == {"x": 42}
assert recovered is False
def test_load_tolerates_raw_control_chars_in_strings():
# Retour à la ligne BRUT dans une chaîne : invalide en strict, accepté ici.
obj, recovered = load_json_object('{"a": "ligne1\nligne2"}')
assert obj == {"a": "ligne1\nligne2"}
assert recovered is False
def test_load_strips_reasoning_block_before_parsing():
raw = '<think>je réfléchis { ] [ }</think>{"ok": true}'
obj, recovered = load_json_object(raw)
assert obj == {"ok": True}
assert recovered is False
def test_load_repairs_truncated_array_and_flags_recovered():
raw = '{"items": [{"a": 1}, {"b": 2}, {"c":'
obj, recovered = load_json_object(raw)
assert obj == {"items": [{"a": 1}, {"b": 2}]}
assert recovered is True
def test_load_returns_none_on_garbage():
obj, recovered = load_json_object('juste de la prose sans json')
assert obj is None
assert recovered is False
# --- looks_like_truncated_json -------------------------------------------------
def test_truncated_detection_no_brace_is_false():
assert looks_like_truncated_json('rien') is False
def test_truncated_detection_short_object_start_unbalanced_is_true():
# Démarre par '{' et déséquilibré → coupé net, même très court.
assert looks_like_truncated_json('{"') is True
def test_truncated_detection_balanced_object_is_false():
assert looks_like_truncated_json('{"a": 1}') is False
def test_truncated_detection_short_prose_with_braces_is_false():
assert looks_like_truncated_json('texte { incomplet') is False
def test_truncated_detection_long_prose_unbalanced_is_true():
raw = 'prose ' * 30 + '{ structure ouverte mais jamais refermée'
assert len(raw) >= 100
assert looks_like_truncated_json(raw) is True
# --- repair_truncated_json -----------------------------------------------------
def test_repair_closes_open_containers_after_last_complete_element():
repaired = repair_truncated_json('{"items": [{"a": 1}, {"b": 2}, {"c":')
assert repaired == '{"items": [{"a": 1}, {"b": 2}]}'
def test_repair_returns_none_when_nothing_complete():
assert repair_truncated_json('{"a": "jamais fermé') is None
def test_repair_returns_none_without_brace():
assert repair_truncated_json('pas de json') is None

View File

@@ -0,0 +1,112 @@
"""Tests du retry des appels LLM one-shot (app.application.llm_retry).
`asyncio.sleep` est neutralisé (et enregistré) pour que les backoffs n'imposent
aucune attente réelle tout en vérifiant les durées choisies.
"""
from __future__ import annotations
import pytest
from app.application import llm_retry
from app.application.llm_retry import (
_is_daily_quota,
_is_rate_limit,
_suggested_retry_after,
generate_with_retry,
)
from app.domain.ports import LLMGenerationTimeout, LLMProviderError
class FakeLLM:
"""LLM factice : rejoue une liste de comportements (exception ou texte)."""
def __init__(self, behaviors: list) -> None:
self._behaviors = list(behaviors)
self.calls = 0
async def generate(self, prompt: str, *, output_format=None, temperature=None) -> str:
b = self._behaviors[self.calls]
self.calls += 1
if isinstance(b, Exception):
raise b
return b
@pytest.fixture
def slept(monkeypatch):
"""Neutralise asyncio.sleep et enregistre les durées demandées."""
recorded: list[float] = []
async def fake_sleep(d):
recorded.append(d)
monkeypatch.setattr("asyncio.sleep", fake_sleep)
return recorded
# --- helpers de classification -------------------------------------------------
def test_is_rate_limit():
assert _is_rate_limit(LLMProviderError("HTTP 429 Too Many Requests"))
assert _is_rate_limit(LLMProviderError("rate limit reached"))
assert not _is_rate_limit(LLMProviderError("HTTP 500"))
def test_is_daily_quota():
assert _is_daily_quota(LLMProviderError("free-models-per-day limit"))
assert _is_daily_quota(LLMProviderError("quota per day exceeded"))
assert not _is_daily_quota(LLMProviderError("429 per-minute"))
def test_suggested_retry_after():
assert _suggested_retry_after(LLMProviderError('{"retry_after_seconds": 8}')) == 8.0
assert _suggested_retry_after(LLMProviderError('Retry-After: 12')) == 12.0
assert _suggested_retry_after(LLMProviderError("pas de hint")) is None
# --- generate_with_retry -------------------------------------------------------
async def test_returns_on_first_success(slept):
llm = FakeLLM(["réponse"])
assert await generate_with_retry(llm, "p") == "réponse"
assert llm.calls == 1
assert slept == []
async def test_retries_transient_error_then_succeeds(slept):
llm = FakeLLM([LLMProviderError("HTTP 503"), "ok"])
assert await generate_with_retry(llm, "p") == "ok"
assert llm.calls == 2
assert slept == [3.0] # _BASE_DELAY_SECONDS
async def test_timeout_raises_immediately_without_retry(slept):
llm = FakeLLM([LLMGenerationTimeout("trop lent")])
with pytest.raises(LLMGenerationTimeout):
await generate_with_retry(llm, "p")
assert llm.calls == 1
assert slept == []
async def test_daily_quota_aborts_immediately(slept):
llm = FakeLLM([LLMProviderError("free-models-per-day exceeded")])
with pytest.raises(LLMProviderError):
await generate_with_retry(llm, "p")
assert llm.calls == 1
assert slept == []
async def test_exhausts_attempts_then_raises_last(slept):
llm = FakeLLM([LLMProviderError("503 a"), LLMProviderError("503 b"), LLMProviderError("503 c")])
with pytest.raises(LLMProviderError, match="503 c"):
await generate_with_retry(llm, "p")
assert llm.calls == 3
# 2 attentes entre 3 tentatives (backoff exponentiel 3s puis 6s).
assert slept == [3.0, 6.0]
async def test_rate_limit_respects_suggested_retry_after(slept):
llm = FakeLLM([LLMProviderError('429 {"retry_after_seconds": 8}'), "ok"])
assert await generate_with_retry(llm, "p") == "ok"
# min(8 + 2, 60) = 10
assert slept == [10.0]

View File

@@ -0,0 +1,52 @@
"""Tests de la logique portée par les modèles de domaine (app.domain.models)."""
from __future__ import annotations
from app.domain.models import (
ArcProposal,
CampaignImportResult,
ChapterProposal,
ExtractedDocument,
ExtractedPage,
RulesImportResult,
SceneProposal,
)
def test_extracted_document_properties():
doc = ExtractedDocument(pages=[
ExtractedPage(index=0, text="page un", used_ocr=False),
ExtractedPage(index=1, text="page deux", used_ocr=True),
ExtractedPage(index=2, text=" ", used_ocr=False), # vide → exclue de full_text
])
assert doc.page_count == 3
assert doc.ocr_page_count == 1
assert doc.full_text == "page un\n\npage deux"
def test_rules_import_result_to_markdown():
result = RulesImportResult(
sections={"Combat": "règles de combat", "Magie": "règles de magie"},
page_count=10, ocr_page_count=0,
)
md = result.to_markdown()
assert "## Combat\n\nrègles de combat" in md
assert "## Magie\n\nrègles de magie" in md
assert md.endswith("\n")
def test_campaign_import_result_counts():
arcs = [
ArcProposal("A1", "", chapters=[
ChapterProposal("C1", "", scenes=[SceneProposal("S1", ""), SceneProposal("S2", "")]),
ChapterProposal("C2", "", scenes=[SceneProposal("S3", "")]),
]),
ArcProposal("A2", "", chapters=[]),
]
result = CampaignImportResult(arcs=arcs, page_count=1, ocr_page_count=0)
assert result.counts() == (2, 2, 3)
def test_arc_proposal_defaults():
arc = ArcProposal("Acte", "synopsis")
assert arc.arc_type == "LINEAR"
assert arc.chapters == []

View File

@@ -0,0 +1,95 @@
"""Tests de caractérisation de l'adapter Ollama (protocole propre : /api/generate
one-shot + /api/chat NDJSON streamé)."""
from __future__ import annotations
import json
import httpx
import pytest
import respx
from app.core.config import Settings
from app.domain.models import ChatMessage
from app.domain.ports import LLMGenerationTimeout, LLMProviderError
from app.infrastructure.ollama_adapter import OllamaLLMProvider
_GEN = "http://ollama:11434/api/generate"
_CHAT = "http://ollama:11434/api/chat"
def _svc() -> OllamaLLMProvider:
s = Settings(_env_file=None, ollama_base_url="http://ollama:11434",
llm_model="gemma", llm_timeout_seconds=30, llm_num_ctx=8192)
return OllamaLLMProvider(s)
@respx.mock
async def test_generate_returns_response_field():
respx.post(_GEN).mock(return_value=httpx.Response(200, json={"response": "texte", "done_reason": "stop"}))
assert await _svc().generate("prompt") == "texte"
@respx.mock
async def test_generate_payload_always_sends_num_ctx_and_omits_temperature():
route = respx.post(_GEN).mock(return_value=httpx.Response(200, json={"response": "x"}))
await _svc().generate("p")
body = json.loads(route.calls.last.request.content)
assert body["model"] == "gemma"
assert body["stream"] is False
assert body["options"] == {"num_ctx": 8192}
assert "format" not in body
@respx.mock
async def test_generate_payload_includes_temperature_and_format_when_given():
route = respx.post(_GEN).mock(return_value=httpx.Response(200, json={"response": "x"}))
await _svc().generate("p", output_format="json", temperature=0.1)
body = json.loads(route.calls.last.request.content)
assert body["options"]["temperature"] == 0.1
assert body["format"] == "json"
@respx.mock
async def test_generate_http_error_surfaces_ollama_message():
respx.post(_GEN).mock(return_value=httpx.Response(404, json={"error": "model 'x' not found"}))
with pytest.raises(LLMProviderError) as exc:
await _svc().generate("p")
assert "not found" in str(exc.value)
assert "404" in str(exc.value)
@respx.mock
async def test_generate_read_timeout_is_generation_timeout():
respx.post(_GEN).mock(side_effect=httpx.ReadTimeout("trop lent"))
with pytest.raises(LLMGenerationTimeout):
await _svc().generate("p")
@respx.mock
async def test_generate_connect_timeout_is_provider_error():
respx.post(_GEN).mock(side_effect=httpx.ConnectTimeout("injoignable"))
with pytest.raises(LLMProviderError) as exc:
await _svc().generate("p")
assert not isinstance(exc.value, LLMGenerationTimeout)
@respx.mock
async def test_stream_chat_yields_tokens_until_done():
body = (
'{"message":{"content":"Bon"},"done":false}\n'
'{"message":{"content":"jour"},"done":false}\n'
'{"done":true}\n'
)
respx.post(_CHAT).mock(return_value=httpx.Response(200, text=body))
tokens = [t async for t in _svc().stream_chat([ChatMessage(role="user", content="hi")])]
assert tokens == ["Bon", "jour"]
@respx.mock
async def test_stream_chat_prepends_system_prompt():
route = respx.post(_CHAT).mock(return_value=httpx.Response(200, text='{"done":true}\n'))
_ = [t async for t in _svc().stream_chat(
[ChatMessage(role="user", content="Q")], system_prompt="SYS")]
body = json.loads(route.calls.last.request.content)
assert body["messages"][0] == {"role": "system", "content": "SYS"}
assert body["messages"][-1] == {"role": "user", "content": "Q"}

View File

@@ -0,0 +1,110 @@
"""Tests de l'adapter 1min.ai (API propriétaire : prompt unique aplati, SSE
`event: content`/`data:{content}`)."""
from __future__ import annotations
import httpx
import pytest
import respx
from app.core.config import Settings
from app.domain.models import ChatMessage
from app.domain.ports import LLMProviderError
from app.infrastructure.onemin_adapter import OneMinAiLLMProvider
_URL = "https://api.1min.ai/api/chat-with-ai?isStreaming=true"
def _svc() -> OneMinAiLLMProvider:
s = Settings(_env_file=None, onemin_api_key="k", onemin_model="gpt-4o-mini",
llm_timeout_seconds=30)
return OneMinAiLLMProvider(s)
def _sse(*blocks: str) -> str:
return "".join(blocks)
# --- streaming -----------------------------------------------------------------
@respx.mock
async def test_generate_collects_content_chunks():
body = _sse(
"event: content\ndata: {\"content\": \"Bon\"}\n\n",
"event: content\ndata: {\"content\": \"jour\"}\n\n",
"event: done\ndata: {}\n\n",
)
respx.post(_URL).mock(return_value=httpx.Response(200, text=body))
assert await _svc().generate("salut") == "Bonjour"
@respx.mock
async def test_generate_sends_api_key_header_and_prompt_payload():
route = respx.post(_URL).mock(return_value=httpx.Response(
200, text="event: done\ndata: {}\n\n"))
await _svc().generate("ma question")
req = route.calls.last.request
assert req.headers["API-KEY"] == "k"
import json
body = json.loads(req.content)
assert body["model"] == "gpt-4o-mini"
assert body["promptObject"]["prompt"] == "ma question"
@respx.mock
async def test_error_event_raises_provider_error():
body = "event: error\ndata: {\"message\": \"quota dépassé\"}\n\n"
respx.post(_URL).mock(return_value=httpx.Response(200, text=body))
with pytest.raises(LLMProviderError) as exc:
await _svc().generate("p")
assert "quota dépassé" in str(exc.value)
@respx.mock
async def test_http_error_is_translated():
respx.post(_URL).mock(return_value=httpx.Response(502, text="bad gateway"))
with pytest.raises(LLMProviderError) as exc:
await _svc().generate("p")
assert "1min.ai" in str(exc.value)
@respx.mock
async def test_stream_chat_flattens_and_streams():
route = respx.post(_URL).mock(return_value=httpx.Response(
200, text="event: content\ndata: {\"content\": \"R\"}\n\nevent: done\ndata: {}\n\n"))
tokens = [t async for t in _svc().stream_chat(
[ChatMessage(role="user", content="Q")], system_prompt="SYS")]
assert tokens == ["R"]
import json
prompt = json.loads(route.calls.last.request.content)["promptObject"]["prompt"]
assert "[SYSTEM]" in prompt and "SYS" in prompt
assert "[USER]" in prompt and "Q" in prompt
# --- helpers purs --------------------------------------------------------------
def test_flatten_messages_structure():
out = OneMinAiLLMProvider._flatten_messages(
[ChatMessage(role="user", content="Q1"), ChatMessage(role="assistant", content="R1")],
"instructions système",
)
assert "[SYSTEM]\ninstructions système" in out
assert "[USER]\nQ1" in out
assert "[ASSISTANT]\nR1" in out
assert out.rstrip().endswith("[ASSISTANT]")
def test_extract_content_chunk_json_and_fallback():
assert OneMinAiLLMProvider._extract_content_chunk('{"content": "x"}') == "x"
assert OneMinAiLLMProvider._extract_content_chunk('{"token": "y"}') == "y"
# Non-JSON : filet de sécurité, on renvoie le brut.
assert OneMinAiLLMProvider._extract_content_chunk("texte brut") == "texte brut"
def test_extract_result_reads_nested_result_object():
payload = {"aiRecord": {"aiRecordDetail": {"resultObject": ["partie 1", "partie 2"]}}}
assert OneMinAiLLMProvider._extract_result(payload) == "partie 1partie 2"
def test_extract_result_raises_on_unexpected_schema():
with pytest.raises(LLMProviderError):
OneMinAiLLMProvider._extract_result({"unexpected": True})

View File

@@ -0,0 +1,204 @@
"""Tests de caractérisation des adapters LLM « OpenAI-compatible »
(OpenRouter, Gemini, Mistral).
But : VERROUILLER le comportement observable AVANT d'extraire une classe de base
commune (les trois adapters partageaient ~80 % de code). On couvre via respx
(mock du transport httpx) : collecte du stream, payload envoyé, en-têtes, parsing
SSE, et traduction des erreurs HTTP — sans aucun appel réseau réel.
Ces tests doivent rester verts à l'identique après le refactor.
"""
from __future__ import annotations
import json
import httpx
import pytest
import respx
from app.core.config import Settings
from app.domain.models import ChatMessage
from app.domain.ports import LLMProviderError
from app.infrastructure.gemini_adapter import GeminiLLMProvider
from app.infrastructure.mistral_adapter import MistralLLMProvider
from app.infrastructure.openrouter_adapter import OpenRouterLLMProvider
def _settings(**kw) -> Settings:
return Settings(_env_file=None, llm_timeout_seconds=30, **kw)
def _sse(*contents: str) -> str:
"""Construit un corps SSE OpenAI : une trame `data: {choices:[{delta:{content}}]}`
par fragment, terminé par `data: [DONE]`."""
lines: list[str] = []
for c in contents:
lines.append("data: " + json.dumps({"choices": [{"delta": {"content": c}}]}))
lines.append("")
lines += ["data: [DONE]", ""]
return "\n".join(lines)
# (id, classe, url, kwargs settings (clé+modèle), supporte response_format=json_object)
CASES = [
pytest.param(
OpenRouterLLMProvider,
"https://openrouter.ai/api/v1/chat/completions",
dict(openrouter_api_key="k", openrouter_model="m"),
False,
"OpenRouter",
id="openrouter",
),
pytest.param(
GeminiLLMProvider,
"https://generativelanguage.googleapis.com/v1beta/openai/chat/completions",
dict(gemini_api_key="k", gemini_model="m"),
True,
"Gemini",
id="gemini",
),
pytest.param(
MistralLLMProvider,
"https://api.mistral.ai/v1/chat/completions",
dict(mistral_api_key="k", mistral_model="m"),
True,
"Mistral",
id="mistral",
),
]
@pytest.mark.parametrize("cls, url, skw, supports_json, label", CASES)
@respx.mock
async def test_generate_collects_full_stream(cls, url, skw, supports_json, label):
respx.post(url).mock(return_value=httpx.Response(200, text=_sse("Bonjour", " le", " monde")))
svc = cls(_settings(**skw))
assert await svc.generate("salut") == "Bonjour le monde"
@pytest.mark.parametrize("cls, url, skw, supports_json, label", CASES)
@respx.mock
async def test_stream_chat_yields_tokens(cls, url, skw, supports_json, label):
respx.post(url).mock(return_value=httpx.Response(200, text=_sse("A", "B", "C")))
svc = cls(_settings(**skw))
tokens = [t async for t in svc.stream_chat([ChatMessage(role="user", content="hi")])]
assert tokens == ["A", "B", "C"]
@pytest.mark.parametrize("cls, url, skw, supports_json, label", CASES)
@respx.mock
async def test_payload_system_prompt_and_temperature(cls, url, skw, supports_json, label):
route = respx.post(url).mock(return_value=httpx.Response(200, text=_sse("x")))
svc = cls(_settings(**skw))
_ = [t async for t in svc.stream_chat(
[ChatMessage(role="user", content="Q")],
system_prompt="SYS",
temperature=0.5,
)]
body = json.loads(route.calls.last.request.content)
assert body["model"] == "m"
assert body["stream"] is True
assert body["messages"][0] == {"role": "system", "content": "SYS"}
assert body["messages"][-1] == {"role": "user", "content": "Q"}
assert body["temperature"] == 0.5
@pytest.mark.parametrize("cls, url, skw, supports_json, label", CASES)
@respx.mock
async def test_payload_omits_temperature_when_none(cls, url, skw, supports_json, label):
route = respx.post(url).mock(return_value=httpx.Response(200, text=_sse("x")))
svc = cls(_settings(**skw))
await svc.generate("p")
body = json.loads(route.calls.last.request.content)
assert "temperature" not in body
# Sans system_prompt, generate envoie un unique message user.
assert body["messages"] == [{"role": "user", "content": "p"}]
@pytest.mark.parametrize("cls, url, skw, supports_json, label", CASES)
@respx.mock
async def test_response_format_json_only_when_supported(cls, url, skw, supports_json, label):
route = respx.post(url).mock(return_value=httpx.Response(200, text=_sse("{}")))
svc = cls(_settings(**skw))
await svc.generate("p", output_format="json")
body = json.loads(route.calls.last.request.content)
if supports_json:
assert body["response_format"] == {"type": "json_object"}
else:
assert "response_format" not in body
@pytest.mark.parametrize("cls, url, skw, supports_json, label", CASES)
@respx.mock
async def test_authorization_header_bearer(cls, url, skw, supports_json, label):
route = respx.post(url).mock(return_value=httpx.Response(200, text=_sse("x")))
svc = cls(_settings(**skw))
await svc.generate("p")
assert route.calls.last.request.headers["Authorization"] == "Bearer k"
@respx.mock
async def test_openrouter_attribution_headers():
route = respx.post("https://openrouter.ai/api/v1/chat/completions").mock(
return_value=httpx.Response(200, text=_sse("x")))
svc = OpenRouterLLMProvider(_settings(openrouter_api_key="k", openrouter_model="m"))
await svc.generate("p")
headers = route.calls.last.request.headers
assert headers["HTTP-Referer"] == "https://loremind.app"
assert headers["X-Title"] == "LoreMind"
@pytest.mark.parametrize("cls, url, skw, supports_json, label", CASES)
@respx.mock
async def test_http_error_translated_to_provider_error(cls, url, skw, supports_json, label):
respx.post(url).mock(return_value=httpx.Response(429, text="quota exceeded"))
svc = cls(_settings(**skw))
with pytest.raises(LLMProviderError) as exc:
await svc.generate("p")
msg = str(exc.value)
assert label in msg
assert "429" in msg
assert "quota exceeded" in msg
@respx.mock
async def test_gemini_rejected_key_gives_actionable_message():
respx.post(
"https://generativelanguage.googleapis.com/v1beta/openai/chat/completions"
).mock(return_value=httpx.Response(403, text="API key not valid"))
svc = GeminiLLMProvider(_settings(gemini_api_key="k", gemini_model="m"))
with pytest.raises(LLMProviderError) as exc:
await svc.generate("p")
assert "refusée par Google" in str(exc.value)
@pytest.mark.parametrize("cls, url, skw, supports_json, label", CASES)
@respx.mock
async def test_sse_skips_keepalive_and_malformed_lines(cls, url, skw, supports_json, label):
body = "\n".join([
": OPENROUTER PROCESSING", # commentaire keep-alive
"",
"data: not-json", # JSON invalide -> ignoré
"",
"data: " + json.dumps({"choices": []}), # pas de choix -> ignoré
"",
"data: " + json.dumps({"choices": [{"delta": {}}]}), # delta sans content -> ignoré
"",
"data: " + json.dumps({"choices": [{"delta": {"content": "OK"}}]}),
"",
"data: [DONE]",
"",
])
respx.post(url).mock(return_value=httpx.Response(200, text=body))
svc = cls(_settings(**skw))
assert await svc.generate("p") == "OK"
@pytest.mark.parametrize("cls, skw", [
pytest.param(OpenRouterLLMProvider, dict(openrouter_api_key=""), id="openrouter"),
pytest.param(GeminiLLMProvider, dict(gemini_api_key=""), id="gemini"),
pytest.param(MistralLLMProvider, dict(mistral_api_key=""), id="mistral"),
])
def test_missing_api_key_raises_at_construction(cls, skw):
with pytest.raises(LLMProviderError):
cls(_settings(**skw))

View File

@@ -0,0 +1,54 @@
"""Tests de la réécriture de question autonome (app.application.query_rewrite)."""
from __future__ import annotations
from app.application.query_rewrite import standalone_question
from app.domain.models import ChatMessage
class FakeLLM:
def __init__(self, response: str | None = None, exc: Exception | None = None) -> None:
self.response = response
self.exc = exc
self.called = False
async def generate(self, prompt, *, temperature=None, output_format=None) -> str:
self.called = True
if self.exc:
raise self.exc
return self.response
async def test_single_turn_returns_last_user_without_calling_llm():
llm = FakeLLM()
q = await standalone_question(llm, [ChatMessage(role="user", content="Qui est Strahd ?")])
assert q == "Qui est Strahd ?"
assert llm.called is False
async def test_multi_turn_uses_llm_rewrite_and_strips_quotes():
llm = FakeLLM(response='"Quelles sont les faiblesses de Strahd ?"')
msgs = [
ChatMessage(role="user", content="Qui est Strahd ?"),
ChatMessage(role="assistant", content="Un vampire."),
ChatMessage(role="user", content="Et ses faiblesses ?"),
]
assert await standalone_question(llm, msgs) == "Quelles sont les faiblesses de Strahd ?"
assert llm.called is True
async def test_llm_failure_falls_back_to_last_user():
llm = FakeLLM(exc=RuntimeError("LLM HS"))
msgs = [ChatMessage(role="user", content="A"), ChatMessage(role="user", content="B")]
assert await standalone_question(llm, msgs) == "B"
async def test_suspiciously_long_rewrite_falls_back():
llm = FakeLLM(response="x" * 500)
msgs = [ChatMessage(role="user", content="A"), ChatMessage(role="user", content="B")]
assert await standalone_question(llm, msgs) == "B"
async def test_empty_messages_returns_empty_string():
llm = FakeLLM()
assert await standalone_question(llm, []) == ""
assert llm.called is False

View File

@@ -0,0 +1,60 @@
"""Tests du reranking LLM des passages RAG (app.application.rerank)."""
from __future__ import annotations
from app.application.rerank import pool_size, rerank
class FakeLLM:
def __init__(self, response: str | None = None, exc: Exception | None = None) -> None:
self.response = response
self.exc = exc
async def generate(self, prompt, *, temperature=None, output_format=None) -> str:
if self.exc:
raise self.exc
return self.response
def test_pool_size():
assert pool_size(8) == 24 # min(max(24, 8), 24)
assert pool_size(4) == 12 # 4 * 3
assert pool_size(10) == 24 # plafonné à POOL_MAX
assert pool_size(1) == 3
async def test_rerank_skips_when_pool_not_larger_than_top_k():
passages = [{"text": "a"}, {"text": "b"}]
# len <= top_k → renvoyé tel quel, sans appel LLM.
assert await rerank(FakeLLM(exc=AssertionError("ne doit pas être appelé")),
"q", passages, top_k=3) == passages
async def test_rerank_reorders_by_llm_scores():
passages = [{"text": "a"}, {"text": "b"}, {"text": "c"}]
out = await rerank(FakeLLM(response='{"scores":[1, 9, 5]}'), "q", passages, top_k=2)
assert [p["text"] for p in out] == ["b", "c"]
async def test_rerank_stable_on_score_ties():
passages = [{"text": "a"}, {"text": "b"}, {"text": "c"}]
# Notes égales → ordre cosinus d'origine préservé.
out = await rerank(FakeLLM(response='{"scores":[5, 5, 5]}'), "q", passages, top_k=2)
assert [p["text"] for p in out] == ["a", "b"]
async def test_rerank_llm_failure_falls_back_to_cosine_order():
passages = [{"text": "a"}, {"text": "b"}, {"text": "c"}]
out = await rerank(FakeLLM(exc=RuntimeError("LLM HS")), "q", passages, top_k=2)
assert [p["text"] for p in out] == ["a", "b"]
async def test_rerank_wrong_score_count_falls_back():
passages = [{"text": "a"}, {"text": "b"}, {"text": "c"}]
out = await rerank(FakeLLM(response='{"scores":[1, 2]}'), "q", passages, top_k=2)
assert [p["text"] for p in out] == ["a", "b"]
async def test_rerank_non_numeric_scores_fall_back():
passages = [{"text": "a"}, {"text": "b"}, {"text": "c"}]
out = await rerank(FakeLLM(response='{"scores":["x","y","z"]}'), "q", passages, top_k=2)
assert [p["text"] for p in out] == ["a", "b"]

View File

@@ -0,0 +1,107 @@
"""Tests des helpers de l'import de règles (app.application.import_rules) :
_SectionMerger, _normalize_sections, _coerce_markdown, _find_anchor, _combine_sections.
"""
from __future__ import annotations
from app.application.import_rules import (
_SectionMerger,
_coerce_markdown,
_combine_sections,
_find_anchor,
_normalize_sections,
)
# --- _SectionMerger ------------------------------------------------------------
def test_section_merger_case_insensitive_and_joins():
m = _SectionMerger()
touched = m.add({"Combat": "règle A", "combat": "règle B"})
assert touched == ["Combat"] # clé canonique = 1re vue
res = m.result()
assert list(res.keys()) == ["Combat"]
assert res["Combat"] == "règle A\n\nrègle B"
def test_section_merger_skips_empty_title_or_content():
m = _SectionMerger()
touched = m.add({"": "x", "Titre": " ", "Vrai": "contenu"})
assert touched == ["Vrai"]
assert m.result() == {"Vrai": "contenu"}
def test_section_merger_accumulates_across_chunks():
m = _SectionMerger()
m.add({"Combat": "p1"})
m.add({"Combat": "p2", "Magie": "sorts"})
res = m.result()
assert res["Combat"] == "p1\n\np2"
assert res["Magie"] == "sorts"
# --- _normalize_sections -------------------------------------------------------
def test_normalize_unwraps_known_envelope():
assert _normalize_sections({"sections": {"Combat": "x"}}) == {"Combat": "x"}
assert _normalize_sections({"règles": {"A": "y"}}) == {"A": "y"}
def test_normalize_title_content_schema():
assert _normalize_sections({"title": "Combat", "content": "texte"}) == {"Combat": "texte"}
def test_normalize_strips_meta_keys():
assert _normalize_sections({"Combat": "x", "thought": "bla", "notes": "y"}) == {"Combat": "x"}
def test_normalize_passthrough_plain_sections():
assert _normalize_sections({"A": "1", "B": "2"}) == {"A": "1", "B": "2"}
# --- _coerce_markdown ----------------------------------------------------------
def test_coerce_markdown_string_passthrough():
assert _coerce_markdown("texte") == "texte"
def test_coerce_markdown_none_is_empty():
assert _coerce_markdown(None) == ""
def test_coerce_markdown_list_joined():
assert _coerce_markdown(["a", "b"]) == "a\n\nb"
def test_coerce_markdown_dict_flattened():
out = _coerce_markdown({"Sous-titre": "contenu"})
assert "Sous-titre" in out
assert "contenu" in out
# --- _find_anchor --------------------------------------------------------------
def test_find_anchor_exact():
text = "Chapitre 1. Le héros entre."
assert _find_anchor(text, "Le héros entre", 0) == text.index("Le héros entre")
def test_find_anchor_whitespace_flexible():
text = "Le héros\nentre dans la taverne."
# Espaces multiples / saut de ligne dans le texte source, anchor normalisé.
assert _find_anchor(text, "Le héros entre dans la taverne", 0) is not None
def test_find_anchor_case_insensitive():
assert _find_anchor("LE DONJON s'ouvre", "le donjon", 0) is not None
def test_find_anchor_not_found():
assert _find_anchor("texte quelconque", "introuvable xyz", 0) is None
# --- _combine_sections ---------------------------------------------------------
def test_combine_sections_case_insensitive_concat():
out = _combine_sections({"Combat": "p1"}, {"combat": "p2", "Magie": "sorts"})
assert out["Combat"] == "p1\n\np2"
assert out["Magie"] == "sorts"

View File

@@ -0,0 +1,57 @@
"""Tests des overrides runtime persistés (app.core.settings_store).
Le chemin du fichier est redirigé vers un tmp_path pour isoler chaque test.
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from app.core import settings_store
@pytest.fixture(autouse=True)
def isolated_store(tmp_path, monkeypatch):
monkeypatch.setattr(settings_store, "_OVERRIDES_PATH", tmp_path / "settings.json")
return tmp_path / "settings.json"
def test_load_missing_file_returns_empty():
assert settings_store.load_overrides() == {}
def test_save_filters_to_allowlist_and_persists(isolated_store):
result = settings_store.save_overrides({
"llm_model": "gemma3:12b",
"internal_shared_secret": "HACK", # hors allow-list → ignoré
"champ_inconnu": "x", # hors allow-list → ignoré
})
assert result == {"llm_model": "gemma3:12b"}
on_disk = json.loads(Path(isolated_store).read_text(encoding="utf-8"))
assert on_disk == {"llm_model": "gemma3:12b"}
def test_save_merges_with_existing():
settings_store.save_overrides({"llm_model": "a"})
merged = settings_store.save_overrides({"llm_provider": "ollama"})
assert merged == {"llm_model": "a", "llm_provider": "ollama"}
def test_load_ignores_non_allowlisted_keys_on_disk(isolated_store):
Path(isolated_store).write_text(
json.dumps({"llm_model": "ok", "internal_shared_secret": "leak"}),
encoding="utf-8",
)
assert settings_store.load_overrides() == {"llm_model": "ok"}
def test_load_corrupted_file_returns_empty(isolated_store):
Path(isolated_store).write_text("{ pas du json", encoding="utf-8")
assert settings_store.load_overrides() == {}
def test_load_non_dict_json_returns_empty(isolated_store):
Path(isolated_store).write_text("[1, 2, 3]", encoding="utf-8")
assert settings_store.load_overrides() == {}

View File

@@ -0,0 +1,48 @@
"""Tests des heartbeats SSE (app.application.streaming.with_heartbeat)."""
from __future__ import annotations
import asyncio
import pytest
from app.application.streaming import with_heartbeat
async def _collect(agen) -> list[tuple[str, object]]:
return [ev async for ev in agen]
async def test_fast_coro_emits_only_result():
async def quick() -> int:
return 42
events = await _collect(with_heartbeat(quick(), interval=0.05))
assert events == [("result", 42)]
async def test_slow_coro_emits_heartbeats_then_result():
async def slow() -> str:
await asyncio.sleep(0.06)
return "fini"
events = await _collect(with_heartbeat(slow(), interval=0.02))
assert ("heartbeat", None) in events
assert events[-1] == ("result", "fini")
async def test_relays_status_messages_from_queue():
queue: asyncio.Queue = asyncio.Queue()
async def work() -> str:
await asyncio.sleep(0.05)
return "ok"
queue.put_nowait("fournisseur saturé, nouvel essai")
events = await _collect(with_heartbeat(work(), interval=0.02, status_queue=queue))
assert ("status", "fournisseur saturé, nouvel essai") in events
assert events[-1] == ("result", "ok")
async def test_propagates_coro_exception():
async def boom() -> None:
raise ValueError("échec interne")
with pytest.raises(ValueError, match="échec interne"):
await _collect(with_heartbeat(boom(), interval=0.05))

View File

@@ -0,0 +1,149 @@
"""Tests du _TreeMerger de l'import de campagne (app.application.import_campaign).
Cœur du REDUCE : fusion par nom (insensible à la casse) des sous-arbres
arc→chapitre→scène→pièce produits morceau par morceau, + accumulation des PNJ.
"""
from __future__ import annotations
from app.application.import_campaign import _TreeMerger
def test_single_chunk_builds_full_tree():
m = _TreeMerger()
m.add([{
"name": "Acte I", "description": "intro",
"chapters": [{
"name": "Ch1", "description": "d",
"scenes": [{
"name": "Sc1", "description": "s",
"player_narration": "PN", "gm_notes": "GM",
"rooms": [{"name": "R1", "description": "rd", "enemies": "gob", "loot": "or"}],
}],
}],
}])
arcs = m.result()
assert len(arcs) == 1
arc = arcs[0]
assert arc.name == "Acte I"
assert arc.arc_type == "LINEAR"
sc = arc.chapters[0].scenes[0]
assert sc.player_narration == "PN"
assert sc.gm_notes == "GM"
room = sc.rooms[0]
assert (room.name, room.enemies, room.loot) == ("R1", "gob", "or")
def test_case_insensitive_arc_and_chapter_merge():
m = _TreeMerger()
m.add([{"name": "Acte I", "chapters": [{"name": "Ch1", "scenes": []}]}])
m.add([{"name": "acte i", "chapters": [{"name": "ch1", "scenes": []},
{"name": "Ch2", "scenes": []}]}])
arcs = m.result()
assert len(arcs) == 1
assert {c.name for c in arcs[0].chapters} == {"Ch1", "Ch2"}
def test_description_first_non_empty_wins():
m = _TreeMerger()
m.add([{"name": "A", "description": "", "chapters": []}])
m.add([{"name": "A", "description": "vraie", "chapters": []}])
m.add([{"name": "A", "description": "autre", "chapters": []}])
assert m.result()[0].description == "vraie"
def test_hub_type_wins_if_any_chunk_signals_it():
m = _TreeMerger()
m.add([{"name": "A", "type": "LINEAR", "chapters": []}])
m.add([{"name": "A", "type": "HUB", "chapters": []}])
assert m.result()[0].arc_type == "HUB"
def _scene(narr=None, gm=None):
s = {"name": "S"}
if narr is not None:
s["player_narration"] = narr
if gm is not None:
s["gm_notes"] = gm
return {"name": "A", "chapters": [{"name": "C", "scenes": [s]}]}
def test_scene_narration_concatenated_across_chunks():
m = _TreeMerger()
m.add([_scene(narr="début")])
m.add([_scene(narr="suite")])
sc = m.result()[0].chapters[0].scenes[0]
assert sc.player_narration == "début\n\nsuite"
def test_scene_field_dedups_exact_overlap():
m = _TreeMerger()
m.add([_scene(gm="texte identique")])
m.add([_scene(gm="texte identique")])
assert m.result()[0].chapters[0].scenes[0].gm_notes == "texte identique"
def test_scene_field_takes_superset_version():
m = _TreeMerger()
m.add([_scene(gm="court")])
m.add([_scene(gm="court et bien plus long")])
assert m.result()[0].chapters[0].scenes[0].gm_notes == "court et bien plus long"
def test_npcs_longest_description_wins():
m = _TreeMerger()
m.add_npcs([{"name": "Thorin", "description": "court"}])
m.add_npcs([{"name": "thorin", "description": "une description bien plus complète"}])
npcs = m.npcs()
assert len(npcs) == 1
assert npcs[0].name == "Thorin"
assert npcs[0].description == "une description bien plus complète"
def test_counts():
m = _TreeMerger()
m.add([{"name": "A", "chapters": [
{"name": "C1", "scenes": [{"name": "S1"}, {"name": "S2"}]},
{"name": "C2", "scenes": []},
]}])
assert m.counts() == (1, 2, 2)
def test_blank_names_are_skipped():
m = _TreeMerger()
m.add([{"name": "", "chapters": []},
{"name": " ", "chapters": []},
{"name": "OK", "chapters": [{"name": "", "scenes": []}]}])
arcs = m.result()
assert len(arcs) == 1
assert arcs[0].name == "OK"
assert arcs[0].chapters == []
def test_merge_chapters_consolidation():
m = _TreeMerger()
m.add([{"name": "A", "chapters": [
{"name": "Intro", "scenes": [{"name": "S1"}]},
{"name": "Introduction", "scenes": [{"name": "S2"}]},
]}])
assert m.merge_chapters("Intro", ["Introduction"]) is True
chapters = m.result()[0].chapters
assert len(chapters) == 1
assert {s.name for s in chapters[0].scenes} == {"S1", "S2"}
def test_merge_chapters_unknown_target_returns_false():
m = _TreeMerger()
m.add([{"name": "A", "chapters": [{"name": "Intro", "scenes": []}]}])
assert m.merge_chapters("Inexistant", ["Intro"]) is False
def test_merge_scenes_consolidation():
m = _TreeMerger()
m.add([{"name": "A", "chapters": [{"name": "C", "scenes": [
{"name": "Combat", "gm_notes": "x"},
{"name": "Le combat", "gm_notes": "y"},
]}]}])
assert m.merge_scenes("C", "Combat", ["Le combat"]) is True
scenes = m.result()[0].chapters[0].scenes
assert len(scenes) == 1
assert scenes[0].name == "Combat"

View File

@@ -0,0 +1,119 @@
"""Tests du stockage vectoriel fichier + recherche hybride (app.infrastructure.vector_store).
Le répertoire de stockage est redirigé vers un tmp_path et le cache mémoire est
vidé avant chaque test pour une isolation totale.
"""
from __future__ import annotations
import pytest
from app.infrastructure import vector_store
@pytest.fixture(autouse=True)
def isolated_store(tmp_path, monkeypatch):
monkeypatch.setattr(vector_store, "_STORE_DIR", tmp_path)
vector_store._CACHE.clear()
yield
vector_store._CACHE.clear()
# --- cosinus -------------------------------------------------------------------
def test_cosine_identical_is_one():
assert vector_store._cosine([1.0, 0.0], [2.0, 0.0]) == pytest.approx(1.0)
def test_cosine_orthogonal_is_zero():
assert vector_store._cosine([1.0, 0.0], [0.0, 1.0]) == 0.0
def test_cosine_mismatched_or_zero_is_zero():
assert vector_store._cosine([1.0], [1.0, 2.0]) == 0.0
assert vector_store._cosine([0.0, 0.0], [1.0, 1.0]) == 0.0
assert vector_store.cosine_similarity([], [1.0]) == 0.0 # alias public
# --- mots significatifs --------------------------------------------------------
def test_significant_words_filters_stopwords_and_short():
words = vector_store._significant_words("Le dragon DORT dans la caverne avec les gobelins")
assert "dragon" in words
assert "caverne" in words
assert "gobelins" in words
assert "les" not in words and "avec" not in words and "la" not in words
# --- save / exists / delete ----------------------------------------------------
def test_save_then_exists_and_delete():
vector_store.save("src1", ["chunk a"], [[1.0, 0.0]])
assert vector_store.exists("src1") is True
vector_store.delete("src1")
assert vector_store.exists("src1") is False
def test_save_rejects_mismatched_lengths():
with pytest.raises(ValueError):
vector_store.save("s", ["a", "b"], [[1.0]])
with pytest.raises(ValueError):
vector_store.save("s", ["a"], [[1.0]], pages=[1, 2])
def test_all_chunks_returns_text_and_page():
vector_store.save("s", ["t1", "t2"], [[1.0], [2.0]], pages=[3, 7])
chunks = vector_store.all_chunks("s")
assert chunks == [{"text": "t1", "page": 3}, {"text": "t2", "page": 7}]
# --- recherche -----------------------------------------------------------------
def test_search_ranks_by_cosine():
vector_store.save("s", ["proche", "loin"], [[1.0, 0.0], [0.0, 1.0]])
results = vector_store.search(["s"], [1.0, 0.0], top_k=2)
assert [r["text"] for r in results] == ["proche", "loin"]
assert results[0]["score"] > results[1]["score"]
def test_search_respects_top_k():
vector_store.save("s", ["a", "b", "c"], [[1.0], [0.9], [0.8]])
assert len(vector_store.search(["s"], [1.0], top_k=2)) == 2
def test_search_min_score_filters_out_weak_matches():
vector_store.save("s", ["proche", "orthogonal"], [[1.0, 0.0], [0.0, 1.0]])
results = vector_store.search(["s"], [1.0, 0.0], top_k=5, min_score=0.5)
assert [r["text"] for r in results] == ["proche"]
def test_search_lexical_bonus_promotes_exact_term_match():
# Deux extraits de cosinus IDENTIQUE : le bonus lexical départage celui qui
# contient le mot exact de la question.
vector_store.save(
"s",
["Strahd règne sur Barovia", "un texte neutre sans rapport"],
[[1.0, 0.0], [1.0, 0.0]],
)
results = vector_store.search(["s"], [1.0, 0.0], top_k=2, query_text="Strahd")
assert results[0]["text"] == "Strahd règne sur Barovia"
assert results[0]["score"] > results[1]["score"]
def test_search_includes_source_id_and_page():
vector_store.save("livre", ["extrait"], [[1.0]], pages=[42])
[res] = vector_store.search(["livre"], [1.0], top_k=1)
assert res["source_id"] == "livre"
assert res["page"] == 42
# --- résumés (analyse approfondie) ---------------------------------------------
def test_summaries_roundtrip_keyed_by_batch_tokens():
vector_store.save_summaries("s", 1000, [{"summary": "résumé", "vector": [1.0]}])
assert vector_store.load_summaries("s", 1000) == [{"summary": "résumé", "vector": [1.0]}]
# Taille de lot différente → invalidé (le découpage ne correspondrait plus).
assert vector_store.load_summaries("s", 2000) is None
def test_load_summaries_absent_returns_none():
assert vector_store.load_summaries("inconnu", 1000) is None

View File

@@ -0,0 +1,3 @@
wrapperVersion=3.3.4
distributionType=only-script
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.9/apache-maven-3.9.9-bin.zip

295
core/mvnw vendored Normal file
View File

@@ -0,0 +1,295 @@
#!/bin/sh
# ----------------------------------------------------------------------------
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
# ----------------------------------------------------------------------------
# ----------------------------------------------------------------------------
# Apache Maven Wrapper startup batch script, version 3.3.4
#
# Optional ENV vars
# -----------------
# JAVA_HOME - location of a JDK home dir, required when download maven via java source
# MVNW_REPOURL - repo url base for downloading maven distribution
# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output
# ----------------------------------------------------------------------------
set -euf
[ "${MVNW_VERBOSE-}" != debug ] || set -x
# OS specific support.
native_path() { printf %s\\n "$1"; }
case "$(uname)" in
CYGWIN* | MINGW*)
[ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")"
native_path() { cygpath --path --windows "$1"; }
;;
esac
# set JAVACMD and JAVACCMD
set_java_home() {
# For Cygwin and MinGW, ensure paths are in Unix format before anything is touched
if [ -n "${JAVA_HOME-}" ]; then
if [ -x "$JAVA_HOME/jre/sh/java" ]; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
JAVACCMD="$JAVA_HOME/jre/sh/javac"
else
JAVACMD="$JAVA_HOME/bin/java"
JAVACCMD="$JAVA_HOME/bin/javac"
if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then
echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2
echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2
return 1
fi
fi
else
JAVACMD="$(
'set' +e
'unset' -f command 2>/dev/null
'command' -v java
)" || :
JAVACCMD="$(
'set' +e
'unset' -f command 2>/dev/null
'command' -v javac
)" || :
if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then
echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2
return 1
fi
fi
}
# hash string like Java String::hashCode
hash_string() {
str="${1:-}" h=0
while [ -n "$str" ]; do
char="${str%"${str#?}"}"
h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296))
str="${str#?}"
done
printf %x\\n $h
}
verbose() { :; }
[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; }
die() {
printf %s\\n "$1" >&2
exit 1
}
trim() {
# MWRAPPER-139:
# Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds.
# Needed for removing poorly interpreted newline sequences when running in more
# exotic environments such as mingw bash on Windows.
printf "%s" "${1}" | tr -d '[:space:]'
}
scriptDir="$(dirname "$0")"
scriptName="$(basename "$0")"
# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties
while IFS="=" read -r key value; do
case "${key-}" in
distributionUrl) distributionUrl=$(trim "${value-}") ;;
distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;;
esac
done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties"
[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
case "${distributionUrl##*/}" in
maven-mvnd-*bin.*)
MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/
case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in
*AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;;
:Darwin*x86_64) distributionPlatform=darwin-amd64 ;;
:Darwin*arm64) distributionPlatform=darwin-aarch64 ;;
:Linux*x86_64*) distributionPlatform=linux-amd64 ;;
*)
echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2
distributionPlatform=linux-amd64
;;
esac
distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip"
;;
maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;;
*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;;
esac
# apply MVNW_REPOURL and calculate MAVEN_HOME
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}"
distributionUrlName="${distributionUrl##*/}"
distributionUrlNameMain="${distributionUrlName%.*}"
distributionUrlNameMain="${distributionUrlNameMain%-bin}"
MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}"
MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")"
exec_maven() {
unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || :
exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD"
}
if [ -d "$MAVEN_HOME" ]; then
verbose "found existing MAVEN_HOME at $MAVEN_HOME"
exec_maven "$@"
fi
case "${distributionUrl-}" in
*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;;
*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;;
esac
# prepare tmp dir
if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then
clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; }
trap clean HUP INT TERM EXIT
else
die "cannot create temp dir"
fi
mkdir -p -- "${MAVEN_HOME%/*}"
# Download and Install Apache Maven
verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
verbose "Downloading from: $distributionUrl"
verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
# select .zip or .tar.gz
if ! command -v unzip >/dev/null; then
distributionUrl="${distributionUrl%.zip}.tar.gz"
distributionUrlName="${distributionUrl##*/}"
fi
# verbose opt
__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR=''
[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v
# normalize http auth
case "${MVNW_PASSWORD:+has-password}" in
'') MVNW_USERNAME='' MVNW_PASSWORD='' ;;
has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;;
esac
if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then
verbose "Found wget ... using wget"
wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl"
elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then
verbose "Found curl ... using curl"
curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl"
elif set_java_home; then
verbose "Falling back to use Java to download"
javaSource="$TMP_DOWNLOAD_DIR/Downloader.java"
targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName"
cat >"$javaSource" <<-END
public class Downloader extends java.net.Authenticator
{
protected java.net.PasswordAuthentication getPasswordAuthentication()
{
return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() );
}
public static void main( String[] args ) throws Exception
{
setDefault( new Downloader() );
java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() );
}
}
END
# For Cygwin/MinGW, switch paths to Windows format before running javac and java
verbose " - Compiling Downloader.java ..."
"$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java"
verbose " - Running Downloader.java ..."
"$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")"
fi
# If specified, validate the SHA-256 sum of the Maven distribution zip file
if [ -n "${distributionSha256Sum-}" ]; then
distributionSha256Result=false
if [ "$MVN_CMD" = mvnd.sh ]; then
echo "Checksum validation is not supported for maven-mvnd." >&2
echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
exit 1
elif command -v sha256sum >/dev/null; then
if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then
distributionSha256Result=true
fi
elif command -v shasum >/dev/null; then
if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then
distributionSha256Result=true
fi
else
echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2
echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
exit 1
fi
if [ $distributionSha256Result = false ]; then
echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2
echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2
exit 1
fi
fi
# unzip and move
if command -v unzip >/dev/null; then
unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip"
else
tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar"
fi
# Find the actual extracted directory name (handles snapshots where filename != directory name)
actualDistributionDir=""
# First try the expected directory name (for regular distributions)
if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then
if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then
actualDistributionDir="$distributionUrlNameMain"
fi
fi
# If not found, search for any directory with the Maven executable (for snapshots)
if [ -z "$actualDistributionDir" ]; then
# enable globbing to iterate over items
set +f
for dir in "$TMP_DOWNLOAD_DIR"/*; do
if [ -d "$dir" ]; then
if [ -f "$dir/bin/$MVN_CMD" ]; then
actualDistributionDir="$(basename "$dir")"
break
fi
fi
done
set -f
fi
if [ -z "$actualDistributionDir" ]; then
verbose "Contents of $TMP_DOWNLOAD_DIR:"
verbose "$(ls -la "$TMP_DOWNLOAD_DIR")"
die "Could not find Maven distribution directory in extracted archive"
fi
verbose "Found extracted Maven distribution directory: $actualDistributionDir"
printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url"
mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME"
clean || :
exec_maven "$@"

189
core/mvnw.cmd vendored Normal file
View File

@@ -0,0 +1,189 @@
<# : batch portion
@REM ----------------------------------------------------------------------------
@REM Licensed to the Apache Software Foundation (ASF) under one
@REM or more contributor license agreements. See the NOTICE file
@REM distributed with this work for additional information
@REM regarding copyright ownership. The ASF licenses this file
@REM to you under the Apache License, Version 2.0 (the
@REM "License"); you may not use this file except in compliance
@REM with the License. You may obtain a copy of the License at
@REM
@REM http://www.apache.org/licenses/LICENSE-2.0
@REM
@REM Unless required by applicable law or agreed to in writing,
@REM software distributed under the License is distributed on an
@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
@REM KIND, either express or implied. See the License for the
@REM specific language governing permissions and limitations
@REM under the License.
@REM ----------------------------------------------------------------------------
@REM ----------------------------------------------------------------------------
@REM Apache Maven Wrapper startup batch script, version 3.3.4
@REM
@REM Optional ENV vars
@REM MVNW_REPOURL - repo url base for downloading maven distribution
@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output
@REM ----------------------------------------------------------------------------
@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0)
@SET __MVNW_CMD__=
@SET __MVNW_ERROR__=
@SET __MVNW_PSMODULEP_SAVE=%PSModulePath%
@SET PSModulePath=
@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @(
IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B)
)
@SET PSModulePath=%__MVNW_PSMODULEP_SAVE%
@SET __MVNW_PSMODULEP_SAVE=
@SET __MVNW_ARG0_NAME__=
@SET MVNW_USERNAME=
@SET MVNW_PASSWORD=
@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*)
@echo Cannot start maven from wrapper >&2 && exit /b 1
@GOTO :EOF
: end batch / begin powershell #>
$ErrorActionPreference = "Stop"
if ($env:MVNW_VERBOSE -eq "true") {
$VerbosePreference = "Continue"
}
# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties
$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl
if (!$distributionUrl) {
Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
}
switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) {
"maven-mvnd-*" {
$USE_MVND = $true
$distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip"
$MVN_CMD = "mvnd.cmd"
break
}
default {
$USE_MVND = $false
$MVN_CMD = $script -replace '^mvnw','mvn'
break
}
}
# apply MVNW_REPOURL and calculate MAVEN_HOME
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
if ($env:MVNW_REPOURL) {
$MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" }
$distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')"
}
$distributionUrlName = $distributionUrl -replace '^.*/',''
$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$',''
$MAVEN_M2_PATH = "$HOME/.m2"
if ($env:MAVEN_USER_HOME) {
$MAVEN_M2_PATH = "$env:MAVEN_USER_HOME"
}
if (-not (Test-Path -Path $MAVEN_M2_PATH)) {
New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null
}
$MAVEN_WRAPPER_DISTS = $null
if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) {
$MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists"
} else {
$MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists"
}
$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain"
$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join ''
$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME"
if (Test-Path -Path "$MAVEN_HOME" -PathType Container) {
Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME"
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
exit $?
}
if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) {
Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl"
}
# prepare tmp dir
$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile
$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir"
$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null
trap {
if ($TMP_DOWNLOAD_DIR.Exists) {
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
}
}
New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null
# Download and Install Apache Maven
Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
Write-Verbose "Downloading from: $distributionUrl"
Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
$webclient = New-Object System.Net.WebClient
if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) {
$webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD)
}
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null
# If specified, validate the SHA-256 sum of the Maven distribution zip file
$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum
if ($distributionSha256Sum) {
if ($USE_MVND) {
Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties."
}
Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash
if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) {
Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property."
}
}
# unzip and move
Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null
# Find the actual extracted directory name (handles snapshots where filename != directory name)
$actualDistributionDir = ""
# First try the expected directory name (for regular distributions)
$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain"
$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD"
if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) {
$actualDistributionDir = $distributionUrlNameMain
}
# If not found, search for any directory with the Maven executable (for snapshots)
if (!$actualDistributionDir) {
Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object {
$testPath = Join-Path $_.FullName "bin/$MVN_CMD"
if (Test-Path -Path $testPath -PathType Leaf) {
$actualDistributionDir = $_.Name
}
}
}
if (!$actualDistributionDir) {
Write-Error "Could not find Maven distribution directory in extracted archive"
}
Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir"
Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null
try {
Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null
} catch {
if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) {
Write-Error "fail to move MAVEN_HOME"
}
} finally {
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
}
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"

View File

@@ -14,7 +14,7 @@
<groupId>com.loremind</groupId>
<artifactId>loremind-core</artifactId>
<version>0.16.0</version>
<version>1.0.1-beta</version>
<name>LoreMind Core</name>
<description>Backend Core - Architecture Hexagonale</description>
@@ -148,10 +148,66 @@
<artifactId>tink</artifactId>
<version>1.21.0</version>
</dependency>
<!-- openhtmltopdf — rendu XHTML+CSS -> PDF, 100 % JVM (pas de binaire natif :
compatible distribution bureau locale via jpackage). Sert l'export PDF
d'une campagne (livret). -->
<dependency>
<groupId>com.openhtmltopdf</groupId>
<artifactId>openhtmltopdf-pdfbox</artifactId>
<version>1.0.10</version>
</dependency>
<!-- TwelveMonkeys imageio-webp — décodeur WebP pur Java pour ImageIO. Sans lui,
ImageIO (et donc openhtmltopdf/PDFBox) ne sait pas lire le WebP : les
portraits/illustrations WebP seraient absents du PDF. -->
<dependency>
<groupId>com.twelvemonkeys.imageio</groupId>
<artifactId>imageio-webp</artifactId>
<version>3.12.0</version>
</dependency>
</dependencies>
<build>
<plugins>
<!-- Declare EXPLICITEMENT le processor d'annotations Lombok : sinon javac
avertit que l'annotation processing implicite (processeur trouve sur le
classpath sans etre declare) sera desactivee dans un futur JDK. -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
<!-- Expose le chemin du jar mockito-core dans la propriete
${org.mockito:mockito-core:jar}, consommee par surefire ci-dessous. -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-dependency-plugin</artifactId>
<executions>
<execution>
<goals>
<goal>properties</goal>
</goals>
</execution>
</executions>
</plugin>
<!-- Charge Mockito comme un VRAI -javaagent (au lieu de l'auto-attachement
dynamique de byte-buddy, qui imprime un avertissement et sera interdit
dans un futur JDK). `@{argLine}` preserve l'argLine pose par JaCoCo. -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<argLine>@{argLine} -javaagent:${org.mockito:mockito-core:jar} -Xshare:off</argLine>
</configuration>
</plugin>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
@@ -195,6 +251,31 @@
<goal>report</goal>
</goals>
</execution>
<!-- Plancher ANTI-REGRESSION : `mvn test` echoue si la couverture
d'instructions du bundle passe sous 60% (mesure actuelle ~68%).
A remonter au fil du temps. N'impacte PAS le build Docker
(qui passe -DskipTests) : le gating est porte par la CI. -->
<execution>
<id>check</id>
<phase>test</phase>
<goals>
<goal>check</goal>
</goals>
<configuration>
<rules>
<rule>
<element>BUNDLE</element>
<limits>
<limit>
<counter>INSTRUCTION</counter>
<value>COVEREDRATIO</value>
<minimum>0.60</minimum>
</limit>
</limits>
</rule>
</rules>
</configuration>
</execution>
</executions>
</plugin>
</plugins>

View File

@@ -1,9 +1,13 @@
package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Arc;
import com.loremind.domain.campaigncontext.Chapter;
import com.loremind.domain.campaigncontext.structure.Arc;
import com.loremind.domain.campaigncontext.structure.Chapter;
import com.loremind.domain.campaigncontext.generation.FieldProposal;
import com.loremind.domain.campaigncontext.quest.Quest;
import com.loremind.domain.shared.ReorderSupport;
import com.loremind.domain.campaigncontext.ports.ArcRepository;
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
import com.loremind.domain.campaigncontext.ports.QuestRepository;
import com.loremind.domain.campaigncontext.ports.SceneRepository;
import org.springframework.beans.BeanUtils;
import org.springframework.stereotype.Service;
@@ -23,13 +27,16 @@ public class ArcService {
private final ArcRepository arcRepository;
private final ChapterRepository chapterRepository;
private final SceneRepository sceneRepository;
private final QuestRepository questRepository;
public ArcService(ArcRepository arcRepository,
ChapterRepository chapterRepository,
SceneRepository sceneRepository) {
SceneRepository sceneRepository,
QuestRepository questRepository) {
this.arcRepository = arcRepository;
this.chapterRepository = chapterRepository;
this.sceneRepository = sceneRepository;
this.questRepository = questRepository;
}
/** Compte des entités qui seront supprimées en cascade avec l'arc. */
@@ -86,6 +93,37 @@ public class ArcService {
return arcRepository.save(arc);
}
/**
* Patch CIBLÉ champ-par-champ d'un arc (Pilier A — co-création). Applique UNIQUEMENT
* les {@link FieldProposal} reçus ; les autres champs restent INTACTS (contraste voulu
* avec {@link #updateArc} qui écrase tout via BeanUtils).
*/
@Transactional
public Arc patchArc(String id, List<FieldProposal> fields) {
Arc arc = arcRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Arc non trouvé avec l'ID: " + id));
if (fields != null) {
for (FieldProposal f : fields) {
if (f == null || f.key() == null) continue;
applyField(arc, f.key(), f.proposedValue());
}
}
return arcRepository.save(arc);
}
/** Whitelist STRICTE des champs étoffables d'un arc ; clé inconnue ignorée. */
private void applyField(Arc arc, String key, String value) {
switch (key) {
case "description" -> arc.setDescription(value);
case "themes" -> arc.setThemes(value);
case "stakes" -> arc.setStakes(value);
case "rewards" -> arc.setRewards(value);
case "resolution" -> arc.setResolution(value);
case "gmNotes" -> arc.setGmNotes(value);
default -> { /* clé inconnue → ignorée (garde-fou anti-écrasement) */ }
}
}
/**
* Calcule l'impact d'une suppression en cascade : chapitres + scènes
* qui disparaîtront avec l'arc.
@@ -111,10 +149,28 @@ public class ArcService {
}
chapterRepository.deleteById(chapter.getId());
}
// Détache les quêtes rattachées (arc HUB) : elles deviennent TRANSVERSES plutôt
// que fantômes (arcId pointant un arc disparu). Weak ref, pas de FK cascade.
for (Quest quest : questRepository.findByArcId(id)) {
quest.setArcId(null);
questRepository.save(quest);
}
arcRepository.deleteById(id);
}
public boolean arcExists(String id) {
return arcRepository.existsById(id);
}
/**
* Réordonne les arcs d'une campagne : {@code order} = position dans la liste fournie.
* Les ids inconnus sont ignorés. Transactionnel.
*/
@Transactional
public void reorderArcs(List<String> orderedIds) {
ReorderSupport.reorder(orderedIds,
arcRepository::findById,
Arc::setOrder,
arcRepository::save);
}
}

View File

@@ -41,6 +41,16 @@ public class CampaignBriefBuilder {
sb.append("# Campagne : ").append(cc.campaignName()).append("\n");
if (notBlank(cc.campaignDescription())) sb.append(cc.campaignDescription()).append("\n");
appendStructure(sb, cc);
appendNpcs(sb, cc);
if (campaign.isLinkedToLore()) {
loreContextBuilder.buildOptional(campaign.getLoreId()).ifPresent(lore -> appendLore(sb, lore));
}
return sb.toString();
}
private void appendStructure(StringBuilder sb, CampaignStructuralContext cc) {
sb.append("\n## Structure (arcs → chapitres → scènes)\n");
sb.append("_Un arc HUB contient des chapitres parallèles appelés « quêtes » ; ")
.append("un arc LINEAR contient des chapitres en séquence._\n");
@@ -48,34 +58,38 @@ public class CampaignBriefBuilder {
sb.append("_(aucun arc pour le moment)_\n");
}
for (ArcSummary arc : cc.arcs()) {
sb.append(arc.hub() ? "### Arc HUB (à quêtes) : " : "### Arc : ").append(arc.name());
if (notBlank(arc.description())) sb.append("").append(arc.description());
appendArc(sb, arc);
}
}
private void appendArc(StringBuilder sb, ArcSummary arc) {
sb.append(arc.hub() ? "### Arc HUB (à quêtes) : " : "### Arc : ").append(arc.name());
if (notBlank(arc.description())) sb.append("").append(arc.description());
sb.append("\n");
for (ChapterSummary ch : arc.chapters()) {
appendChapter(sb, arc.hub(), ch);
}
}
private void appendChapter(StringBuilder sb, boolean hub, ChapterSummary ch) {
sb.append(hub ? "- Quête : " : "- 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");
for (ChapterSummary ch : arc.chapters()) {
sb.append(arc.hub() ? "- Quête : " : "- 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");
}
private void appendNpcs(StringBuilder sb, CampaignStructuralContext cc) {
if (cc.npcs().isEmpty()) return;
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) {

View File

@@ -0,0 +1,40 @@
package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Campaign;
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.gamesystemcontext.ports.GameSystemRepository;
import org.springframework.stereotype.Component;
/**
* Formateur de contexte campagne pour les prompts IA.
* Centralise la construction du bloc "nom + description + système de jeu".
*/
@Component
public class CampaignContextFormatter {
private final CampaignRepository campaignRepository;
private final GameSystemRepository gameSystemRepository;
public CampaignContextFormatter(CampaignRepository campaignRepository,
GameSystemRepository gameSystemRepository) {
this.campaignRepository = campaignRepository;
this.gameSystemRepository = gameSystemRepository;
}
/** Contexte compact : nom de campagne + description + système de jeu. */
public String format(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();
}
}

View File

@@ -1,17 +1,17 @@
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.NpcProposal;
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.structure.Arc;
import com.loremind.domain.campaigncontext.structure.ArcType;
import com.loremind.domain.campaigncontext.generation.CampaignImportProgress;
import com.loremind.domain.campaigncontext.generation.CampaignImportProposal;
import com.loremind.domain.campaigncontext.generation.CampaignImportProposal.ArcProposal;
import com.loremind.domain.campaigncontext.generation.CampaignImportProposal.ChapterProposal;
import com.loremind.domain.campaigncontext.generation.CampaignImportProposal.NpcProposal;
import com.loremind.domain.campaigncontext.generation.CampaignImportProposal.RoomProposal;
import com.loremind.domain.campaigncontext.generation.CampaignImportProposal.SceneProposal;
import com.loremind.domain.campaigncontext.structure.Chapter;
import com.loremind.domain.campaigncontext.structure.Room;
import com.loremind.domain.campaigncontext.structure.Scene;
import com.loremind.domain.campaigncontext.ports.CampaignPdfImporter;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -83,66 +83,95 @@ public class CampaignImportService {
throw new IllegalArgumentException("Campagne introuvable : " + campaignId);
}
int arcsCreated = 0, chaptersCreated = 0, scenesCreated = 0;
int arcsCreated = 0;
int chaptersCreated = 0;
int 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++;
}
}
ArcOutcome outcome = applyArc(campaignId, arcP, arcOrder);
arcOrder = outcome.nextOrder();
arcsCreated += outcome.arcsCreated();
chaptersCreated += outcome.chaptersCreated();
scenesCreated += outcome.scenesCreated();
}
int npcsCreated = createNpcs(campaignId, proposal.npcs());
return new ApplyResult(arcsCreated, chaptersCreated, scenesCreated, npcsCreated);
}
/** Ordre d'arc suivant + compteurs créés (arc et, en cascade, ses chapitres/scènes). */
private record ArcOutcome(int nextOrder, int arcsCreated, int chaptersCreated, int scenesCreated) {}
private ArcOutcome applyArc(String campaignId, ArcProposal arcP, int arcOrder) {
String arcId;
int arcsCreated = 0;
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 = 1;
}
int chapterOrder = countExisting(arcP.chapters(), ChapterProposal::existingId);
int chaptersCreated = 0;
int scenesCreated = 0;
for (ChapterProposal chapP : safe(arcP.chapters())) {
if (isBlank(chapP.name())) continue;
ChapterOutcome outcome = applyChapter(arcId, chapP, chapterOrder);
chapterOrder = outcome.nextOrder();
chaptersCreated += outcome.chaptersCreated();
scenesCreated += outcome.scenesCreated();
}
return new ArcOutcome(arcOrder, arcsCreated, chaptersCreated, scenesCreated);
}
/** Ordre de chapitre suivant + compteurs créés (chapitre et, en cascade, ses scènes). */
private record ChapterOutcome(int nextOrder, int chaptersCreated, int scenesCreated) {}
private ChapterOutcome applyChapter(String arcId, ChapterProposal chapP, int chapterOrder) {
String chapId;
int chaptersCreated = 0;
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 = 1;
}
int sceneOrder = countExisting(chapP.scenes(), SceneProposal::existingId);
int scenesCreated = 0;
for (SceneProposal sceneP : safe(chapP.scenes())) {
if (isBlank(sceneP.name()) || !isBlank(sceneP.existingId())) continue; // vide ou 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 ChapterOutcome(chapterOrder, chaptersCreated, scenesCreated);
}
/**
* Crée les PNJ proposés (description → values["Description"], même convention
* que les cartes d'action des ateliers). Les PNJ portant un nom déjà présent
@@ -182,7 +211,8 @@ public class CampaignImportService {
/** "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;
String normalized = type == null ? "" : type.trim();
return "HUB".equalsIgnoreCase(normalized) ? ArcType.HUB : ArcType.LINEAR;
}
/** Convertit les pièces proposées en {@link Room} (ID généré, ordre = index). */

View File

@@ -0,0 +1,23 @@
package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.readiness.ReadinessStatus;
import java.util.List;
import java.util.Map;
/**
* Bilan de préparation d'une campagne (read-model, Pilier B « guidage »).
*
* @param campaignId campagne évaluée
* @param overallStatus statut agrégé (DRAFT si ≥1 gap bloquant, PLAYABLE si ≥1
* recommandé sans bloquant, POLISHED sinon)
* @param counts nombre de gaps par sévérité ({@code "BLOCKING"|"RECOMMENDED"|"OPTIONAL"})
* @param gaps liste des manques, triés par gravité décroissante
*/
public record CampaignReadinessAssessment(
String campaignId,
ReadinessStatus overallStatus,
Map<String, Integer> counts,
List<ReadinessGap> gaps
) {
}

View File

@@ -0,0 +1,352 @@
package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.structure.Arc;
import com.loremind.domain.campaigncontext.structure.ArcType;
import com.loremind.domain.campaigncontext.Campaign;
import com.loremind.domain.campaigncontext.structure.Chapter;
import com.loremind.domain.campaigncontext.bestiary.Enemy;
import com.loremind.domain.campaigncontext.quest.NodeType;
import com.loremind.domain.campaigncontext.quest.Prerequisite;
import com.loremind.domain.campaigncontext.quest.Quest;
import com.loremind.domain.campaigncontext.readiness.ReadinessEntityType;
import com.loremind.domain.campaigncontext.readiness.ReadinessSeverity;
import com.loremind.domain.campaigncontext.readiness.ReadinessStatus;
import com.loremind.domain.campaigncontext.structure.Room;
import com.loremind.domain.campaigncontext.structure.Scene;
import com.loremind.domain.campaigncontext.structure.SceneBranch;
import com.loremind.domain.campaigncontext.ports.ArcRepository;
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
import com.loremind.domain.campaigncontext.ports.EnemyRepository;
import com.loremind.domain.campaigncontext.ports.QuestRepository;
import com.loremind.domain.campaigncontext.ports.SceneRepository;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
/**
* Service applicatif du Pilier B (« guidage / readiness ») : calcule à la volée
* l'état de préparation d'une campagne et la liste des manques (« gaps ») à combler,
* chacun cliquable vers l'éditeur concerné côté front.
*
* <p><b>Déterministe, sans IA, sans persistance</b> (aucune colonne readiness — recalcul
* à chaque appel, comme le statut de quête). <b>Orthogonalité stricte</b> : n'injecte
* AUCUN repository du Play Context ; le readiness ne dépend jamais d'un Playthrough,
* d'un flag ou d'une progression. Purement indicatif : aucune sévérité ne bloque une action.</p>
*
* <p>Chargement en une passe façon {@code CampaignStructuralContextBuilder} : arcs →
* chapitres (par arc) → scènes (par chapitre), + quêtes et bestiaire de la campagne
* chargés une seule fois, indexés par id pour résoudre les références faibles sans N+1.</p>
*
* <p>Périmètre MVP : le noyau BLOQUANT (vides, branches/portes cassées, quête sans nœud
* ou à nœud mort, prérequis cassé) + le trio « combat » RECOMMANDÉ (combat annoncé sans
* ennemi, réf d'ennemi cassée en scène et en pièce). Les règles narratives/ambiance et
* les orphelins (état que l'UI empêche) sont hors MVP.</p>
*/
@Service
public class CampaignReadinessService {
private static final String SCENE_FALLBACK_NAME = "Scène";
private final CampaignRepository campaignRepository;
private final ArcRepository arcRepository;
private final ChapterRepository chapterRepository;
private final SceneRepository sceneRepository;
private final QuestRepository questRepository;
private final EnemyRepository enemyRepository;
public CampaignReadinessService(CampaignRepository campaignRepository,
ArcRepository arcRepository,
ChapterRepository chapterRepository,
SceneRepository sceneRepository,
QuestRepository questRepository,
EnemyRepository enemyRepository) {
this.campaignRepository = campaignRepository;
this.arcRepository = arcRepository;
this.chapterRepository = chapterRepository;
this.sceneRepository = sceneRepository;
this.questRepository = questRepository;
this.enemyRepository = enemyRepository;
}
/** Évalue la préparation d'une campagne (arbre + quêtes) et agrège son statut. */
public CampaignReadinessAssessment assess(String campaignId) {
List<ReadinessGap> gaps = new ArrayList<>();
String campaignName = campaignRepository.findById(campaignId)
.map(Campaign::getName).orElse(null);
// Bestiaire de la campagne, indexé pour résoudre les weak refs sans N+1.
// On écarte les ids vides/blancs : ils ne doivent jamais « résoudre » une référence.
Set<String> enemyIds = enemyRepository.findByCampaignId(campaignId).stream()
.map(Enemy::getId).filter(id -> !isBlank(id)).collect(Collectors.toSet());
List<Quest> quests = questRepository.findByCampaignId(campaignId);
// Arcs HUB « portés » par ≥1 quête rattachée : ne comptent PAS comme vides
// (un arc HUB contient des quêtes, un arc LINÉAIRE des chapitres).
Set<String> arcsWithQuests = quests.stream()
.map(Quest::getArcId).filter(id -> id != null && !id.isBlank())
.collect(Collectors.toSet());
List<Arc> arcs = new ArrayList<>(arcRepository.findByCampaignId(campaignId));
arcs.sort(Comparator.comparingInt(Arc::getOrder));
// Index global chapitres / scènes (cibles possibles des nœuds de quête).
Set<String> allChapterIds = new HashSet<>();
Set<String> allSceneIds = new HashSet<>();
int totalScenes = 0;
for (Arc arc : arcs) {
ArcScan scan = checkArc(arc, arcsWithQuests, enemyIds, gaps);
allChapterIds.addAll(scan.chapterIds());
allSceneIds.addAll(scan.sceneIds());
totalScenes += scan.sceneCount();
}
// Campagne vide : ni scène jouable, ni quête porteuse de contenu (couvre le mode plat).
if (totalScenes == 0 && !anyQuestHasNodes(quests)) {
gaps.add(new ReadinessGap(ReadinessEntityType.CAMPAIGN, campaignId, campaignName,
"CAMP-001-NO-CONTENT",
"Campagne vide : ajoutez un arc avec une scène, ou créez une quête, pour commencer à jouer.",
ReadinessSeverity.BLOCKING, null, null));
}
Set<String> questIds = quests.stream()
.map(Quest::getId).filter(Objects::nonNull).collect(Collectors.toSet());
for (Quest quest : quests) {
checkQuest(quest, allChapterIds, allSceneIds, questIds, gaps);
}
return aggregate(campaignId, gaps);
}
/** Résultat du scan d'un arc : chapitres/scènes indexés (cibles des nœuds de quête) + total scènes. */
private record ArcScan(Set<String> chapterIds, Set<String> sceneIds, int sceneCount) {}
private ArcScan checkArc(Arc arc, Set<String> arcsWithQuests, Set<String> enemyIds, List<ReadinessGap> gaps) {
List<Chapter> chapters = chapterRepository.findByArcId(arc.getId());
// Arc SYSTEM (conteneurs des quêtes libres) : jamais « vide » — c'est de la
// plomberie invisible. Ses chapitres restent analysés (CHAP-001 des conteneurs).
boolean hubCoveredByQuest = arc.getType() == ArcType.HUB && arcsWithQuests.contains(arc.getId());
if (chapters.isEmpty() && !hubCoveredByQuest && arc.getType() != ArcType.SYSTEM) {
gaps.add(emptyArcGap(arc));
}
Set<String> chapterIds = new HashSet<>();
Set<String> sceneIds = new HashSet<>();
int sceneCount = 0;
for (Chapter chapter : chapters) {
chapterIds.add(chapter.getId());
List<Scene> scenes = sceneRepository.findByChapterId(chapter.getId());
if (scenes.isEmpty()) {
gaps.add(new ReadinessGap(ReadinessEntityType.CHAPTER, chapter.getId(),
labelOr(chapter.getName(), "Chapitre"), "CHAP-001-NO-SCENE",
"Chapitre vide : ajoutez au moins une scène pour pouvoir le jouer.",
ReadinessSeverity.BLOCKING, arc.getId(), chapter.getId()));
}
Set<String> chapterSceneIds = scenes.stream()
.map(Scene::getId).filter(Objects::nonNull).collect(Collectors.toSet());
sceneCount += scenes.size();
for (Scene scene : scenes) {
sceneIds.add(scene.getId());
checkScene(scene, arc.getId(), chapter.getId(), chapterSceneIds, enemyIds, gaps);
}
}
return new ArcScan(chapterIds, sceneIds, sceneCount);
}
private ReadinessGap emptyArcGap(Arc arc) {
String msg = arc.getType() == ArcType.HUB
? "Arc vide : ajoutez une quête (ou un chapitre), ou supprimez-le."
: "Arc vide : ajoutez un chapitre, ou supprimez-le.";
return new ReadinessGap(ReadinessEntityType.ARC, arc.getId(), labelOr(arc.getName(), "Arc"),
"ARC-001-EMPTY", msg, ReadinessSeverity.BLOCKING, arc.getId(), null);
}
private static boolean anyQuestHasNodes(List<Quest> quests) {
return quests.stream().anyMatch(q -> q.getNodes() != null && !q.getNodes().isEmpty());
}
private void checkScene(Scene scene, String arcId, String chapterId,
Set<String> chapterSceneIds, Set<String> enemyIds, List<ReadinessGap> gaps) {
checkSceneName(scene, arcId, chapterId, gaps);
checkSceneBranches(scene, arcId, chapterId, chapterSceneIds, gaps);
checkSceneCombat(scene, arcId, chapterId, enemyIds, gaps);
checkSceneEnemyRefs(scene, arcId, chapterId, enemyIds, gaps);
checkSceneRooms(scene, arcId, chapterId, enemyIds, gaps);
}
/** SCENE-001 — scène sans titre. */
private void checkSceneName(Scene scene, String arcId, String chapterId, List<ReadinessGap> gaps) {
if (isBlank(scene.getName())) {
gaps.add(sceneGap(scene, arcId, chapterId, "SCENE-001-NO-NAME",
"Scène sans titre : donnez-lui un nom pour l'identifier et la jouer.",
ReadinessSeverity.BLOCKING));
}
}
/** SCENE-010 — branche de sortie cassée (vide / hors chapitre / auto-référence). */
private void checkSceneBranches(Scene scene, String arcId, String chapterId,
Set<String> chapterSceneIds, List<ReadinessGap> gaps) {
List<SceneBranch> branches = scene.getBranches();
if (branches == null) return;
boolean invalid = branches.stream().anyMatch(b ->
isBlank(b.targetSceneId())
|| b.targetSceneId().equals(scene.getId())
|| !chapterSceneIds.contains(b.targetSceneId()));
if (invalid) {
gaps.add(sceneGap(scene, arcId, chapterId, "SCENE-010-BRANCH-INVALID",
"Branche cassée : une sortie de « " + labelOr(scene.getName(), SCENE_FALLBACK_NAME)
+ " » pointe dans le vide, hors du chapitre, ou sur elle-même.",
ReadinessSeverity.BLOCKING));
}
}
/** SCENE-011 — combat annoncé sans adversaire (règle produit clé). */
private void checkSceneCombat(Scene scene, String arcId, String chapterId,
Set<String> enemyIds, List<ReadinessGap> gaps) {
if (isBlank(scene.getCombatDifficulty())) return;
boolean hasEnemyText = !isBlank(scene.getEnemies());
boolean hasResolvedEnemy = scene.getEnemyIds() != null
&& scene.getEnemyIds().stream().anyMatch(id -> !isBlank(id) && enemyIds.contains(id));
if (!hasEnemyText && !hasResolvedEnemy) {
gaps.add(sceneGap(scene, arcId, chapterId, "SCENE-011-COMBAT-NO-ENEMY",
"Combat annoncé sans adversaire : ajoutez une fiche du bestiaire ou décrivez les ennemis.",
ReadinessSeverity.RECOMMENDED));
}
}
/** SCENE-012 — référence d'ennemi cassée (fiche supprimée). */
private void checkSceneEnemyRefs(Scene scene, String arcId, String chapterId,
Set<String> enemyIds, List<ReadinessGap> gaps) {
if (scene.getEnemyIds() == null) return;
boolean broken = scene.getEnemyIds().stream().anyMatch(id -> !isBlank(id) && !enemyIds.contains(id));
if (broken) {
gaps.add(sceneGap(scene, arcId, chapterId, "SCENE-012-ENEMY-REF-BROKEN",
"Ennemi introuvable : « " + labelOr(scene.getName(), SCENE_FALLBACK_NAME)
+ " » référence une fiche du bestiaire supprimée. Retirez la référence ou recréez la fiche.",
ReadinessSeverity.RECOMMENDED));
}
}
/** SCENE-041 / SCENE-042 — pièces explorables : portes cassées + ennemis fantômes. */
private void checkSceneRooms(Scene scene, String arcId, String chapterId,
Set<String> enemyIds, List<ReadinessGap> gaps) {
List<Room> rooms = scene.getRooms();
if (rooms == null || rooms.isEmpty()) return;
String name = labelOr(scene.getName(), SCENE_FALLBACK_NAME);
Set<String> roomIds = rooms.stream()
.map(Room::getId).filter(Objects::nonNull).collect(Collectors.toSet());
if (rooms.stream().anyMatch(room -> hasInvalidBranch(room, roomIds))) {
gaps.add(sceneGap(scene, arcId, chapterId, "SCENE-041-ROOMBRANCH-INVALID",
"Porte cassée : dans « " + name
+ " », une sortie de pièce pointe hors de la scène ou dans le vide.",
ReadinessSeverity.BLOCKING));
}
if (rooms.stream().anyMatch(room -> hasBrokenEnemyRef(room, enemyIds))) {
gaps.add(sceneGap(scene, arcId, chapterId, "SCENE-042-ROOM-ENEMY-BROKEN",
"Ennemi introuvable dans une pièce de « " + name
+ " » : la référence pointe vers une fiche supprimée.",
ReadinessSeverity.RECOMMENDED));
}
}
private static boolean hasInvalidBranch(Room room, Set<String> roomIds) {
if (room.getBranches() == null) return false;
return room.getBranches().stream().anyMatch(rb ->
isBlank(rb.targetRoomId())
|| rb.targetRoomId().equals(room.getId())
|| !roomIds.contains(rb.targetRoomId()));
}
private static boolean hasBrokenEnemyRef(Room room, Set<String> enemyIds) {
if (room.getEnemyIds() == null) return false;
return room.getEnemyIds().stream().anyMatch(id -> !isBlank(id) && !enemyIds.contains(id));
}
private void checkQuest(Quest quest, Set<String> allChapterIds, Set<String> allSceneIds,
Set<String> questIds, List<ReadinessGap> gaps) {
String name = labelOr(quest.getName(), "Quête");
// QUEST-001 — quête sans nœud ; sinon QUEST-010 — nœud pointant dans le vide.
if (quest.getNodes() == null || quest.getNodes().isEmpty()) {
gaps.add(questGap(quest, "QUEST-001-NO-NODES",
"Quête sans contenu : ajoutez au moins un chapitre ou une scène à « " + name + " »."));
} else if (quest.getNodes().stream().anyMatch(n ->
n.nodeType() == null
|| isBlank(n.nodeId())
|| (n.nodeType() == NodeType.CHAPTER && !allChapterIds.contains(n.nodeId()))
|| (n.nodeType() == NodeType.SCENE && !allSceneIds.contains(n.nodeId())))) {
gaps.add(questGap(quest, "QUEST-010-NODE-REF-BROKEN",
"Nœud de quête cassé : dans « " + name
+ " », un chapitre ou une scène référencé n'existe plus."));
}
// CAMP-010 — prérequis QuestCompleted pointant une quête disparue.
if (quest.getPrerequisites() != null && quest.getPrerequisites().stream()
.filter(p -> p instanceof Prerequisite.QuestCompleted)
.map(p -> ((Prerequisite.QuestCompleted) p).questId())
.anyMatch(qid -> isBlank(qid) || !questIds.contains(qid))) {
gaps.add(questGap(quest, "CAMP-010-DANGLING-QUEST-PREREQ",
"Prérequis cassé : « " + name
+ " » dépend d'une quête qui n'existe plus. Corrigez la condition de déblocage."));
}
}
private CampaignReadinessAssessment aggregate(String campaignId, List<ReadinessGap> gaps) {
Map<String, Integer> counts = new LinkedHashMap<>();
counts.put(ReadinessSeverity.BLOCKING.name(), 0);
counts.put(ReadinessSeverity.RECOMMENDED.name(), 0);
counts.put(ReadinessSeverity.OPTIONAL.name(), 0);
for (ReadinessGap g : gaps) {
counts.merge(g.severity().name(), 1, Integer::sum);
}
ReadinessStatus status;
if (counts.get(ReadinessSeverity.BLOCKING.name()) > 0) {
status = ReadinessStatus.DRAFT;
} else if (counts.get(ReadinessSeverity.RECOMMENDED.name()) > 0) {
status = ReadinessStatus.PLAYABLE;
} else {
status = ReadinessStatus.POLISHED;
}
gaps.sort(Comparator.comparingInt(g -> severityRank(g.severity())));
return new CampaignReadinessAssessment(campaignId, status, counts, gaps);
}
private ReadinessGap sceneGap(Scene scene, String arcId, String chapterId,
String ruleId, String message, ReadinessSeverity severity) {
return new ReadinessGap(ReadinessEntityType.SCENE, scene.getId(),
labelOr(scene.getName(), SCENE_FALLBACK_NAME), ruleId, message, severity, arcId, chapterId);
}
private ReadinessGap questGap(Quest quest, String ruleId, String message) {
return new ReadinessGap(ReadinessEntityType.QUEST, quest.getId(),
labelOr(quest.getName(), "Quête"), ruleId, message, ReadinessSeverity.BLOCKING, null, null);
}
private static int severityRank(ReadinessSeverity severity) {
return switch (severity) {
case BLOCKING -> 0;
case RECOMMENDED -> 1;
case OPTIONAL -> 2;
};
}
private static boolean isBlank(String s) {
return s == null || s.isBlank();
}
private static String labelOr(String value, String fallback) {
return isBlank(value) ? fallback : value;
}
}

View File

@@ -1,10 +1,8 @@
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 com.loremind.domain.campaigncontext.quest.Prerequisite;
import com.loremind.domain.campaigncontext.quest.Quest;
import com.loremind.domain.campaigncontext.ports.QuestRepository;
import org.springframework.stereotype.Service;
import java.util.List;
@@ -12,35 +10,33 @@ 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.
* référencés par les quêtes 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>
* la Partie, autocomplete dans l'éditeur de prérequis de quête).</p>
*
* <p>Niveau 1 : lit désormais les quêtes (entité de première classe), plus les
* chapitres HUB.</p>
*/
@Service
public class CampaignReferencedFlagsService {
private final ArcRepository arcRepository;
private final ChapterRepository chapterRepository;
private final QuestRepository questRepository;
public CampaignReferencedFlagsService(ArcRepository arcRepository,
ChapterRepository chapterRepository) {
this.arcRepository = arcRepository;
this.chapterRepository = chapterRepository;
public CampaignReferencedFlagsService(QuestRepository questRepository) {
this.questRepository = questRepository;
}
/** 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());
}
for (Quest quest : questRepository.findByCampaignId(campaignId)) {
if (quest.getPrerequisites() == null) continue;
for (Prerequisite p : quest.getPrerequisites()) {
if (p instanceof Prerequisite.FlagSet f && f.flagName() != null && !f.flagName().isBlank()) {
unique.add(f.flagName());
}
}
}

View File

@@ -1,9 +1,9 @@
package com.loremind.application.campaigncontext;
import com.loremind.application.playcontext.PlaythroughService;
import com.loremind.domain.campaigncontext.Arc;
import com.loremind.domain.campaigncontext.structure.Arc;
import com.loremind.domain.campaigncontext.Campaign;
import com.loremind.domain.campaigncontext.Chapter;
import com.loremind.domain.campaigncontext.structure.Chapter;
import com.loremind.domain.campaigncontext.ports.ArcRepository;
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
@@ -48,7 +48,7 @@ public class CampaignService {
this.playthroughService = playthroughService;
}
public record CampaignData(String name, String description, String loreId, String gameSystemId) {}
public record CampaignData(String name, String description, String loreId, String gameSystemId, int playerCount) {}
public record DeletionImpact(int arcs, int chapters, int scenes, int playthroughs) {}
@@ -59,6 +59,7 @@ public class CampaignService {
.loreId(normalizeId(data.loreId()))
.gameSystemId(normalizeId(data.gameSystemId()))
.arcsCount(0)
.playerCount(data.playerCount())
.build();
Campaign saved = campaignRepository.save(campaign);
@@ -87,9 +88,26 @@ public class CampaignService {
campaign.setDescription(data.description());
campaign.setLoreId(normalizeId(data.loreId()));
campaign.setGameSystemId(normalizeId(data.gameSystemId()));
campaign.setPlayerCount(data.playerCount());
return campaignRepository.save(campaign);
}
/**
* Sauvegarde les positions du graphe de campagne (JSON opaque, état de
* présentation possédé par le front). Null/vide = retour à la disposition
* automatique. Taille bornée : ce champ ne doit pas devenir un fourre-tout.
*/
public void updateGraphPositions(String id, String positionsJson) {
Campaign campaign = campaignRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Campaign non trouvé avec l'ID: " + id));
String value = (positionsJson == null || positionsJson.isBlank()) ? null : positionsJson;
if (value != null && value.length() > 200_000) {
throw new IllegalArgumentException("Positions de graphe trop volumineuses");
}
campaign.setGraphPositions(value);
campaignRepository.save(campaign);
}
private String normalizeId(String id) {
return (id == null || id.isBlank()) ? null : id;
}

View File

@@ -1,6 +1,8 @@
package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Chapter;
import com.loremind.domain.campaigncontext.structure.Chapter;
import com.loremind.domain.campaigncontext.generation.FieldProposal;
import com.loremind.domain.shared.ReorderSupport;
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
import com.loremind.domain.campaigncontext.ports.SceneRepository;
import org.springframework.beans.BeanUtils;
@@ -80,6 +82,35 @@ public class ChapterService {
return chapterRepository.save(chapter);
}
/**
* Patch CIBLÉ champ-par-champ d'un chapitre (Pilier A — co-création). Applique
* UNIQUEMENT les {@link FieldProposal} reçus ; les autres champs restent INTACTS
* (contraste voulu avec {@link #updateChapter} qui écrase tout via BeanUtils).
*/
@Transactional
public Chapter patchChapter(String id, List<FieldProposal> fields) {
Chapter chapter = chapterRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Chapter non trouvé avec l'ID: " + id));
if (fields != null) {
for (FieldProposal f : fields) {
if (f == null || f.key() == null) continue;
applyField(chapter, f.key(), f.proposedValue());
}
}
return chapterRepository.save(chapter);
}
/** Whitelist STRICTE des champs étoffables d'un chapitre ; clé inconnue ignorée. */
private void applyField(Chapter chapter, String key, String value) {
switch (key) {
case "description" -> chapter.setDescription(value);
case "gmNotes" -> chapter.setGmNotes(value);
case "playerObjectives" -> chapter.setPlayerObjectives(value);
case "narrativeStakes" -> chapter.setNarrativeStakes(value);
default -> { /* clé inconnue → ignorée (garde-fou anti-écrasement) */ }
}
}
/** Compte des scènes qui tomberont avec le chapitre. */
public DeletionImpact getDeletionImpact(String id) {
return new DeletionImpact(sceneRepository.findByChapterId(id).size());
@@ -97,4 +128,19 @@ public class ChapterService {
public boolean chapterExists(String id) {
return chapterRepository.existsById(id);
}
/**
* Réordonne (et déplace) les chapitres d'un arc : {@code order} = position. Si
* {@code arcId} est fourni, on réaffecte le chapitre à cet arc. Transactionnel.
*/
@Transactional
public void reorderChapters(String arcId, List<String> orderedIds) {
ReorderSupport.reorder(orderedIds,
chapterRepository::findById,
(chapter, i) -> {
if (arcId != null && !arcId.isBlank()) chapter.setArcId(arcId);
chapter.setOrder(i);
},
chapterRepository::save);
}
}

View File

@@ -1,104 +0,0 @@
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

@@ -1,6 +1,6 @@
package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Character;
import com.loremind.domain.campaigncontext.bestiary.Character;
import com.loremind.domain.campaigncontext.ports.CharacterRepository;
import org.springframework.stereotype.Service;
@@ -41,9 +41,9 @@ public class CharacterService {
.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<>())
.values(copyStringMap(data.values()))
.imageValues(copyStringListMap(data.imageValues()))
.keyValueValues(copyStringMapMap(data.keyValueValues()))
.playthroughId(data.playthroughId())
.order(order)
.build();
@@ -64,9 +64,9 @@ public class CharacterService {
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.setValues(copyStringMap(data.values()));
existing.setImageValues(copyStringListMap(data.imageValues()));
existing.setKeyValueValues(copyStringMapMap(data.keyValueValues()));
if (data.order() != null) {
existing.setOrder(data.order());
}
@@ -89,4 +89,16 @@ public class CharacterService {
.max()
.orElse(-1) + 1;
}
private static Map<String, String> copyStringMap(Map<String, String> map) {
return map != null ? new HashMap<>(map) : new HashMap<>();
}
private static Map<String, List<String>> copyStringListMap(Map<String, List<String>> map) {
return map != null ? new HashMap<>(map) : new HashMap<>();
}
private static Map<String, Map<String, String>> copyStringMapMap(Map<String, Map<String, String>> map) {
return map != null ? new HashMap<>(map) : new HashMap<>();
}
}

View File

@@ -1,13 +1,16 @@
package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Enemy;
import com.loremind.domain.campaigncontext.bestiary.Enemy;
import com.loremind.domain.shared.ReorderSupport;
import com.loremind.domain.campaigncontext.ports.EnemyRepository;
import com.loremind.domain.images.Image;
import com.loremind.application.images.ImageService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.io.ByteArrayInputStream;
import java.util.*;
/**
* Service d'application pour les fiches d'ennemis (bestiaire de campagne).
@@ -16,10 +19,14 @@ import java.util.Optional;
@Service
public class EnemyService {
private final EnemyRepository enemyRepository;
private static final Logger log = LoggerFactory.getLogger(EnemyService.class);
public EnemyService(EnemyRepository enemyRepository) {
private final EnemyRepository enemyRepository;
private final ImageService imageService;
public EnemyService(EnemyRepository enemyRepository, ImageService imageService) {
this.enemyRepository = enemyRepository;
this.imageService = imageService;
}
public record EnemyData(
@@ -37,18 +44,10 @@ public class EnemyService {
public Enemy createEnemy(EnemyData data) {
int order = data.order() != null ? data.order() : nextOrderFor(data.campaignId());
Enemy enemy = Enemy.builder()
.name(data.name())
.level(normalize(data.level()))
.folder(normalize(data.folder()))
.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())
.order(order)
.build();
Enemy enemy = Enemy.builder().build();
applyCommonEnemyFields(enemy, data);
enemy.setCampaignId(data.campaignId());
enemy.setOrder(order);
return enemyRepository.save(enemy);
}
@@ -63,17 +62,11 @@ public class EnemyService {
public Enemy updateEnemy(String id, EnemyData data) {
Enemy existing = enemyRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Enemy non trouvé avec l'ID: " + id));
existing.setName(data.name());
existing.setLevel(normalize(data.level()));
existing.setFolder(normalize(data.folder()));
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<>());
applyCommonEnemyFields(existing, data);
if (data.order() != null) {
existing.setOrder(data.order());
}
// campaignId immuable après création.
return enemyRepository.save(existing);
}
@@ -81,6 +74,139 @@ public class EnemyService {
enemyRepository.deleteById(id);
}
/**
* Réordonne (et reclasse) les ennemis d'un dossier : {@code order} = position, et
* le dossier de chaque ennemi est posé à {@code folder} (glisser-déposer).
*/
@org.springframework.transaction.annotation.Transactional
public void reorderEnemies(String folder, List<String> orderedIds) {
String f = normalize(folder);
ReorderSupport.reorder(orderedIds,
enemyRepository::findById,
(enemy, i) -> { enemy.setFolder(f); enemy.setOrder(i); },
enemyRepository::save);
}
// --- Import de monstres depuis un compendium Foundry -------------------
/**
* Un monstre du catalogue Foundry : nom + référence + snapshot de stats + dossier
* + vignette du portrait (data URL base64, optionnelle).
*/
public record MonsterImport(String name, String foundryRef, Map<String, String> stats,
String folder, String imgData) {}
/**
* Dossier LoreMind d'un monstre importé : on conserve l'arborescence Foundry
* sous un dossier racine « Foundry » ("Foundry/Briarban", "Foundry" si aucun).
*/
private static String foundryFolder(String path) {
String p = path == null ? "" : path.trim();
return p.isEmpty() ? "Foundry" : "Foundry/" + p;
}
/**
* Décode une vignette de portrait (data URL base64 "data:image/webp;base64,…")
* et l'enregistre comme image LoreMind. Renvoie l'id de l'image, ou null si
* absente/illisible (l'import ne doit jamais échouer à cause d'un portrait).
*/
private String storePortrait(String dataUrl, String name) {
if (dataUrl == null || !dataUrl.startsWith("data:")) return null;
int comma = dataUrl.indexOf(',');
if (comma < 0) return null;
String header = dataUrl.substring(5, comma); // "image/webp;base64"
String contentType = header.split(";")[0].toLowerCase();
byte[] bytes;
try {
bytes = Base64.getDecoder().decode(dataUrl.substring(comma + 1));
} catch (IllegalArgumentException e) {
return null;
}
if (bytes.length == 0) return null;
String ext = pickExtension(contentType);
// Type MIME canonique dérivé de l'extension : un data URL "image/jpg" (non
// standard) serait rejeté par ImageService (qui n'accepte que "image/jpeg").
String mimeType = "jpg".equals(ext) ? "image/jpeg" : "image/" + ext;
String filename = (name == null || name.isBlank() ? "monstre" : name.replaceAll("[^a-zA-Z0-9._-]", "_")) + "." + ext;
try {
Image img = imageService.upload(filename, mimeType,
new ByteArrayInputStream(bytes), bytes.length);
return img.getId();
} catch (RuntimeException e) {
log.warn("Portrait de monstre ignoré (\"{}\") : {}", name, e.getMessage());
return null;
}
}
/** Extension déduite du content-type ; "webp" par défaut si non reconnu. */
private static String pickExtension(String contentType) {
if (contentType.contains("png")) return "png";
if (contentType.contains("jpeg") || contentType.contains("jpg")) return "jpg";
if (contentType.contains("gif")) return "gif";
return "webp";
}
public record MonsterImportResult(int created, int updated) {}
/**
* Importe (upsert) des monstres Foundry dans le bestiaire d'une campagne.
* Dédup par {@code foundryRef} : un monstre déjà importé est mis à jour (nom),
* jamais dupliqué. Fiche minimale (nom + référence) ; les stats restent côté
* Foundry et sont ré-instanciées à l'export.
*/
public MonsterImportResult importFoundryMonsters(String campaignId, List<MonsterImport> monsters) {
List<Enemy> existing = enemyRepository.findByCampaignId(campaignId);
Map<String, Enemy> byRef = new HashMap<>();
for (Enemy e : existing) {
if (e.getFoundryRef() != null) byRef.put(e.getFoundryRef(), e);
}
int order = existing.stream().mapToInt(Enemy::getOrder).max().orElse(-1) + 1;
int created = 0;
int updated = 0;
for (MonsterImport m : monsters) {
if (isBlank(m.foundryRef()) || isBlank(m.name())) continue;
Map<String, String> stats = m.stats() != null ? new HashMap<>(m.stats()) : new HashMap<>();
Enemy ex = byRef.get(m.foundryRef());
if (ex != null) {
updateMonster(ex, m, stats);
updated++;
} else {
byRef.put(m.foundryRef(), createMonster(campaignId, m, stats, order++));
created++;
}
}
return new MonsterImportResult(created, updated);
}
/** Met à jour un monstre déjà importé : nom, snapshot de stats, dossier, portrait manquant. */
private void updateMonster(Enemy ex, MonsterImport m, Map<String, String> stats) {
ex.setName(m.name());
ex.setFoundryStats(stats); // rafraîchit le snapshot
ex.setFolder(foundryFolder(m.folder())); // ré-aligne sur l'arborescence Foundry
// Portrait : seulement s'il manque (pas de ré-upload à chaque import).
if (ex.getPortraitImageId() == null) {
String portrait = storePortrait(m.imgData(), m.name());
if (portrait != null) ex.setPortraitImageId(portrait);
}
enemyRepository.save(ex);
}
private Enemy createMonster(String campaignId, MonsterImport m, Map<String, String> stats, int order) {
return enemyRepository.save(Enemy.builder()
.name(m.name())
.foundryRef(m.foundryRef())
.foundryStats(stats)
.folder(foundryFolder(m.folder()))
.portraitImageId(storePortrait(m.imgData(), m.name()))
.campaignId(campaignId)
.order(order)
.values(new HashMap<>())
.imageValues(new HashMap<>())
.keyValueValues(new HashMap<>())
.build());
}
public List<Enemy> searchEnemies(String query) {
if (query == null || query.isBlank()) return List.of();
return enemyRepository.searchByName(query.trim());
@@ -93,10 +219,37 @@ public class EnemyService {
return trimmed.isEmpty() ? null : trimmed;
}
private static boolean isBlank(String s) {
return s == null || s.isBlank();
}
private int nextOrderFor(String campaignId) {
return enemyRepository.findByCampaignId(campaignId).stream()
.mapToInt(Enemy::getOrder)
.max()
.orElse(-1) + 1;
}
private static Map<String, String> copyStringMap(Map<String, String> map) {
return map != null ? new HashMap<>(map) : new HashMap<>();
}
private static Map<String, List<String>> copyStringListMap(Map<String, List<String>> map) {
return map != null ? new HashMap<>(map) : new HashMap<>();
}
private static Map<String, Map<String, String>> copyStringMapMap(Map<String, Map<String, String>> map) {
return map != null ? new HashMap<>(map) : new HashMap<>();
}
private static void applyCommonEnemyFields(Enemy enemy, EnemyData data) {
enemy.setName(data.name());
enemy.setLevel(normalize(data.level()));
enemy.setFolder(normalize(data.folder()));
enemy.setPortraitImageId(data.portraitImageId());
enemy.setHeaderImageId(data.headerImageId());
enemy.setValues(copyStringMap(data.values()));
enemy.setImageValues(copyStringListMap(data.imageValues()));
enemy.setKeyValueValues(copyStringMapMap(data.keyValueValues()));
}
}

View File

@@ -1,12 +1,9 @@
package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Campaign;
import com.loremind.domain.campaigncontext.CatalogItem;
import com.loremind.domain.campaigncontext.ItemCatalog;
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.campaigncontext.itemcatalog.CatalogItem;
import com.loremind.domain.campaigncontext.itemcatalog.ItemCatalog;
import com.loremind.domain.campaigncontext.ports.ItemCatalogGenerator;
import com.loremind.domain.campaigncontext.ports.ItemCatalogRepository;
import com.loremind.domain.gamesystemcontext.ports.GameSystemRepository;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
@@ -21,18 +18,15 @@ public class ItemCatalogService {
private final ItemCatalogRepository repository;
private final ItemCatalogGenerator generator;
private final CampaignRepository campaignRepository;
private final GameSystemRepository gameSystemRepository;
private final CampaignContextFormatter campaignContextFormatter;
public ItemCatalogService(
ItemCatalogRepository repository,
ItemCatalogGenerator generator,
CampaignRepository campaignRepository,
GameSystemRepository gameSystemRepository) {
CampaignContextFormatter campaignContextFormatter) {
this.repository = repository;
this.generator = generator;
this.campaignRepository = campaignRepository;
this.gameSystemRepository = gameSystemRepository;
this.campaignContextFormatter = campaignContextFormatter;
}
public record CatalogData(
@@ -89,7 +83,8 @@ public class ItemCatalogService {
/** Génère une PROPOSITION de catalogue (non persistée) via l'IA, contextualisée campagne. */
public ItemCatalog generateProposal(String campaignId, String description) {
ItemCatalogGenerator.GeneratedCatalog g = generator.generate(description, buildContext(campaignId));
ItemCatalogGenerator.GeneratedCatalog g = generator.generate(
description, campaignContextFormatter.format(campaignId));
return ItemCatalog.builder()
.name(g.name())
.description(g.description())
@@ -102,22 +97,6 @@ public class ItemCatalogService {
return items != null ? new ArrayList<>(items) : new ArrayList<>();
}
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 int nextOrderFor(String campaignId) {
return repository.findByCampaignId(campaignId).stream()
.mapToInt(ItemCatalog::getOrder)

View File

@@ -1,9 +1,9 @@
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.notebook.Notebook;
import com.loremind.domain.campaigncontext.notebook.NotebookMessage;
import com.loremind.domain.campaigncontext.notebook.NotebookSource;
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.campaigncontext.ports.NotebookIndexer;
import com.loremind.domain.campaigncontext.ports.NotebookRepository;

View File

@@ -1,16 +1,11 @@
package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Campaign;
import com.loremind.domain.campaigncontext.Npc;
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.campaigncontext.bestiary.Npc;
import com.loremind.domain.shared.ReorderSupport;
import com.loremind.domain.campaigncontext.ports.NpcRepository;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.*;
/**
* Service d'application pour les fiches de PNJ (campagne).
@@ -19,11 +14,9 @@ import java.util.Optional;
public class NpcService {
private final NpcRepository npcRepository;
private final CampaignRepository campaignRepository;
public NpcService(NpcRepository npcRepository, CampaignRepository campaignRepository) {
public NpcService(NpcRepository npcRepository) {
this.npcRepository = npcRepository;
this.campaignRepository = campaignRepository;
}
public record NpcData(
@@ -66,21 +59,6 @@ public class NpcService {
return npcRepository.findByCampaignId(campaignId);
}
/**
* PNJ de TOUTES les campagnes liées au Lore donné (via {@code campaign.loreId}).
* Sert au graphe du Lore : relier les PNJ aux pages qu'ils référencent.
* Volume faible (usage mono-utilisateur) → filtrage en mémoire assumé.
*/
public List<Npc> getNpcsByLoreId(String loreId) {
List<Npc> out = new ArrayList<>();
for (Campaign campaign : campaignRepository.findAll()) {
if (campaign.isLinkedToLore() && campaign.getLoreId().equals(loreId)) {
out.addAll(npcRepository.findByCampaignId(campaign.getId()));
}
}
return out;
}
public Npc updateNpc(String id, NpcData data) {
Npc existing = npcRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Npc non trouvé avec l'ID: " + id));
@@ -102,6 +80,19 @@ public class NpcService {
npcRepository.deleteById(id);
}
/**
* Réordonne (et reclasse) les PNJ d'un dossier : {@code order} = position, et le
* dossier de chaque PNJ est posé à {@code folder} (déplacement par glisser-déposer).
*/
@org.springframework.transaction.annotation.Transactional
public void reorderNpcs(String folder, List<String> orderedIds) {
String f = normalizeFolder(folder);
ReorderSupport.reorder(orderedIds,
npcRepository::findById,
(npc, i) -> { npc.setFolder(f); npc.setOrder(i); },
npcRepository::save);
}
public List<Npc> searchNpcs(String query) {
if (query == null || query.isBlank()) return List.of();
return npcRepository.searchByName(query.trim());

View File

@@ -0,0 +1,207 @@
package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.structure.Arc;
import com.loremind.domain.campaigncontext.structure.ArcType;
import com.loremind.domain.campaigncontext.structure.Chapter;
import com.loremind.domain.campaigncontext.quest.NodeType;
import com.loremind.domain.campaigncontext.quest.Quest;
import com.loremind.domain.campaigncontext.quest.QuestNodeRef;
import com.loremind.domain.campaigncontext.ports.ArcRepository;
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
import com.loremind.domain.campaigncontext.ports.QuestRepository;
import com.loremind.domain.campaigncontext.ports.SceneRepository;
import com.loremind.domain.playcontext.ports.QuestProgressionRepository;
import com.loremind.domain.shared.ReorderSupport;
import org.springframework.beans.BeanUtils;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
/**
* Service d'application pour le contexte Quest (Niveau 1).
* Orchestre la logique métier via le Port {@code QuestRepository}.
*/
@Service
public class QuestService {
/** Nom de l'arc technique hébergeant les conteneurs des quêtes libres (invisible). */
static final String SYSTEM_ARC_NAME = "Quêtes libres";
private final QuestRepository questRepository;
private final QuestProgressionRepository progressionRepository;
private final ChapterRepository chapterRepository;
private final SceneRepository sceneRepository;
private final ArcRepository arcRepository;
public QuestService(QuestRepository questRepository,
QuestProgressionRepository progressionRepository,
ChapterRepository chapterRepository,
SceneRepository sceneRepository,
ArcRepository arcRepository) {
this.questRepository = questRepository;
this.progressionRepository = progressionRepository;
this.chapterRepository = chapterRepository;
this.sceneRepository = sceneRepository;
this.arcRepository = arcRepository;
}
/**
* Création à partir d'une Quest complète. L'id est forcé à null (généré par la DB).
*
* <p>TOUTE quête créée sans nœud reçoit son CONTENEUR de scènes (chapitre jumeau,
* même nom, masqué dans l'arbre par la fusion quête/jumeau) : une quête est un espace
* jouable où le MJ crée ses scènes à la volée — qu'elle vive dans un arc HUB (le
* conteneur y est rangé) ou LIBRE (le conteneur va dans l'arc technique {@code SYSTEM}
* de la campagne, invisible et non exporté). Lier des nœuds existants à la création
* (quête « transversale ») court-circuite le provisioning.</p>
*/
@Transactional
public Quest createQuest(Quest input) {
input.setId(null);
if (nullSafeNodes(input.getNodes()).isEmpty()) {
provisionContainer(input);
}
return questRepository.save(input);
}
/**
* Provisionne le conteneur de scènes d'une quête sans nœud et le référence
* (mutation de {@code quest.nodes} — la sauvegarde reste à la charge de l'appelant).
*/
private void provisionContainer(Quest quest) {
String containerArcId = quest.getArcId() != null && !quest.getArcId().isBlank()
? quest.getArcId()
: systemArcIdFor(quest.getCampaignId());
int order = chapterRepository.findByArcId(containerArcId).stream()
.mapToInt(Chapter::getOrder).max().orElse(-1) + 1;
Chapter container = chapterRepository.save(Chapter.builder()
.name(quest.getName())
.description("") // le narratif vit sur la quête, pas sur le conteneur
.arcId(containerArcId)
.order(order)
.build());
quest.setNodes(new ArrayList<>(List.of(
new QuestNodeRef(NodeType.CHAPTER, container.getId(), 0))));
}
/** Arc technique (SYSTEM) de la campagne — créé au premier besoin. */
private String systemArcIdFor(String campaignId) {
return arcRepository.findByCampaignId(campaignId).stream()
.filter(a -> a.getType() == ArcType.SYSTEM)
.map(Arc::getId)
.findFirst()
.orElseGet(() -> arcRepository.save(Arc.builder()
.name(SYSTEM_ARC_NAME)
.description("")
.campaignId(campaignId)
.type(ArcType.SYSTEM)
.order(9999)
.build()).getId());
}
/** Le chapitre est-il un CONTENEUR de cette quête (jumeau hub ou hébergé en arc SYSTEM) ? */
private boolean isContainerOf(Quest quest, Chapter chapter) {
if (Objects.equals(quest.getArcId(), chapter.getArcId())) return true;
return chapter.getArcId() != null && arcRepository.findById(chapter.getArcId())
.map(a -> a.getType() == ArcType.SYSTEM)
.orElse(false);
}
public Optional<Quest> getQuestById(String id) {
return questRepository.findById(id);
}
public List<Quest> getQuestsByCampaignId(String campaignId) {
return questRepository.findByCampaignId(campaignId);
}
/** Met à jour une Quest (Parameter Object pattern, comme ChapterService). */
@Transactional
public Quest updateQuest(String id, Quest updated) {
Optional<Quest> existing = questRepository.findById(id);
if (existing.isEmpty()) {
throw new IllegalArgumentException("Quest non trouvée avec l'ID: " + id);
}
Quest quest = existing.get();
String oldName = quest.getName();
BeanUtils.copyProperties(updated, quest, "id");
Quest saved = questRepository.save(quest);
// Le conteneur jumeau porte le même nom que la quête (fusion dans l'arbre) :
// il suit le renommage, sinon le guidage citerait encore l'ancien nom.
// Vaut pour les quêtes de hub COMME pour les quêtes libres (conteneur en arc SYSTEM).
if (oldName != null && !oldName.equals(saved.getName())) {
for (QuestNodeRef node : nullSafeNodes(saved.getNodes())) {
if (node.nodeType() != NodeType.CHAPTER) continue;
chapterRepository.findById(node.nodeId()).ifPresent(ch -> {
if (oldName.equals(ch.getName()) && isContainerOf(saved, ch)) {
ch.setName(saved.getName());
chapterRepository.save(ch);
}
});
}
}
// Auto-réparation : une quête historique restée sans nœud (créée avant le
// provisioning systématique) reçoit son espace de scènes à la première sauvegarde.
if (nullSafeNodes(saved.getNodes()).isEmpty()) {
provisionContainer(saved);
return questRepository.save(saved);
}
return saved;
}
/**
* Supprime la quête et, en cascade, ses {@code QuestProgression} dans toutes les Parties.
*
* <p>Limite connue (nettoyage prévu Phase 5) : les {@code Prerequisite.QuestCompleted}
* d'autres quêtes qui pointaient celle-ci restent pendants, sans être signalés ni
* nettoyés. Échec sûr aujourd'hui : un prérequis vers une quête supprimée n'est jamais
* satisfait → la quête dépendante reste LOCKED (pas de corruption).</p>
*/
@Transactional
public void deleteQuest(String id) {
Quest quest = questRepository.findById(id).orElse(null);
progressionRepository.deleteByQuestId(id);
questRepository.deleteById(id);
if (quest == null) return;
// Nettoyage du CONTENEUR (jumeau hub ou hébergé en arc SYSTEM) : un chapitre VIDE
// (aucune scène), plus référencé par aucune autre quête, ne doit pas réapparaître
// comme « chapitre vide » fantôme. S'il contient des scènes, on le GARDE (aucune
// perte de contenu). Les chapitres simplement LIÉS (quête transversale pointant du
// contenu réel d'un autre arc) ne sont JAMAIS touchés — isContainerOf les exclut.
List<Quest> remaining = questRepository.findByCampaignId(quest.getCampaignId());
for (QuestNodeRef node : nullSafeNodes(quest.getNodes())) {
if (node.nodeType() != NodeType.CHAPTER) continue;
chapterRepository.findById(node.nodeId()).ifPresent(ch -> {
boolean container = isContainerOf(quest, ch);
boolean empty = sceneRepository.findByChapterId(ch.getId()).isEmpty();
boolean referencedElsewhere = remaining.stream()
.anyMatch(q -> nullSafeNodes(q.getNodes()).stream()
.anyMatch(n -> n.nodeType() == NodeType.CHAPTER
&& ch.getId().equals(n.nodeId())));
if (container && empty && !referencedElsewhere) {
chapterRepository.deleteById(ch.getId());
}
});
}
}
private static List<QuestNodeRef> nullSafeNodes(List<QuestNodeRef> nodes) {
return nodes != null ? nodes : List.of();
}
/** Réordonne les quêtes d'une campagne : {@code order} = position. Transactionnel. */
@Transactional
public void reorderQuests(String campaignId, List<String> orderedIds) {
ReorderSupport.reorder(orderedIds,
questRepository::findById,
(quest, i) -> {
if (campaignId != null && !campaignId.isBlank()) quest.setCampaignId(campaignId);
quest.setOrder(i);
},
questRepository::save);
}
}

View File

@@ -0,0 +1,122 @@
package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.quest.PrerequisiteEvaluator;
import com.loremind.domain.campaigncontext.quest.ProgressionStatus;
import com.loremind.domain.campaigncontext.quest.Quest;
import com.loremind.domain.campaigncontext.quest.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.QuestDTO;
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 {@link QuestDTO} 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. L'enrichissement nécessite donc un playthroughId ; sans lui (ou s'il est
* inconnu), le snapshot est vide et tout est NOT_STARTED / AVAILABLE.</p>
*/
@Service
public class QuestStatusEnricher {
private final PlaythroughRepository playthroughRepository;
private final QuestProgressionRepository progressionRepository;
private final PlaythroughFlagRepository flagRepository;
private final SessionRepository sessionRepository;
private final PrerequisiteEvaluator evaluator = new PrerequisiteEvaluator();
public QuestStatusEnricher(PlaythroughRepository playthroughRepository,
QuestProgressionRepository progressionRepository,
PlaythroughFlagRepository flagRepository,
SessionRepository sessionRepository) {
this.playthroughRepository = playthroughRepository;
this.progressionRepository = progressionRepository;
this.flagRepository = flagRepository;
this.sessionRepository = sessionRepository;
}
/** Contexte d'évaluation + map questId -> ProgressionStatus pour ce Playthrough. */
public record PlaythroughEvalSnapshot(
PrerequisiteEvaluator.EvaluationContext ctx,
Map<String, ProgressionStatus> progressionByQuestId
) {}
/** Construit le snapshot d'évaluation pour un Playthrough (court-circuit si null / inconnu). */
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.findCompletedQuestIdsByPlaythroughId(playthroughId);
int sessionCount = sessionRepository.findByPlaythroughId(playthroughId).size();
Map<String, ProgressionStatus> progressionMap = new HashMap<>();
for (QuestProgression qp : progressionRepository.findByPlaythroughId(playthroughId)) {
progressionMap.put(qp.getQuestId(), qp.getStatus());
}
return new PlaythroughEvalSnapshot(
new PrerequisiteEvaluator.EvaluationContext(completedQuestIds, sessionCount, flags),
progressionMap
);
}
/** Calcule le statut effectif d'une seule quête relatif à un Playthrough. */
public QuestStatus computeFor(Quest quest, String playthroughId) {
PlaythroughEvalSnapshot snap = buildSnapshot(playthroughId);
ProgressionStatus progression = snap.progressionByQuestId()
.getOrDefault(quest.getId(), ProgressionStatus.NOT_STARTED);
return evaluator.computeStatus(progression, quest.getPrerequisites(), snap.ctx());
}
/**
* Statut effectif de PLUSIEURS quêtes avec un seul build du snapshot (contrairement
* à {@link #computeFor} qui reconstruit le snapshot à chaque appel). Utilisé par les
* read-models qui balaient toutes les quêtes d'une campagne (préparation de séance).
*/
public Map<String, QuestStatus> computeAll(List<Quest> quests, String playthroughId) {
PlaythroughEvalSnapshot snap = buildSnapshot(playthroughId);
Map<String, QuestStatus> out = new HashMap<>();
for (Quest q : quests) {
if (q == null || q.getId() == null) continue;
ProgressionStatus progression = snap.progressionByQuestId()
.getOrDefault(q.getId(), ProgressionStatus.NOT_STARTED);
out.put(q.getId(), evaluator.computeStatus(progression, q.getPrerequisites(), snap.ctx()));
}
return out;
}
/**
* Injecte {@code progressionStatus} + {@code effectiveStatus} dans une liste de DTOs.
* Un seul build du snapshot pour toute la liste.
*/
public void enrich(List<QuestDTO> dtos, List<Quest> domain, String playthroughId) {
if (dtos == null || dtos.isEmpty()) return;
PlaythroughEvalSnapshot snap = buildSnapshot(playthroughId);
Map<String, Quest> byId = domain.stream()
.collect(Collectors.toMap(Quest::getId, q -> q));
for (QuestDTO dto : dtos) {
Quest q = byId.get(dto.getId());
if (q == null) continue;
ProgressionStatus progression = snap.progressionByQuestId()
.getOrDefault(q.getId(), ProgressionStatus.NOT_STARTED);
QuestStatus status = evaluator.computeStatus(progression, q.getPrerequisites(), snap.ctx());
dto.setProgressionStatus(progression.name());
dto.setEffectiveStatus(status.name());
}
}
}

View File

@@ -1,12 +1,10 @@
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.randomtable.RandomTable;
import com.loremind.domain.shared.ReorderSupport;
import com.loremind.domain.campaigncontext.randomtable.RandomTableEntry;
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;
@@ -21,18 +19,15 @@ public class RandomTableService {
private final RandomTableRepository repository;
private final RandomTableGenerator generator;
private final CampaignRepository campaignRepository;
private final GameSystemRepository gameSystemRepository;
private final CampaignContextFormatter campaignContextFormatter;
public RandomTableService(
RandomTableRepository repository,
RandomTableGenerator generator,
CampaignRepository campaignRepository,
GameSystemRepository gameSystemRepository) {
CampaignContextFormatter campaignContextFormatter) {
this.repository = repository;
this.generator = generator;
this.campaignRepository = campaignRepository;
this.gameSystemRepository = gameSystemRepository;
this.campaignContextFormatter = campaignContextFormatter;
}
public record TableData(
@@ -85,6 +80,15 @@ public class RandomTableService {
repository.deleteById(id);
}
/** Réordonne les tables aléatoires d'une campagne : {@code order} = position. */
@org.springframework.transaction.annotation.Transactional
public void reorderTables(List<String> orderedIds) {
ReorderSupport.reorder(orderedIds,
repository::findById,
RandomTable::setOrder,
repository::save);
}
public List<RandomTable> searchTables(String query) {
if (query == null || query.isBlank()) return List.of();
return repository.searchByName(query.trim());
@@ -93,7 +97,8 @@ public class RandomTableService {
/** 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));
RandomTableGenerator.GeneratedTable g = generator.generate(
description, formula, campaignContextFormatter.format(campaignId));
return RandomTable.builder()
.name(g.name())
.description(g.description())
@@ -105,24 +110,7 @@ public class RandomTableService {
/** 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();
return generator.improvise(tableName, resultLabel, resultDetail, campaignContextFormatter.format(campaignId));
}
private static List<RandomTableEntry> copyEntries(List<RandomTableEntry> entries) {

View File

@@ -0,0 +1,34 @@
package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.readiness.ReadinessEntityType;
import com.loremind.domain.campaigncontext.readiness.ReadinessSeverity;
/**
* Un manque de préparation détecté sur une entité de scénario (read-model, Pilier B).
*
* <p>Le message est déjà rédigé (orienté action, bienveillant) côté back ; le front
* l'affiche tel quel. {@code arcId}/{@code chapterId} sont le CONTEXTE de navigation
* (nullable selon l'entité) : le front construit le lien profond vers l'éditeur à
* partir de {@code entityType}, {@code entityId} et de ces ancêtres — aucune route
* Angular n'est codée côté back.</p>
*
* @param entityType type de l'entité concernée
* @param entityId id de l'entité concernée
* @param entityName libellé lisible de l'entité (peut être {@code null} si sans nom)
* @param ruleId identifiant stable de la règle (ex. {@code SCENE-011-COMBAT-NO-ENEMY})
* @param message message utilisateur prêt à afficher
* @param severity gravité du manque
* @param arcId arc parent (navigation), ou {@code null}
* @param chapterId chapitre parent (navigation), ou {@code null}
*/
public record ReadinessGap(
ReadinessEntityType entityType,
String entityId,
String entityName,
String ruleId,
String message,
ReadinessSeverity severity,
String arcId,
String chapterId
) {
}

View File

@@ -1,10 +1,14 @@
package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Scene;
import com.loremind.domain.campaigncontext.SceneBranch;
import com.loremind.domain.campaigncontext.generation.FieldProposal;
import com.loremind.domain.campaigncontext.structure.Scene;
import com.loremind.domain.campaigncontext.generation.SceneDraft;
import com.loremind.domain.shared.ReorderSupport;
import com.loremind.domain.campaigncontext.structure.SceneBranch;
import com.loremind.domain.campaigncontext.ports.SceneRepository;
import org.springframework.beans.BeanUtils;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.List;
@@ -84,7 +88,96 @@ public class SceneService {
return sceneRepository.save(scene);
}
/**
* Patch CIBLÉ champ-par-champ d'une scène (Pilier A — co-création). Applique
* UNIQUEMENT les {@link FieldProposal} reçus (valeurs acceptées par l'utilisateur) sur
* les champs correspondants ; tous les autres champs restent INTACTS.
*
* <p>Contraste volontaire avec {@link #updateScene} : ce dernier fait un
* {@code BeanUtils.copyProperties} qui écrase MÊME avec des null — inadapté ici où l'on
* ne veut toucher que les champs proposés. Les branches ne sont pas modifiées (pas de
* revalidation du graphe nécessaire).</p>
*/
@Transactional
public Scene patchScene(String id, List<FieldProposal> fields) {
Scene scene = sceneRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Scene non trouvée avec l'ID: " + id));
if (fields != null) {
for (FieldProposal f : fields) {
if (f == null || f.key() == null) continue;
applyField(scene, f.key(), f.proposedValue());
}
}
return sceneRepository.save(scene);
}
/**
* Applique une valeur sur le champ nommé. Whitelist STRICTE alignée sur
* {@code NarrativeEntityContextBuilder.fromScene()} : toute clé inconnue est ignorée
* (jamais d'écrasement hors de la liste connue).
*/
private void applyField(Scene scene, String key, String value) {
switch (key) {
case "description" -> scene.setDescription(value);
case "location" -> scene.setLocation(value);
case "timing" -> scene.setTiming(value);
case "atmosphere" -> scene.setAtmosphere(value);
case "playerNarration" -> scene.setPlayerNarration(value);
case "choicesConsequences" -> scene.setChoicesConsequences(value);
case "combatDifficulty" -> scene.setCombatDifficulty(value);
case "enemies" -> scene.setEnemies(value);
case "gmSecretNotes" -> scene.setGmSecretNotes(value);
default -> { /* clé inconnue → ignorée (garde-fou anti-écrasement) */ }
}
}
/**
* Crée en bloc des scènes à partir d'ébauches IA acceptées (Pilier A — capacité
* « create »). Les scènes sont AJOUTÉES à la fin du chapitre (ordre = suite des scènes
* existantes). Les ébauches sans titre sont ignorées. Transactionnel.
*/
@Transactional
public List<Scene> createDraftScenes(String chapterId, List<SceneDraft> drafts) {
if (drafts == null || drafts.isEmpty()) return List.of();
int order = sceneRepository.findByChapterId(chapterId).stream()
.mapToInt(Scene::getOrder).max().orElse(-1) + 1;
List<Scene> created = new ArrayList<>();
for (SceneDraft d : drafts) {
if (d == null || d.name() == null || d.name().isBlank()) continue;
Scene scene = Scene.builder()
.name(d.name().trim())
.description(d.description())
.playerNarration(d.playerNarration())
.chapterId(chapterId)
.order(order++)
.build();
created.add(createScene(scene)); // createScene(Scene) force id=null + rooms
}
return created;
}
/**
* Supprime la scène ET nettoie les branches des scènes sœurs qui pointaient vers elle
* (sinon elles deviennent des références mortes : invisibles dans le graphe — qui filtre
* les cibles inexistantes — mais signalées « branche cassée » par le guidage, ce qui est
* incompréhensible pour l'utilisateur). Les branches étant intra-chapitre, le nettoyage
* se limite aux sœurs du même chapitre. Transactionnel : atomique.
*/
@Transactional
public void deleteScene(String id) {
sceneRepository.findById(id).ifPresent(scene -> {
for (Scene sibling : sceneRepository.findByChapterId(scene.getChapterId())) {
List<SceneBranch> branches = sibling.getBranches();
if (id.equals(sibling.getId()) || branches == null || branches.isEmpty()) continue;
List<SceneBranch> kept = branches.stream()
.filter(b -> !id.equals(b.targetSceneId()))
.toList();
if (kept.size() != branches.size()) {
sibling.setBranches(kept);
sceneRepository.save(sibling);
}
}
});
sceneRepository.deleteById(id);
}
@@ -92,6 +185,25 @@ public class SceneService {
return sceneRepository.existsById(id);
}
/**
* Réordonne les scènes d'un chapitre : {@code order} = position. Si {@code chapterId}
* diffère (scène déplacée vers un autre chapitre), on réaffecte le chapitre et on
* vide ses branches (elles ne valent que dans le chapitre d'origine). Transactionnel.
*/
@Transactional
public void reorderScenes(String chapterId, List<String> orderedIds) {
ReorderSupport.reorder(orderedIds,
sceneRepository::findById,
(scene, i) -> {
if (chapterId != null && !chapterId.isBlank() && !chapterId.equals(scene.getChapterId())) {
scene.setChapterId(chapterId);
scene.setBranches(new ArrayList<>());
}
scene.setOrder(i);
},
sceneRepository::save);
}
/**
* Vérifie les invariants du graphe narratif :
* 1. Pas d'auto-référence (scène qui pointe sur elle-même).

View File

@@ -0,0 +1,45 @@
package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Prerequisite;
import com.loremind.domain.campaigncontext.quest.Quest;
import com.loremind.domain.campaigncontext.ports.QuestRepository;
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 quêtes 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 de quête).</p>
*
* <p>Niveau 1 : lit désormais les quêtes (entité de première classe), plus les
* chapitres HUB.</p>
*/
@Service
public class CampaignReferencedFlagsService {
private final QuestRepository questRepository;
public CampaignReferencedFlagsService(QuestRepository questRepository) {
this.questRepository = questRepository;
}
/** 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 (Quest quest : questRepository.findByCampaignId(campaignId)) {
if (quest.getPrerequisites() == null) continue;
for (Prerequisite p : quest.getPrerequisites()) {
if (p instanceof Prerequisite.FlagSet f && f.flagName() != null && !f.flagName().isBlank()) {
unique.add(f.flagName());
}
}
}
return List.copyOf(unique);
}
}

View File

@@ -11,11 +11,9 @@ import java.util.Optional;
/**
* Service d'application du contexte Conversation.
*
* Regroupe les cas d'usage CRUD + append message + rename. Un seul
* service suffit — le contexte est simple et les operations fortement
* liees (meme aggregat).
*
* Regles metier :
* - exactement un ancrage parent (loreId XOR campaignId) ;
* - entityType et entityId vont ensemble (tous deux null = niveau racine,

View File

@@ -0,0 +1,119 @@
package com.loremind.application.files;
import com.loremind.domain.files.StoredFile;
import com.loremind.domain.files.ports.FileStorage;
import com.loremind.domain.files.ports.StoredFileRepository;
import org.springframework.stereotype.Service;
import java.io.InputStream;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.Optional;
import java.util.Set;
/**
* Service d'application pour les fichiers generiques (port {@link FileStorage}
* + {@link StoredFileRepository}). Pendant de
* {@link com.loremind.application.images.ImageService}, mais SANS la contrainte
* "image" : accepte aussi video et sidecars JSON (battlemaps Universal VTT).
* <p>
* Validation MIME volontairement permissive : l'appli est mono-utilisateur
* (bureau / self-hosted), le risque "upload piege" est faible, et les outils de
* cartes (Dungeon Alchemist...) servent parfois le sidecar {@code .dd2vtt} sans
* type MIME fiable.
*/
@Service
public class StoredFileService {
private static final String DEFAULT_CONTENT_TYPE = "application/octet-stream";
/** Types MIME explicitement autorises (en plus de tout {@code image/*} et {@code video/*}). */
private static final Set<String> ALLOWED_EXACT_MIME = Set.of(
"application/json",
DEFAULT_CONTENT_TYPE,
"text/plain"
);
/** Coherent avec spring.servlet.multipart.max-file-size (application.properties). */
private static final long MAX_SIZE_BYTES = 128L * 1024 * 1024; // 128 Mo
private final StoredFileRepository repository;
private final FileStorage storage;
public StoredFileService(StoredFileRepository repository, FileStorage storage) {
this.repository = repository;
this.storage = storage;
}
/**
* Use case upload : valide -> envoie le binaire -> persiste les metadonnees.
* En cas d'echec DB apres ecriture du binaire, compense (supprime l'orphelin).
*/
public StoredFile upload(String filename, String contentType, InputStream data, long sizeBytes) {
String resolvedType = resolveContentType(contentType);
validateUpload(filename, resolvedType, sizeBytes);
String storageKey = storage.upload(filename, resolvedType, data, sizeBytes);
try {
StoredFile file = StoredFile.builder()
.filename(filename)
.contentType(resolvedType)
.sizeBytes(sizeBytes)
.storageKey(storageKey)
.uploadedAt(LocalDateTime.now(ZoneId.systemDefault()))
.build();
return repository.save(file);
} catch (RuntimeException ex) {
storage.delete(storageKey);
throw ex;
}
}
public Optional<StoredFile> getById(String id) {
return repository.findById(id);
}
public Optional<InputStream> downloadById(String id) {
return repository.findById(id)
.map(f -> storage.download(f.getStorageKey()));
}
public void deleteById(String id) {
repository.findById(id).ifPresent(f -> {
storage.delete(f.getStorageKey());
repository.deleteById(id);
});
}
// --- Validation --------------------------------------------------------
private String resolveContentType(String contentType) {
if (contentType == null || contentType.isBlank()) {
return DEFAULT_CONTENT_TYPE;
}
return contentType.toLowerCase();
}
private void validateUpload(String filename, String contentType, long sizeBytes) {
if (filename == null || filename.isBlank()) {
throw new IllegalArgumentException("Le nom du fichier est requis.");
}
if (!isAllowedMime(contentType)) {
throw new IllegalArgumentException("Type de fichier non supporte : " + contentType);
}
if (sizeBytes <= 0) {
throw new IllegalArgumentException("Le fichier est vide.");
}
if (sizeBytes > MAX_SIZE_BYTES) {
throw new IllegalArgumentException(
"Fichier trop volumineux (max " + (MAX_SIZE_BYTES / 1024 / 1024) + " Mo).");
}
}
private boolean isAllowedMime(String contentType) {
return contentType.startsWith("image/")
|| contentType.startsWith("video/")
|| ALLOWED_EXACT_MIME.contains(contentType);
}
}

View File

@@ -27,8 +27,13 @@ import java.util.regex.Pattern;
@Service
public class GameSystemContextBuilder {
/** Matche "## Titre" en début de ligne (multiline). Capture le titre en groupe 1. */
private static final Pattern H2_HEADER = Pattern.compile("(?m)^##\\s+(.+?)\\s*$");
/**
* Matche "## Titre" en début de ligne (multiline). Capture le titre en groupe 1.
* Le titre est forcé à commencer par un caractère non-blanc ({@code \S.*}) : la
* frontière entre les espaces de tête et le titre devient non ambiguë (classes de
* caractères disjointes), ce qui élimine tout risque de backtracking polynomial.
*/
private static final Pattern H2_HEADER = Pattern.compile("(?m)^##\\s+(\\S.*)$");
private final GameSystemRepository gameSystemRepository;

View File

@@ -4,11 +4,15 @@ 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.RulesPdfImporter;
import com.loremind.domain.shared.template.FieldType;
import com.loremind.domain.shared.template.TemplateField;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
@Service
public class GameSystemService {
@@ -58,6 +62,7 @@ public class GameSystemService {
List<TemplateField> characterTemplate,
List<TemplateField> npcTemplate,
List<TemplateField> enemyTemplate,
String foundryActorType,
String author,
boolean isPublic
) {}
@@ -67,6 +72,7 @@ public class GameSystemService {
.name(data.name())
.description(data.description())
.rulesMarkdown(data.rulesMarkdown())
.foundryActorType(normalize(data.foundryActorType()))
.author(normalize(data.author()))
.isPublic(data.isPublic())
.build();
@@ -93,17 +99,49 @@ public class GameSystemService {
existing.replaceCharacterTemplate(data.characterTemplate());
existing.replaceNpcTemplate(data.npcTemplate());
existing.replaceEnemyTemplate(data.enemyTemplate());
existing.setFoundryActorType(normalize(data.foundryActorType()));
existing.setAuthor(normalize(data.author()));
existing.setPublic(data.isPublic());
return gameSystemRepository.save(existing);
}
public void deleteGameSystem(String id) {
gameSystemRepository.deleteById(id);
/** Un champ scalaire d'une structure d'acteur Foundry importée. */
public record FoundryStructField(String path, String label, String type) {}
/**
* Remplace le template ENNEMI par une structure importée d'un système Foundry :
* chaque champ devient un TemplateField mappé à son chemin Foundry. Pose aussi le
* type d'acteur. L'utilisateur élague/renomme ensuite dans l'éditeur de template.
*/
public GameSystem importFoundryStructure(String id, String actorType, List<FoundryStructField> fields) {
GameSystem gs = gameSystemRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("GameSystem non trouvé avec l'ID: " + id));
List<TemplateField> template = new ArrayList<>();
Set<String> usedNames = new HashSet<>();
for (FoundryStructField f : fields != null ? fields : List.<FoundryStructField>of()) {
TemplateField field = toTemplateField(f, usedNames);
if (field != null) template.add(field);
}
gs.replaceEnemyTemplate(template);
gs.setFoundryActorType(normalize(actorType));
return gameSystemRepository.save(gs);
}
public boolean gameSystemExists(String id) {
return gameSystemRepository.existsById(id);
/** Convertit un champ Foundry en TemplateField, ou null si son chemin est vide ou son nom déjà pris. */
private static TemplateField toTemplateField(FoundryStructField f, Set<String> usedNames) {
if (f.path() == null || f.path().isBlank()) return null;
String label = (f.label() != null && !f.label().isBlank()) ? f.label().trim() : f.path();
// Nom unique : libellé si libre, sinon le chemin (toujours unique).
String name = usedNames.contains(label.toLowerCase()) ? f.path() : label;
if (usedNames.contains(name.toLowerCase())) return null;
usedNames.add(name.toLowerCase());
FieldType type = "number".equalsIgnoreCase(f.type()) ? FieldType.NUMBER : FieldType.TEXT;
return new TemplateField(name, type, null, null, f.path());
}
public void deleteGameSystem(String id) {
gameSystemRepository.deleteById(id);
}
public List<GameSystem> searchGameSystems(String query) {

View File

@@ -1,12 +1,12 @@
package com.loremind.application.generationcontext;
import com.loremind.domain.campaigncontext.Arc;
import com.loremind.domain.campaigncontext.ArcType;
import com.loremind.domain.campaigncontext.structure.Arc;
import com.loremind.domain.campaigncontext.structure.ArcType;
import com.loremind.domain.campaigncontext.Campaign;
import com.loremind.domain.campaigncontext.Chapter;
import com.loremind.domain.campaigncontext.Character;
import com.loremind.domain.campaigncontext.Npc;
import com.loremind.domain.campaigncontext.Scene;
import com.loremind.domain.campaigncontext.structure.Chapter;
import com.loremind.domain.campaigncontext.bestiary.Character;
import com.loremind.domain.campaigncontext.bestiary.Npc;
import com.loremind.domain.campaigncontext.structure.Scene;
import com.loremind.domain.campaigncontext.ports.ArcRepository;
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
@@ -93,26 +93,26 @@ public class CampaignStructuralContextBuilder {
// les enemyIds des pièces sans N+1 sur le repo.
Map<String, String> enemyLabelById = enemyRepository.findByCampaignId(campaignId).stream()
.collect(Collectors.toMap(
com.loremind.domain.campaigncontext.Enemy::getId,
com.loremind.domain.campaigncontext.bestiary.Enemy::getId,
CampaignStructuralContextBuilder::enemyLabel,
(a, b) -> a));
List<ArcSummary> arcs = arcRepository.findByCampaignId(campaignId).stream()
.sorted(Comparator.comparingInt(Arc::getOrder))
.map(arc -> toArcSummary(arc, enemyLabelById))
.collect(Collectors.toList());
.toList();
List<CharacterSummary> characters = (playthroughId == null || playthroughId.isBlank())
? List.of()
: characterRepository.findByPlaythroughId(playthroughId).stream()
.sorted(Comparator.comparingInt(Character::getOrder))
.map(this::toCharacterSummary)
.collect(Collectors.toList());
.toList();
List<NpcSummary> npcs = npcRepository.findByCampaignId(campaignId).stream()
.sorted(Comparator.comparingInt(Npc::getOrder))
.map(this::toNpcSummary)
.collect(Collectors.toList());
.toList();
return new CampaignStructuralContext(
campaign.getName(),
@@ -140,27 +140,36 @@ public class CampaignStructuralContextBuilder {
* Snippet pour le resume IA : 1re ligne signifiante de la 1re valeur non vide
* du template (refonte 2026-04-30 — remplace l'ancien parsing markdown).
*/
private static String extractSnippet(java.util.Map<String, String> values) {
private static String extractSnippet(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 "";
return values.values().stream()
.filter(v -> v != null && !v.isBlank())
.map(CampaignStructuralContextBuilder::firstMeaningfulLine)
.filter(l -> !l.isEmpty())
.findFirst()
.map(CampaignStructuralContextBuilder::truncateSnippet)
.orElse("");
}
/** 1re ligne non vide et non-titre ("# ...") d'une valeur de template, ou "" si aucune. */
private static String firstMeaningfulLine(String value) {
return value.lines()
.map(String::strip)
.filter(l -> !l.isEmpty() && !l.startsWith("#"))
.findFirst()
.orElse("");
}
private static String truncateSnippet(String firstLine) {
if (firstLine.length() <= CHARACTER_SNIPPET_MAX_LEN) return firstLine;
return firstLine.substring(0, CHARACTER_SNIPPET_MAX_LEN - 1).stripTrailing() + "";
}
private ArcSummary toArcSummary(Arc arc, Map<String, String> enemyLabelById) {
List<ChapterSummary> chapters = chapterRepository.findByArcId(arc.getId()).stream()
.sorted(Comparator.comparingInt(Chapter::getOrder))
.map(chapter -> toChapterSummary(chapter, enemyLabelById))
.collect(Collectors.toList());
.toList();
return new ArcSummary(
arc.getName(),
arc.getDescription(),
@@ -181,7 +190,7 @@ public class CampaignStructuralContextBuilder {
List<SceneSummary> summaries = scenes.stream()
.map(s -> toSceneSummary(s, nameById, enemyLabelById))
.collect(Collectors.toList());
.toList();
return new ChapterSummary(
chapter.getName(),
@@ -199,7 +208,7 @@ public class CampaignStructuralContextBuilder {
b.label(),
nameById.getOrDefault(b.targetSceneId(), "(scène inconnue)"),
b.condition()))
.collect(Collectors.toList());
.toList();
List<RoomSummary> rooms = toRoomSummaries(scene, enemyLabelById);
@@ -221,8 +230,8 @@ public class CampaignStructuralContextBuilder {
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,
com.loremind.domain.campaigncontext.structure.Room::getId,
com.loremind.domain.campaigncontext.structure.Room::getName,
(a, b) -> a));
return scene.getRooms().stream()
.map(r -> {
@@ -233,12 +242,12 @@ public class CampaignStructuralContextBuilder {
b.label(),
nameById.getOrDefault(b.targetRoomId(), "(pièce inconnue)"),
b.condition()))
.collect(Collectors.toList());
.toList();
return new RoomSummary(
r.getName(), r.getFloor(), r.getDescription(),
roomEnemiesText(r, enemyLabelById), hints);
})
.collect(Collectors.toList());
.toList();
}
/**
@@ -247,7 +256,7 @@ public class CampaignStructuralContextBuilder {
* libre. L'un ou l'autre peut être vide.
*/
private static String roomEnemiesText(
com.loremind.domain.campaigncontext.Room room, Map<String, String> enemyLabelById) {
com.loremind.domain.campaigncontext.structure.Room room, Map<String, String> enemyLabelById) {
String linked = room.getEnemyIds() == null ? "" : room.getEnemyIds().stream()
.map(enemyLabelById::get)
.filter(l -> l != null && !l.isBlank())
@@ -259,7 +268,7 @@ public class CampaignStructuralContextBuilder {
}
/** Libellé court d'une fiche du bestiaire : « Nom (niveau) » ou « Nom ». */
private static String enemyLabel(com.loremind.domain.campaigncontext.Enemy enemy) {
private static String enemyLabel(com.loremind.domain.campaigncontext.bestiary.Enemy enemy) {
String level = enemy.getLevel() == null ? "" : enemy.getLevel().strip();
return level.isEmpty() ? enemy.getName() : enemy.getName() + " (" + level + ")";
}

View File

@@ -0,0 +1,94 @@
package com.loremind.application.generationcontext;
import com.loremind.application.campaigncontext.CampaignContextFormatter;
import com.loremind.domain.campaigncontext.structure.Chapter;
import com.loremind.domain.campaigncontext.structure.Scene;
import com.loremind.domain.campaigncontext.generation.SceneDraft;
import com.loremind.domain.campaigncontext.generation.SceneDraftProposal;
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
import com.loremind.domain.campaigncontext.ports.SceneDraftAssistant;
import com.loremind.domain.campaigncontext.ports.SceneRepository;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.stream.Collectors;
/**
* Use case (Pilier A — capacité « create ») : produit une PROPOSITION d'ébauches de scènes
* pour un chapitre (non persistée). Répond directement à la « page blanche » / au manque
* de guidage « Chapitre vide » (Pilier B).
*
* <p>Contexte compact : le chapitre (objectifs/enjeux) + les scènes DÉJÀ présentes (pour
* éviter les doublons) + méta campagne. Zéro écriture — la création est un second appel.</p>
*/
@Service
public class DraftScenesUseCase {
private static final int MIN_COUNT = 1;
private static final int MAX_COUNT = 8;
private final ChapterRepository chapterRepository;
private final SceneRepository sceneRepository;
private final CampaignContextFormatter campaignContextFormatter;
private final SceneDraftAssistant assistant;
public DraftScenesUseCase(ChapterRepository chapterRepository,
SceneRepository sceneRepository,
CampaignContextFormatter campaignContextFormatter,
SceneDraftAssistant assistant) {
this.chapterRepository = chapterRepository;
this.sceneRepository = sceneRepository;
this.campaignContextFormatter = campaignContextFormatter;
this.assistant = assistant;
}
public SceneDraftProposal execute(String chapterId, String campaignId, String instruction, int count) {
Chapter chapter = chapterRepository.findById(chapterId)
.orElseThrow(() -> new IllegalArgumentException("Chapitre non trouvé: " + chapterId));
int n = Math.max(MIN_COUNT, Math.min(MAX_COUNT, count));
String context = buildContext(chapter, campaignId);
List<SceneDraft> drafts = assistant.draftScenes(context, instruction, n).stream()
.filter(d -> d != null && d.name() != null && !d.name().isBlank())
.limit(n)
.toList();
return new SceneDraftProposal(chapterId, drafts);
}
private String buildContext(Chapter chapter, String campaignId) {
StringBuilder sb = new StringBuilder();
sb.append("Chapitre : ").append(blankToLabel(chapter.getName())).append("\n");
appendIf(sb, "Synopsis", chapter.getDescription());
appendIf(sb, "Objectifs des joueurs", chapter.getPlayerObjectives());
appendIf(sb, "Enjeux narratifs", chapter.getNarrativeStakes());
List<Scene> existing = sceneRepository.findByChapterId(chapter.getId());
if (!existing.isEmpty()) {
String names = existing.stream()
.map(Scene::getName)
.filter(nm -> nm != null && !nm.isBlank())
.collect(Collectors.joining(" ; "));
if (!names.isEmpty()) {
sb.append("Scènes DÉJÀ présentes (ne pas dupliquer) : ").append(names).append("\n");
}
}
if (campaignId != null && !campaignId.isBlank()) {
String campaignBlock = campaignContextFormatter.format(campaignId);
if (!campaignBlock.isBlank()) {
sb.append(campaignBlock).append("\n");
}
}
return sb.toString();
}
private static void appendIf(StringBuilder sb, String label, String value) {
if (value != null && !value.isBlank()) {
sb.append(label).append(" : ").append(value.trim()).append("\n");
}
}
private static String blankToLabel(String value) {
return value == null || value.isBlank() ? "(sans titre)" : value;
}
}

View File

@@ -30,6 +30,8 @@ import java.util.Map;
@Service
public class GeneratePageValuesUseCase {
private static final String FOR_PAGE = ") pour la page ";
private final PageRepository pageRepository;
private final TemplateRepository templateRepository;
private final LoreRepository loreRepository;
@@ -96,22 +98,19 @@ public class GeneratePageValuesUseCase {
}
return templateRepository.findById(templateId)
.orElseThrow(() -> new IllegalStateException(
"Template introuvable (id=" + templateId
+ ") pour la page " + pageId));
"Template introuvable (id=" + templateId + FOR_PAGE + pageId));
}
private Lore loadLore(String loreId, String pageId) {
return loreRepository.findById(loreId)
.orElseThrow(() -> new IllegalStateException(
"Lore introuvable (id=" + loreId
+ ") pour la page " + pageId));
"Lore introuvable (id=" + loreId + FOR_PAGE + pageId));
}
private LoreNode loadFolder(String nodeId, String pageId) {
return loreNodeRepository.findById(nodeId)
.orElseThrow(() -> new IllegalStateException(
"Dossier parent introuvable (id=" + nodeId
+ ") pour la page " + pageId));
"Dossier parent introuvable (id=" + nodeId + FOR_PAGE + pageId));
}
private void requireNonEmptyFields(Template template) {

View File

@@ -110,7 +110,7 @@ public class LoreStructuralContextBuilder {
return allPages.stream()
.filter(p -> nodeId.equals(p.getNodeId()))
.map(p -> toPageSummary(p, templateNameById, pageTitleById))
.collect(Collectors.toList());
.toList();
}
private PageSummary toPageSummary(
@@ -160,7 +160,7 @@ public class LoreStructuralContextBuilder {
return relatedIds.stream()
.map(pageTitleById::get)
.filter(title -> title != null && !title.isBlank())
.collect(Collectors.toList());
.toList();
}
private List<String> extractUniqueTags(List<Page> pages) {
@@ -168,6 +168,6 @@ public class LoreStructuralContextBuilder {
.filter(p -> p.getTags() != null)
.flatMap(p -> p.getTags().stream())
.distinct()
.collect(Collectors.toList());
.toList();
}
}

View File

@@ -0,0 +1,108 @@
package com.loremind.application.generationcontext;
import com.loremind.application.campaigncontext.CampaignContextFormatter;
import com.loremind.domain.campaigncontext.generation.EntityFieldPatchProposal;
import com.loremind.domain.campaigncontext.generation.FieldProposal;
import com.loremind.domain.campaigncontext.ports.NarrativeFieldAssistant;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
/**
* Use case (Pilier A — co-création) : produit une PROPOSITION d'étoffage des champs d'une
* entité narrative (arc / chapitre / scène), non persistée. Générique par {@code entityType}
* grâce à {@link NarrativeFieldCatalog} (source de vérité des champs) : la même mécanique
* couvre les trois types, seul le catalogue de champs change.
*
* <p>Contexte volontairement COMPACT (entité + méta campagne). Zéro écriture — l'application
* est un second appel explicite (human-in-the-loop).</p>
*/
@Service
public class NarrativeAssistFieldsUseCase {
private final NarrativeFieldCatalog catalog;
private final CampaignContextFormatter campaignContextFormatter;
private final NarrativeFieldAssistant assistant;
public NarrativeAssistFieldsUseCase(
NarrativeFieldCatalog catalog,
CampaignContextFormatter campaignContextFormatter,
NarrativeFieldAssistant assistant) {
this.catalog = catalog;
this.campaignContextFormatter = campaignContextFormatter;
this.assistant = assistant;
}
public EntityFieldPatchProposal execute(String entityType, String entityId, String campaignId, String instruction) {
NarrativeFieldCatalog.Snapshot snap = catalog.read(entityType, entityId);
List<NarrativeFieldAssistant.FieldSpec> specs = snap.defs().stream()
.map(d -> new NarrativeFieldAssistant.FieldSpec(d.key(), d.label()))
.toList();
Set<String> allowed = snap.defs().stream()
.map(NarrativeFieldCatalog.FieldDef::key).collect(Collectors.toSet());
String context = buildContext(campaignId, snap);
List<NarrativeFieldAssistant.ProposedField> proposed =
assistant.assist(snap.entityType(), context, instruction, specs);
List<FieldProposal> fields = new ArrayList<>();
for (NarrativeFieldAssistant.ProposedField pf : proposed) {
FieldProposal proposal = toFieldProposal(pf, allowed, snap);
if (proposal != null) fields.add(proposal);
}
return new EntityFieldPatchProposal(snap.entityType(), entityId, "patch", fields);
}
/** Convertit un champ proposé par l'IA en FieldProposal, ou null si sa clé n'est pas autorisée ou sa valeur vide. */
private static FieldProposal toFieldProposal(
NarrativeFieldAssistant.ProposedField pf, Set<String> allowed, NarrativeFieldCatalog.Snapshot snap) {
if (pf.key() == null || !allowed.contains(pf.key())) return null;
if (pf.value() == null || pf.value().isBlank()) return null;
String current = snap.current().getOrDefault(pf.key(), "");
return new FieldProposal(pf.key(), current, pf.value());
}
/** Contexte compact : type + titre + valeurs actuelles non vides + méta campagne. */
private String buildContext(String campaignId, NarrativeFieldCatalog.Snapshot snap) {
StringBuilder sb = new StringBuilder();
sb.append(entityLabel(snap.entityType())).append(" : ")
.append(blankToLabel(snap.title())).append("\n");
sb.append("État actuel des champs :\n");
boolean any = false;
for (Map.Entry<String, String> e : snap.current().entrySet()) {
String v = e.getValue();
if (v != null && !v.isBlank()) {
sb.append("- ").append(e.getKey()).append(" : ").append(v.trim()).append("\n");
any = true;
}
}
if (!any) {
sb.append("- (tous les champs sont vides — à créer de zéro)\n");
}
if (campaignId != null && !campaignId.isBlank()) {
String campaignBlock = campaignContextFormatter.format(campaignId);
if (!campaignBlock.isBlank()) {
sb.append(campaignBlock).append("\n");
}
}
return sb.toString();
}
private static String entityLabel(String entityType) {
return switch (entityType == null ? "" : entityType) {
case "arc" -> "Arc";
case "chapter" -> "Chapitre";
case "scene" -> "Scène";
default -> "Entité";
};
}
private static String blankToLabel(String value) {
return value == null || value.isBlank() ? "(sans titre)" : value;
}
}

View File

@@ -1,10 +1,10 @@
package com.loremind.application.generationcontext;
import com.loremind.domain.campaigncontext.Arc;
import com.loremind.domain.campaigncontext.Chapter;
import com.loremind.domain.campaigncontext.Character;
import com.loremind.domain.campaigncontext.Npc;
import com.loremind.domain.campaigncontext.Scene;
import com.loremind.domain.campaigncontext.structure.Arc;
import com.loremind.domain.campaigncontext.structure.Chapter;
import com.loremind.domain.campaigncontext.bestiary.Character;
import com.loremind.domain.campaigncontext.bestiary.Npc;
import com.loremind.domain.campaigncontext.structure.Scene;
import com.loremind.domain.campaigncontext.ports.ArcRepository;
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
import com.loremind.domain.campaigncontext.ports.CharacterRepository;
@@ -28,6 +28,9 @@ import java.util.Map;
@Component
public class NarrativeEntityContextBuilder {
/** Longueur max d'une valeur de stat dans le résumé d'ennemi lié (contexte focus compact). */
private static final int STAT_VALUE_MAX_LEN = 100;
private final ArcRepository arcRepository;
private final ChapterRepository chapterRepository;
private final SceneRepository sceneRepository;
@@ -144,14 +147,14 @@ public class NarrativeEntityContextBuilder {
StringBuilder sb = new StringBuilder();
for (String enemyId : s.getEnemyIds()) {
enemyRepository.findById(enemyId).ifPresent(e -> {
if (sb.length() > 0) sb.append("\n");
if (!sb.isEmpty()) sb.append("\n");
sb.append("- ").append(e.getName());
if (e.getLevel() != null && !e.getLevel().isBlank()) {
sb.append(" (").append(e.getLevel().trim()).append(")");
}
String stats = e.getValues().entrySet().stream()
.filter(en -> en.getValue() != null && !en.getValue().isBlank())
.map(en -> en.getKey() + ": " + truncate(en.getValue().trim(), 100))
.map(en -> en.getKey() + ": " + truncate(en.getValue().trim()))
.collect(java.util.stream.Collectors.joining(" ; "));
if (!stats.isEmpty()) sb.append("").append(stats);
});
@@ -159,8 +162,8 @@ public class NarrativeEntityContextBuilder {
return sb.toString();
}
private static String truncate(String value, int maxLen) {
return value.length() <= maxLen ? value : value.substring(0, maxLen - 1).stripTrailing() + "";
private static String truncate(String value) {
return value.length() <= STAT_VALUE_MAX_LEN ? value : value.substring(0, STAT_VALUE_MAX_LEN - 1).stripTrailing() + "";
}
private NarrativeEntityContext fromCharacter(Character c) {

View File

@@ -0,0 +1,129 @@
package com.loremind.application.generationcontext;
import com.loremind.domain.campaigncontext.structure.Arc;
import com.loremind.domain.campaigncontext.structure.Chapter;
import com.loremind.domain.campaigncontext.structure.Scene;
import com.loremind.domain.campaigncontext.ports.ArcRepository;
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
import com.loremind.domain.campaigncontext.ports.SceneRepository;
import org.springframework.stereotype.Component;
import java.util.LinkedHashMap;
import java.util.List;
/**
* Catalogue des champs ÉTOFFABLES par l'IA (Pilier A), par type d'entité narrative.
* SOURCE DE VÉRITÉ unique côté génération : ordre + clés + libellés + valeurs actuelles.
*
* <p>Les clés correspondent aux setters des services (patch) et aux contrôles de
* formulaire côté front. Le champ {@code name}/{@code type} n'est jamais étoffable
* (identité / enum). La liaison clé → setter reste dans chaque service (persistance).</p>
*/
@Component
public class NarrativeFieldCatalog {
/** Définition d'un champ : clé technique + libellé (guide le prompt du Brain). */
public record FieldDef(String key, String label) {}
/** Instantané d'une entité pour l'étoffage : titre + valeurs actuelles + champs. */
public record Snapshot(String entityType, String title,
LinkedHashMap<String, String> current, List<FieldDef> defs) {}
private static final String FIELD_DESCRIPTION = "description";
private static final String FIELD_GM_NOTES = "gmNotes";
private static final List<FieldDef> ARC_DEFS = List.of(
new FieldDef(FIELD_DESCRIPTION, "description / synopsis de l'arc"),
new FieldDef("themes", "thèmes explorés"),
new FieldDef("stakes", "enjeux globaux pour les personnages"),
new FieldDef("rewards", "récompenses et progression"),
new FieldDef("resolution", "dénouement prévu"),
new FieldDef(FIELD_GM_NOTES, "notes privées du MJ"));
private static final List<FieldDef> CHAPTER_DEFS = List.of(
new FieldDef(FIELD_DESCRIPTION, "synopsis du chapitre"),
new FieldDef("playerObjectives", "objectifs des joueurs"),
new FieldDef("narrativeStakes", "enjeux narratifs dramatiques"),
new FieldDef(FIELD_GM_NOTES, "notes privées du MJ"));
private static final List<FieldDef> SCENE_DEFS = List.of(
new FieldDef(FIELD_DESCRIPTION, "description courte de la scène"),
new FieldDef("location", "lieu où se déroule la scène"),
new FieldDef("timing", "moment / temporalité"),
new FieldDef("atmosphere", "ambiance (sons, odeurs, émotions, lumière)"),
new FieldDef("playerNarration", "texte de mise en scène lu aux joueurs"),
new FieldDef("choicesConsequences", "choix offerts aux joueurs et leurs conséquences"),
new FieldDef("combatDifficulty", "difficulté de combat estimée"),
new FieldDef("enemies", "ennemis / créatures présentes (texte libre)"),
new FieldDef("gmSecretNotes", "notes secrètes du MJ (cachées des joueurs)"));
private final ArcRepository arcRepository;
private final ChapterRepository chapterRepository;
private final SceneRepository sceneRepository;
public NarrativeFieldCatalog(ArcRepository arcRepository,
ChapterRepository chapterRepository,
SceneRepository sceneRepository) {
this.arcRepository = arcRepository;
this.chapterRepository = chapterRepository;
this.sceneRepository = sceneRepository;
}
/** Charge l'entité et son instantané d'étoffage. @throws IllegalArgumentException si type/entité inconnu. */
public Snapshot read(String entityType, String entityId) {
return switch (normalize(entityType)) {
case "arc" -> arcSnapshot(entityId);
case "chapter" -> chapterSnapshot(entityId);
case "scene" -> sceneSnapshot(entityId);
default -> throw new IllegalArgumentException("Type d'entité narrative inconnu: " + entityType);
};
}
private Snapshot arcSnapshot(String id) {
Arc a = arcRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Arc non trouvé: " + id));
LinkedHashMap<String, String> cur = new LinkedHashMap<>();
cur.put(FIELD_DESCRIPTION, nz(a.getDescription()));
cur.put("themes", nz(a.getThemes()));
cur.put("stakes", nz(a.getStakes()));
cur.put("rewards", nz(a.getRewards()));
cur.put("resolution", nz(a.getResolution()));
cur.put(FIELD_GM_NOTES, nz(a.getGmNotes()));
return new Snapshot("arc", a.getName(), cur, ARC_DEFS);
}
private Snapshot chapterSnapshot(String id) {
Chapter c = chapterRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Chapitre non trouvé: " + id));
LinkedHashMap<String, String> cur = new LinkedHashMap<>();
cur.put(FIELD_DESCRIPTION, nz(c.getDescription()));
cur.put("playerObjectives", nz(c.getPlayerObjectives()));
cur.put("narrativeStakes", nz(c.getNarrativeStakes()));
cur.put(FIELD_GM_NOTES, nz(c.getGmNotes()));
return new Snapshot("chapter", c.getName(), cur, CHAPTER_DEFS);
}
private Snapshot sceneSnapshot(String id) {
Scene s = sceneRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Scène non trouvée: " + id));
LinkedHashMap<String, String> cur = new LinkedHashMap<>();
cur.put(FIELD_DESCRIPTION, nz(s.getDescription()));
cur.put("location", nz(s.getLocation()));
cur.put("timing", nz(s.getTiming()));
cur.put("atmosphere", nz(s.getAtmosphere()));
cur.put("playerNarration", nz(s.getPlayerNarration()));
cur.put("choicesConsequences", nz(s.getChoicesConsequences()));
cur.put("combatDifficulty", nz(s.getCombatDifficulty()));
cur.put("enemies", nz(s.getEnemies()));
cur.put("gmSecretNotes", nz(s.getGmSecretNotes()));
return new Snapshot("scene", s.getName(), cur, SCENE_DEFS);
}
private static String normalize(String entityType) {
return entityType == null ? "" : entityType.trim().toLowerCase();
}
private static String nz(String v) {
return v == null ? "" : v;
}
}

View File

@@ -1,13 +1,10 @@
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.campaigncontext.quest.PrerequisiteEvaluator;
import com.loremind.domain.campaigncontext.quest.ProgressionStatus;
import com.loremind.domain.campaigncontext.quest.Quest;
import com.loremind.domain.campaigncontext.quest.QuestStatus;
import com.loremind.domain.campaigncontext.ports.QuestRepository;
import com.loremind.domain.generationcontext.SessionContext;
import com.loremind.domain.generationcontext.SessionContext.JournalEntrySummary;
import com.loremind.domain.generationcontext.SessionContext.QuestSummary;
@@ -29,7 +26,6 @@ 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.
@@ -46,8 +42,7 @@ public class SessionStructuralContextBuilder {
private final SessionRepository sessionRepository;
private final SessionEntryRepository entryRepository;
private final PlaythroughRepository playthroughRepository;
private final ArcRepository arcRepository;
private final ChapterRepository chapterRepository;
private final QuestRepository questRepository;
private final PlaythroughFlagRepository playthroughFlagRepository;
private final QuestProgressionRepository questProgressionRepository;
private final PrerequisiteEvaluator prerequisiteEvaluator = new PrerequisiteEvaluator();
@@ -55,23 +50,17 @@ public class SessionStructuralContextBuilder {
public SessionStructuralContextBuilder(SessionRepository sessionRepository,
SessionEntryRepository entryRepository,
PlaythroughRepository playthroughRepository,
ArcRepository arcRepository,
ChapterRepository chapterRepository,
QuestRepository questRepository,
PlaythroughFlagRepository playthroughFlagRepository,
QuestProgressionRepository questProgressionRepository) {
this.sessionRepository = sessionRepository;
this.entryRepository = entryRepository;
this.playthroughRepository = playthroughRepository;
this.arcRepository = arcRepository;
this.chapterRepository = chapterRepository;
this.questRepository = questRepository;
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));
@@ -103,7 +92,7 @@ public class SessionStructuralContextBuilder {
return kept.stream()
.map(e -> toSummary(e, null))
.collect(Collectors.toList());
.toList();
}
/**
@@ -169,23 +158,14 @@ public class SessionStructuralContextBuilder {
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<>();
// Map questId -> ProgressionStatus pour ce Playthrough
Map<String, ProgressionStatus> progressionByQuest = new HashMap<>();
for (QuestProgression qp : questProgressionRepository.findByPlaythroughId(playthroughId)) {
progressionByChapter.put(qp.getChapterId(), qp.getStatus());
progressionByQuest.put(qp.getQuestId(), qp.getStatus());
}
// IDs des chapitres COMPLETED dans la campagne (pour les prérequis QuestCompleted)
var completedIds = questProgressionRepository.findCompletedChapterIdsByPlaythroughId(playthroughId);
// IDs des quêtes COMPLETED dans la campagne (pour les prérequis QuestCompleted)
var completedIds = questProgressionRepository.findCompletedQuestIdsByPlaythroughId(playthroughId);
int sessionCount = sessionRepository.findByPlaythroughId(playthroughId).size();
PrerequisiteEvaluator.EvaluationContext ctx =
@@ -195,29 +175,24 @@ public class SessionStructuralContextBuilder {
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;
}
// Niveau 1 : les quêtes sont des entités orthogonales rattachées à la campagne
// (plus des chapitres HUB). arcName n'a plus de sens => null.
for (Quest q : questRepository.findByCampaignId(campaignId)) {
ProgressionStatus prog = progressionByQuest.getOrDefault(q.getId(), ProgressionStatus.NOT_STARTED);
QuestStatus status = prerequisiteEvaluator.computeStatus(prog, q.getPrerequisites(), ctx);
switch (status) {
case AVAILABLE:
available.add(new QuestSummary(q.getName(), null, q.getDescription()));
break;
case IN_PROGRESS:
inProgress.add(new QuestSummary(q.getName(), null, q.getDescription()));
break;
case LOCKED:
lockedTitles.add(q.getName());
break;
case COMPLETED:
// Omis (déjà dans le journal des EVENTs).
break;
}
}
@@ -229,6 +204,6 @@ public class SessionStructuralContextBuilder {
.filter(Map.Entry::getValue)
.map(Map.Entry::getKey)
.sorted()
.collect(Collectors.toList());
.toList();
}
}

View File

@@ -7,7 +7,7 @@ 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.ChatStreamCallbacks;
import com.loremind.domain.generationcontext.GameSystemContext;
import com.loremind.domain.generationcontext.LoreStructuralContext;
import com.loremind.domain.generationcontext.NarrativeEntityContext;
@@ -15,7 +15,6 @@ import com.loremind.domain.generationcontext.ports.AiChatProvider;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.function.Consumer;
/**
* Use case applicatif : chat conversationnel pour une Campagne avec Structural Context.
@@ -72,10 +71,7 @@ public class StreamChatForCampaignUseCase {
String entityType,
String entityId,
List<ChatMessage> messages,
Consumer<ChatUsage> onUsage,
Consumer<String> onToken,
Runnable onComplete,
Consumer<Throwable> onError) {
ChatStreamCallbacks callbacks) {
Campaign campaign = campaignRepository.findById(campaignId)
.orElseThrow(() -> new IllegalArgumentException(
@@ -94,7 +90,7 @@ public class StreamChatForCampaignUseCase {
.gameSystemContext(gameSystemContext)
.build();
aiChatProvider.streamChat(request, onUsage, onToken, onComplete, onError);
aiChatProvider.streamChat(request, callbacks);
}
/**

View File

@@ -2,7 +2,7 @@ package com.loremind.application.generationcontext;
import com.loremind.domain.generationcontext.ChatMessage;
import com.loremind.domain.generationcontext.ChatRequest;
import com.loremind.domain.generationcontext.ChatUsage;
import com.loremind.domain.generationcontext.ChatStreamCallbacks;
import com.loremind.domain.generationcontext.LoreStructuralContext;
import com.loremind.domain.generationcontext.PageContext;
import com.loremind.domain.generationcontext.ports.AiChatProvider;
@@ -15,7 +15,6 @@ import org.springframework.stereotype.Service;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.function.Consumer;
/**
* Use case applicatif : chat conversationnel avec Structural Context d'un Lore.
@@ -61,10 +60,7 @@ public class StreamChatForLoreUseCase {
String loreId,
String pageId,
List<ChatMessage> messages,
Consumer<ChatUsage> onUsage,
Consumer<String> onToken,
Runnable onComplete,
Consumer<Throwable> onError) {
ChatStreamCallbacks callbacks) {
LoreStructuralContext loreContext = loreContextBuilder.build(loreId);
PageContext pageContext = (pageId == null || pageId.isBlank())
@@ -77,7 +73,7 @@ public class StreamChatForLoreUseCase {
.pageContext(pageContext)
.build();
aiChatProvider.streamChat(request, onUsage, onToken, onComplete, onError);
aiChatProvider.streamChat(request, callbacks);
}
/**

View File

@@ -7,7 +7,7 @@ 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.ChatStreamCallbacks;
import com.loremind.domain.generationcontext.GameSystemContext;
import com.loremind.domain.generationcontext.LoreStructuralContext;
import com.loremind.domain.generationcontext.SessionContext;
@@ -19,7 +19,6 @@ 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.
@@ -71,10 +70,7 @@ public class StreamChatForSessionUseCase {
public void execute(
String sessionId,
List<ChatMessage> messages,
Consumer<ChatUsage> onUsage,
Consumer<String> onToken,
Runnable onComplete,
Consumer<Throwable> onError) {
ChatStreamCallbacks callbacks) {
Session session = sessionRepository.findById(sessionId)
.orElseThrow(() -> new IllegalArgumentException("Session introuvable : " + sessionId));
@@ -102,7 +98,7 @@ public class StreamChatForSessionUseCase {
.sessionContext(sessionContext)
.build();
aiChatProvider.streamChat(request, onUsage, onToken, onComplete, onError);
aiChatProvider.streamChat(request, callbacks);
}
private LoreStructuralContext loadLoreContextOrNull(Campaign campaign) {

View File

@@ -7,6 +7,7 @@ import org.springframework.stereotype.Service;
import java.io.InputStream;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.List;
import java.util.Optional;
import java.util.Set;
@@ -60,7 +61,7 @@ public class ImageService {
.contentType(contentType)
.sizeBytes(sizeBytes)
.storageKey(storageKey)
.uploadedAt(LocalDateTime.now())
.uploadedAt(LocalDateTime.now(ZoneId.systemDefault()))
.build();
return imageRepository.save(image);
} catch (RuntimeException ex) {

View File

@@ -52,8 +52,8 @@ public class LicenseService {
this.repository = repository;
this.jwtVerifier = jwtVerifier;
this.relay = relay;
this.gracePeriodSeconds = (long) gracePeriodDays * 86_400L;
this.refreshBeforeExpirySeconds = (long) refreshBeforeExpiryDays * 86_400L;
this.gracePeriodSeconds = gracePeriodDays * 86_400L;
this.refreshBeforeExpirySeconds = refreshBeforeExpiryDays * 86_400L;
this.licensingEnabled = licensingEnabled;
}
@@ -134,9 +134,9 @@ public class LicenseService {
* 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());
return repository.findCurrent()
.map(l -> snapshotOf(l, Instant.now()))
.orElseGet(LicenseSnapshot::none);
}
/**
@@ -161,31 +161,32 @@ public class LicenseService {
/**
* 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() {
public void refreshIfNeeded() {
Optional<License> opt = repository.findCurrent();
if (opt.isEmpty()) return false;
if (opt.isEmpty()) return;
License current = opt.get();
Instant now = Instant.now();
long secondsUntilExpiry = Duration.between(now, current.getExpiresAt()).getSeconds();
if (secondsUntilExpiry > refreshBeforeExpirySeconds) {
return false;
return;
}
return doRefresh(current, now);
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);
public void forceRefresh() {
repository.findCurrent().ifPresent(license -> doRefresh(license, Instant.now()));
}
private boolean doRefresh(License current, Instant now) {
/**
* Tente le refresh aupres du relais et persiste le resultat (succes ou echec) sur
* la licence. Le succes/echec est lu depuis {@code lastRefreshSucceeded}, pas
* depuis un retour de methode : le tentative est deja actee cote persistance.
*/
private void doRefresh(License current, Instant now) {
log.info("Refreshing Patreon license (current expires {})", current.getExpiresAt());
try {
String newJwt = relay.refreshToken(current.getRawJwt());
@@ -199,7 +200,6 @@ public class LicenseService {
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);
@@ -209,13 +209,11 @@ public class LicenseService {
} 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;
}
}

View File

@@ -1,6 +1,7 @@
package com.loremind.application.lorecontext;
import com.loremind.domain.lorecontext.LoreNode;
import com.loremind.domain.shared.ReorderSupport;
import com.loremind.domain.lorecontext.Page;
import com.loremind.domain.lorecontext.ports.LoreNodeRepository;
import com.loremind.domain.lorecontext.ports.PageRepository;
@@ -45,10 +46,20 @@ public class LoreNodeService {
.icon(changes.getIcon())
.parentId(changes.getParentId())
.loreId(changes.getLoreId())
.order(nextOrderFor(changes.getLoreId(), changes.getParentId()))
.build();
return loreNodeRepository.save(loreNode);
}
/** Prochain ordre pour un dossier neuf : après ses frères (même lore + même parent). */
private int nextOrderFor(String loreId, String parentId) {
if (loreId == null) return 0;
return loreNodeRepository.findByLoreId(loreId).stream()
.filter(n -> java.util.Objects.equals(n.getParentId(), parentId))
.mapToInt(LoreNode::getOrder)
.max().orElse(-1) + 1;
}
public Optional<LoreNode> getLoreNodeById(String id) {
return loreNodeRepository.findById(id);
}
@@ -57,6 +68,37 @@ public class LoreNodeService {
return loreNodeRepository.findAll();
}
/**
* Réordonne (et déplace) des dossiers frères : {@code order} = position, et
* {@code parentId} = dossier parent cible (null = racine). Évite les cycles : un
* dossier n'est pas réaffecté sous lui-même ni sous un de ses descendants.
*/
@Transactional
public void reorderNodes(String parentId, List<String> orderedIds) {
String parent = (parentId == null || parentId.isBlank()) ? null : parentId;
ReorderSupport.reorder(orderedIds,
loreNodeRepository::findById,
(node, i) -> {
if (parent == null || !isSelfOrDescendant(parent, node.getId())) {
node.setParentId(parent);
}
node.setOrder(i);
},
loreNodeRepository::save);
}
/** True si {@code candidateId} est {@code nodeId} ou un de ses descendants (anti-cycle). */
private boolean isSelfOrDescendant(String candidateId, String nodeId) {
String cur = candidateId;
int guard = 0;
while (cur != null && guard++ < 100) {
if (cur.equals(nodeId)) return true;
LoreNode n = loreNodeRepository.findById(cur).orElse(null);
cur = n != null ? n.getParentId() : null;
}
return false;
}
public List<LoreNode> getLoreNodesByLoreId(String loreId) {
return loreNodeRepository.findByLoreId(loreId);
}

View File

@@ -15,7 +15,6 @@ import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
/**
* Service d'application pour le contexte Lore.
@@ -72,7 +71,7 @@ public class LoreService {
public List<Lore> getAllLores() {
return loreRepository.findAll().stream()
.map(this::withCounts)
.collect(Collectors.toList());
.toList();
}
/**

View File

@@ -4,7 +4,7 @@ import com.loremind.domain.lorecontext.Page;
import com.loremind.domain.lorecontext.ports.PageRepository;
import org.springframework.stereotype.Service;
import com.loremind.domain.shared.CollectionUtils;
import com.loremind.domain.shared.ReorderSupport;
import java.util.ArrayList;
import java.util.HashMap;
@@ -33,6 +33,7 @@ public class PageService {
.nodeId(nodeId)
.templateId(templateId)
.title(title)
.order(nextOrderFor(nodeId))
.values(new HashMap<>())
.tags(new ArrayList<>())
.relatedPageIds(new ArrayList<>())
@@ -40,6 +41,14 @@ public class PageService {
return pageRepository.save(page);
}
/** Prochain ordre pour une page neuve : après les pages existantes du même dossier. */
private int nextOrderFor(String nodeId) {
if (nodeId == null) return 0;
return pageRepository.findByNodeId(nodeId).stream()
.mapToInt(Page::getOrder)
.max().orElse(-1) + 1;
}
public Optional<Page> getPageById(String id) {
return pageRepository.findById(id);
}
@@ -74,17 +83,26 @@ public class PageService {
existing.setTitle(changes.getTitle());
existing.setNodeId(changes.getNodeId());
existing.setValues(CollectionUtils.copyMap(changes.getValues()));
existing.setImageValues(CollectionUtils.copyMap(changes.getImageValues()));
existing.setKeyValueValues(CollectionUtils.copyMap(changes.getKeyValueValues()));
existing.setTableValues(CollectionUtils.copyMap(changes.getTableValues()));
existing.setNotes(changes.getNotes());
existing.setTags(CollectionUtils.copyList(changes.getTags()));
existing.setRelatedPageIds(CollectionUtils.copyList(changes.getRelatedPageIds()));
existing.applyEditableContent(changes);
return pageRepository.save(existing);
}
public void deletePage(String id) {
pageRepository.deleteById(id);
}
/**
* Réordonne (et déplace) les pages d'un dossier : {@code order} = position dans la
* liste, et {@code nodeId} = dossier cible (déplacement par glisser-déposer).
*/
@org.springframework.transaction.annotation.Transactional
public void reorderPages(String nodeId, List<String> orderedIds) {
ReorderSupport.reorder(orderedIds,
pageRepository::findById,
(page, i) -> {
if (nodeId != null && !nodeId.isBlank()) page.setNodeId(nodeId);
page.setOrder(i);
},
pageRepository::save);
}
}

View File

@@ -0,0 +1,141 @@
package com.loremind.application.playcontext;
import com.loremind.domain.playcontext.Clock;
import com.loremind.domain.playcontext.ClockTrigger;
import com.loremind.domain.playcontext.ports.ClockRepository;
import com.loremind.domain.playcontext.ports.PlaythroughRepository;
import org.springframework.stereotype.Service;
import java.util.List;
/**
* Service applicatif des Horloges de progression (Clocks) d'une Partie.
* CRUD + <b>avancer / reculer</b> d'un segment, borné à [0, segments].
*/
@Service
public class ClockService {
/** Garde-fou : taille maximale d'une horloge. */
private static final int MAX_SEGMENTS = 60;
private final ClockRepository clockRepository;
private final PlaythroughRepository playthroughRepository;
public ClockService(ClockRepository clockRepository, PlaythroughRepository playthroughRepository) {
this.clockRepository = clockRepository;
this.playthroughRepository = playthroughRepository;
}
public List<Clock> getByPlaythrough(String playthroughId) {
return clockRepository.findByPlaythroughId(playthroughId);
}
public Clock create(String playthroughId, String name, String description, int segments,
ClockTrigger triggerType, String triggerRef, String frontId) {
if (playthroughId == null || !playthroughRepository.existsById(playthroughId)) {
throw new IllegalArgumentException("Partie introuvable : " + playthroughId);
}
if (name == null || name.isBlank()) {
throw new IllegalArgumentException("Le nom de l'horloge est requis.");
}
int order = clockRepository.findByPlaythroughId(playthroughId).size();
ClockTrigger type = triggerType != null ? triggerType : ClockTrigger.NONE;
Clock clock = Clock.builder()
.playthroughId(playthroughId)
.name(name.trim())
.description(description)
.segments(clampSegments(segments))
.filled(0)
.order(order)
.triggerType(type)
.triggerRef(normalizeRef(type, triggerRef))
.frontId(blankToNull(frontId))
.build();
return clockRepository.save(clock);
}
public Clock update(String id, String name, String description, int segments,
ClockTrigger triggerType, String triggerRef, String frontId) {
Clock clock = require(id);
if (name != null && !name.isBlank()) clock.setName(name.trim());
clock.setDescription(description);
int seg = clampSegments(segments);
clock.setSegments(seg);
if (clock.getFilled() > seg) clock.setFilled(seg); // ré-borne si on réduit la taille
ClockTrigger type = triggerType != null ? triggerType : ClockTrigger.NONE;
clock.setTriggerType(type);
clock.setTriggerRef(normalizeRef(type, triggerRef));
clock.setFrontId(blankToNull(frontId));
return clockRepository.save(clock);
}
// ----- Avancement automatique (co-MJ) : le monde qui réagit -----
/** Un Fait vient de passer à vrai → avance les horloges liées (FLAG_SET + ce fait). */
public void onFlagRaised(String playthroughId, String flagName) {
advanceMatching(playthroughId, ClockTrigger.FLAG_SET, flagName);
}
/** Une quête vient de passer à COMPLETED → avance les horloges liées à cette quête. */
public void onQuestCompleted(String playthroughId, String questId) {
advanceMatching(playthroughId, ClockTrigger.QUEST_COMPLETED, questId);
}
/** Une séance vient de se clôturer → avance les horloges « fin de séance » de la Partie. */
public void onSessionEnded(String playthroughId) {
advanceMatching(playthroughId, ClockTrigger.SESSION_ENDED, null);
}
private void advanceMatching(String playthroughId, ClockTrigger type, String ref) {
if (playthroughId == null) return;
for (Clock c : clockRepository.findByPlaythroughId(playthroughId)) {
boolean match = c.getTriggerType() == type
&& (type == ClockTrigger.SESSION_ENDED || java.util.Objects.equals(c.getTriggerRef(), ref));
if (match && c.getFilled() < c.getSegments()) {
c.setFilled(c.getFilled() + 1);
clockRepository.save(c);
}
}
}
private String normalizeRef(ClockTrigger type, String ref) {
boolean needsRef = type == ClockTrigger.FLAG_SET || type == ClockTrigger.QUEST_COMPLETED;
if (!needsRef || ref == null || ref.isBlank()) return null;
return ref.trim();
}
private String blankToNull(String s) {
return (s == null || s.isBlank()) ? null : s.trim();
}
/** +1 segment (sans dépasser {@code segments}). */
public Clock advance(String id) {
Clock clock = require(id);
if (clock.getFilled() < clock.getSegments()) clock.setFilled(clock.getFilled() + 1);
return clockRepository.save(clock);
}
/** 1 segment (sans descendre sous 0). */
public Clock regress(String id) {
Clock clock = require(id);
if (clock.getFilled() > 0) clock.setFilled(clock.getFilled() - 1);
return clockRepository.save(clock);
}
public void delete(String id) {
if (!clockRepository.existsById(id)) {
throw new IllegalArgumentException("Horloge introuvable : " + id);
}
clockRepository.deleteById(id);
}
private Clock require(String id) {
return clockRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Horloge introuvable : " + id));
}
private int clampSegments(int segments) {
if (segments < 1) return 1;
return Math.min(segments, MAX_SEGMENTS);
}
}

View File

@@ -0,0 +1,75 @@
package com.loremind.application.playcontext;
import com.loremind.domain.playcontext.Clock;
import com.loremind.domain.playcontext.Front;
import com.loremind.domain.playcontext.ports.ClockRepository;
import com.loremind.domain.playcontext.ports.FrontRepository;
import com.loremind.domain.playcontext.ports.PlaythroughRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
/**
* Service applicatif des Fronts (menaces regroupant des horloges) d'une Partie.
*/
@Service
public class FrontService {
private final FrontRepository frontRepository;
private final ClockRepository clockRepository;
private final PlaythroughRepository playthroughRepository;
public FrontService(FrontRepository frontRepository, ClockRepository clockRepository,
PlaythroughRepository playthroughRepository) {
this.frontRepository = frontRepository;
this.clockRepository = clockRepository;
this.playthroughRepository = playthroughRepository;
}
public List<Front> getByPlaythrough(String playthroughId) {
return frontRepository.findByPlaythroughId(playthroughId);
}
public Front create(String playthroughId, String name, String description) {
if (playthroughId == null || !playthroughRepository.existsById(playthroughId)) {
throw new IllegalArgumentException("Partie introuvable : " + playthroughId);
}
if (name == null || name.isBlank()) {
throw new IllegalArgumentException("Le nom du front est requis.");
}
int order = frontRepository.findByPlaythroughId(playthroughId).size();
Front front = Front.builder()
.playthroughId(playthroughId)
.name(name.trim())
.description(description)
.order(order)
.build();
return frontRepository.save(front);
}
public Front update(String id, String name, String description) {
Front front = require(id);
if (name != null && !name.isBlank()) front.setName(name.trim());
front.setDescription(description);
return frontRepository.save(front);
}
/** Supprime un front et ORPHELINE ses horloges (frontId -> null : on ne perd pas d'horloges). */
@Transactional
public void delete(String id) {
Front front = require(id);
for (Clock c : clockRepository.findByPlaythroughId(front.getPlaythroughId())) {
if (id.equals(c.getFrontId())) {
c.setFrontId(null);
clockRepository.save(c);
}
}
frontRepository.deleteById(id);
}
private Front require(String id) {
return frontRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Front introuvable : " + id));
}
}

View File

@@ -7,6 +7,7 @@ import com.loremind.domain.playcontext.ports.SessionRepository;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.List;
import java.util.Optional;
@@ -38,7 +39,7 @@ public class SessionEntryService {
}
validateContent(data.content());
LocalDateTime now = LocalDateTime.now();
LocalDateTime now = LocalDateTime.now(ZoneId.systemDefault());
SessionEntry entry = SessionEntry.builder()
.sessionId(sessionId)
.type(data.type() != null ? data.type() : EntryType.NOTE)

View File

@@ -0,0 +1,52 @@
package com.loremind.application.playcontext;
import com.loremind.application.campaigncontext.ReadinessGap;
import java.time.LocalDateTime;
import java.util.List;
/**
* Bilan de PRÉPARATION DE SÉANCE d'une Partie (Phase 3 co-MJ — « PlanDeSéance »).
* Read-model pur, calculé à la volée : croise la position des joueurs (quêtes en cours /
* disponibles, dernière séance), le contenu probable (nœuds des quêtes actives), les
* manques de guidage CIBLÉS sur ce contenu, et les horloges en mouvement.
*
* @param playthroughId la Partie évaluée
* @param lastSession dernière séance (ou {@code null} si aucune)
* @param questsInProgress quêtes EN COURS (le « où en sont les joueurs »)
* @param questsAvailable quêtes DISPONIBLES (les prochaines pistes probables)
* @param questsCompleted quêtes TERMINÉES (rappel discret + possibilité de rouvrir)
* @param hotspots chapitres / scènes probables (nœuds des quêtes actives, dédupliqués)
* @param gaps manques de guidage restreints au contenu probable (à combler avant de jouer) ;
* si la campagne n'utilise pas de quêtes, tous les manques de la campagne
* @param otherGapCount manques ailleurs dans la campagne (information, non bloquant pour la séance)
* @param clocks horloges entamées ({@code filled > 0}), avec le nom de leur menace
*/
public record SessionPrepReport(
String playthroughId,
LastSessionInfo lastSession,
List<QuestInfo> questsInProgress,
List<QuestInfo> questsAvailable,
List<QuestInfo> questsCompleted,
List<NodeInfo> hotspots,
List<ReadinessGap> gaps,
int otherGapCount,
List<ClockInfo> clocks
) {
/** Dernière séance tenue (ou en cours) de la Partie. */
public record LastSessionInfo(String id, String name, LocalDateTime startedAt,
LocalDateTime endedAt, boolean active) {}
/** Quête résumée (statut implicite par la liste qui la porte). */
public record QuestInfo(String id, String name, String icon) {}
/**
* Nœud narratif probable. {@code nodeType} = "CHAPTER"|"SCENE" ; {@code arcId}/
* {@code chapterId} = contexte de navigation pour le lien profond côté front.
*/
public record NodeInfo(String nodeType, String id, String name, String arcId, String chapterId) {}
/** Horloge en mouvement (le front affiche « presque pleine » quand il reste ≤ 1 segment). */
public record ClockInfo(String id, String name, int segments, int filled, String frontName) {}
}

View File

@@ -0,0 +1,203 @@
package com.loremind.application.playcontext;
import com.loremind.application.campaigncontext.CampaignReadinessAssessment;
import com.loremind.application.campaigncontext.CampaignReadinessService;
import com.loremind.application.campaigncontext.QuestStatusEnricher;
import com.loremind.application.campaigncontext.ReadinessGap;
import com.loremind.domain.campaigncontext.structure.Chapter;
import com.loremind.domain.campaigncontext.quest.Quest;
import com.loremind.domain.campaigncontext.quest.QuestNodeRef;
import com.loremind.domain.campaigncontext.quest.QuestStatus;
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
import com.loremind.domain.campaigncontext.ports.QuestRepository;
import com.loremind.domain.campaigncontext.ports.SceneRepository;
import com.loremind.domain.playcontext.Front;
import com.loremind.domain.playcontext.Playthrough;
import com.loremind.domain.playcontext.Session;
import com.loremind.domain.playcontext.ports.ClockRepository;
import com.loremind.domain.playcontext.ports.FrontRepository;
import com.loremind.domain.playcontext.ports.PlaythroughRepository;
import com.loremind.domain.playcontext.ports.SessionRepository;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
/**
* Read-model « Préparer la prochaine séance » (Phase 3 co-MJ). Croise, pour UNE Partie :
*
* <ol>
* <li><b>Position des joueurs</b> — quêtes EN COURS / DISPONIBLES (statut effectif via
* {@link QuestStatusEnricher}, un seul snapshot) + dernière séance ;</li>
* <li><b>Contenu probable</b> — les chapitres/scènes traversés par ces quêtes actives ;</li>
* <li><b>Manques ciblés</b> — les gaps du guidage ({@link CampaignReadinessService})
* restreints à ce contenu probable : « quoi combler AVANT la prochaine séance » ;</li>
* <li><b>Menaces en mouvement</b> — horloges entamées, avec leur front.</li>
* </ol>
*
* <p>Déterministe, sans IA, zéro persistance. Si la campagne n'utilise pas de quêtes, la
* notion de « contenu probable » n'existe pas : on renvoie alors TOUS les manques (toute
* la campagne est potentiellement la prochaine séance).</p>
*/
@Service
public class SessionPrepService {
private final PlaythroughRepository playthroughRepository;
private final SessionRepository sessionRepository;
private final ClockRepository clockRepository;
private final FrontRepository frontRepository;
private final QuestRepository questRepository;
private final ChapterRepository chapterRepository;
private final SceneRepository sceneRepository;
private final QuestStatusEnricher statusEnricher;
private final CampaignReadinessService readinessService;
public SessionPrepService(PlaythroughRepository playthroughRepository,
SessionRepository sessionRepository,
ClockRepository clockRepository,
FrontRepository frontRepository,
QuestRepository questRepository,
ChapterRepository chapterRepository,
SceneRepository sceneRepository,
QuestStatusEnricher statusEnricher,
CampaignReadinessService readinessService) {
this.playthroughRepository = playthroughRepository;
this.sessionRepository = sessionRepository;
this.clockRepository = clockRepository;
this.frontRepository = frontRepository;
this.questRepository = questRepository;
this.chapterRepository = chapterRepository;
this.sceneRepository = sceneRepository;
this.statusEnricher = statusEnricher;
this.readinessService = readinessService;
}
public SessionPrepReport prepare(String playthroughId) {
Playthrough playthrough = playthroughRepository.findById(playthroughId)
.orElseThrow(() -> new IllegalArgumentException("Partie non trouvée: " + playthroughId));
String campaignId = playthrough.getCampaignId();
// 1) Position : quêtes actives + dernière séance.
List<Quest> quests = questRepository.findByCampaignId(campaignId);
Map<String, QuestStatus> statusById = statusEnricher.computeAll(quests, playthroughId);
List<Quest> inProgress = byStatus(quests, statusById, QuestStatus.IN_PROGRESS);
List<Quest> available = byStatus(quests, statusById, QuestStatus.AVAILABLE);
List<Quest> completed = byStatus(quests, statusById, QuestStatus.COMPLETED);
// 2) Contenu probable : nœuds des quêtes actives (en cours d'abord), dédupliqués.
LinkedHashSet<String> hotspotChapterIds = new LinkedHashSet<>();
LinkedHashSet<String> hotspotSceneIds = new LinkedHashSet<>();
List<SessionPrepReport.NodeInfo> hotspots = new ArrayList<>();
for (Quest quest : concat(inProgress, available)) {
for (QuestNodeRef node : nullSafe(quest.getNodes())) {
resolveNode(node, hotspotChapterIds, hotspotSceneIds, hotspots);
}
}
// 3) Manques ciblés sur ce contenu probable. Sans quête, tout est « probable ».
Set<String> hotspotQuestIds = concat(inProgress, available).stream()
.map(Quest::getId).collect(Collectors.toSet());
CampaignReadinessAssessment assessment = readinessService.assess(campaignId);
List<ReadinessGap> focused;
if (quests.isEmpty()) {
focused = assessment.gaps();
} else {
focused = assessment.gaps().stream()
.filter(g -> isFocused(g, hotspotChapterIds, hotspotSceneIds, hotspotQuestIds))
.toList();
}
int otherGapCount = assessment.gaps().size() - focused.size();
// 4) Menaces en mouvement : horloges entamées, avec le nom de leur front.
Map<String, String> frontNames = frontRepository.findByPlaythroughId(playthroughId).stream()
.collect(Collectors.toMap(Front::getId, Front::getName, (a, b) -> a));
List<SessionPrepReport.ClockInfo> clocks = clockRepository.findByPlaythroughId(playthroughId).stream()
.filter(c -> c.getFilled() > 0)
.map(c -> new SessionPrepReport.ClockInfo(c.getId(), c.getName(), c.getSegments(),
c.getFilled(), c.getFrontId() != null ? frontNames.get(c.getFrontId()) : null))
.toList();
return new SessionPrepReport(
playthroughId,
lastSessionOf(playthroughId),
toQuestInfos(inProgress),
toQuestInfos(available),
toQuestInfos(completed),
hotspots,
focused,
otherGapCount,
clocks);
}
/** Résout un nœud de quête vers son info navigable ; les refs mortes sont ignorées. */
private void resolveNode(QuestNodeRef node,
Set<String> chapterIds, Set<String> sceneIds,
List<SessionPrepReport.NodeInfo> out) {
if (node == null || node.nodeId() == null || node.nodeType() == null) return;
switch (node.nodeType()) {
case CHAPTER -> chapterRepository.findById(node.nodeId()).ifPresent(ch -> {
if (chapterIds.add(ch.getId())) {
out.add(new SessionPrepReport.NodeInfo("CHAPTER", ch.getId(), ch.getName(), ch.getArcId(), ch.getId()));
}
});
case SCENE -> sceneRepository.findById(node.nodeId()).ifPresent(sc -> {
if (sceneIds.add(sc.getId())) {
String arcId = chapterRepository.findById(sc.getChapterId())
.map(Chapter::getArcId).orElse(null);
out.add(new SessionPrepReport.NodeInfo("SCENE", sc.getId(), sc.getName(), arcId, sc.getChapterId()));
}
});
}
}
/** Un gap est « ciblé » s'il touche une entité probable (ou une scène d'un chapitre probable). */
private boolean isFocused(ReadinessGap gap,
Set<String> chapterIds, Set<String> sceneIds, Set<String> questIds) {
return switch (gap.entityType()) {
case SCENE -> sceneIds.contains(gap.entityId())
|| (gap.chapterId() != null && chapterIds.contains(gap.chapterId()));
case CHAPTER -> chapterIds.contains(gap.entityId());
case QUEST -> questIds.contains(gap.entityId());
default -> false;
};
}
private SessionPrepReport.LastSessionInfo lastSessionOf(String playthroughId) {
List<Session> sessions = sessionRepository.findByPlaythroughId(playthroughId);
return sessions.stream()
.max(Comparator.comparing(Session::getStartedAt,
Comparator.nullsFirst(Comparator.naturalOrder())))
.map(s -> new SessionPrepReport.LastSessionInfo(
s.getId(), s.getName(), s.getStartedAt(), s.getEndedAt(), s.isActive()))
.orElse(null);
}
private static List<Quest> byStatus(List<Quest> quests, Map<String, QuestStatus> statusById, QuestStatus wanted) {
return quests.stream()
.filter(q -> statusById.get(q.getId()) == wanted)
.sorted(Comparator.comparingInt(Quest::getOrder))
.toList();
}
private static List<SessionPrepReport.QuestInfo> toQuestInfos(List<Quest> quests) {
return quests.stream()
.map(q -> new SessionPrepReport.QuestInfo(q.getId(), q.getName(), q.getIcon()))
.toList();
}
private static List<Quest> concat(List<Quest> a, List<Quest> b) {
List<Quest> out = new ArrayList<>(a);
out.addAll(b);
return out;
}
private static <T> List<T> nullSafe(List<T> list) {
return list != null ? list : List.of();
}
}

View File

@@ -0,0 +1,85 @@
package com.loremind.application.playcontext;
import com.loremind.domain.playcontext.Session;
import com.loremind.domain.playcontext.SessionEntry;
import com.loremind.domain.playcontext.ports.SessionEntryRepository;
import com.loremind.domain.playcontext.ports.SessionRecapAssistant;
import com.loremind.domain.playcontext.ports.SessionRepository;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.Comparator;
import java.util.List;
import java.util.Optional;
/**
* Génère le récap « précédemment dans… » à lire à l'OUVERTURE d'une séance : résume le
* journal de la SÉANCE PRÉCÉDENTE (même Partie, la plus récente commencée avant celle-ci ;
* ou la dernière terminée si la courante est la plus récente). Zéro persistance : le MJ
* lit le récap, et peut choisir côté front de le consigner au journal.
*/
@Service
public class SessionRecapService {
/** Plafond du transcript envoyé au LLM (garde le budget de contexte local ~16k). */
private static final int MAX_TRANSCRIPT_CHARS = 12_000;
private final SessionRepository sessionRepository;
private final SessionEntryRepository entryRepository;
private final SessionRecapAssistant recapAssistant;
public SessionRecapService(SessionRepository sessionRepository,
SessionEntryRepository entryRepository,
SessionRecapAssistant recapAssistant) {
this.sessionRepository = sessionRepository;
this.entryRepository = entryRepository;
this.recapAssistant = recapAssistant;
}
/** Récap généré + nom de la séance résumée (pour l'afficher au MJ). */
public record RecapResult(String previousSessionName, String recap) {}
public RecapResult recapPreviousSession(String sessionId) {
Session current = sessionRepository.findById(sessionId)
.orElseThrow(() -> new IllegalArgumentException("Session introuvable : " + sessionId));
Session previous = findPreviousSession(current)
.orElseThrow(() -> new IllegalArgumentException(
"Aucune séance précédente à résumer pour cette Partie."));
List<SessionEntry> entries = entryRepository.findBySessionId(previous.getId());
if (entries.isEmpty()) {
throw new IllegalArgumentException(
"La séance précédente (« " + previous.getName() + " ») n'a aucune entrée de journal à résumer.");
}
String transcript = buildTranscript(entries);
String context = "Séance : " + previous.getName();
return new RecapResult(previous.getName(), recapAssistant.generateRecap(transcript, context));
}
/** Séance la plus récente de la même Partie commencée AVANT la courante. */
private Optional<Session> findPreviousSession(Session current) {
LocalDateTime pivot = current.getStartedAt();
return sessionRepository.findByPlaythroughId(current.getPlaythroughId()).stream()
.filter(s -> !s.getId().equals(current.getId()))
.filter(s -> s.getStartedAt() != null
&& (pivot == null || s.getStartedAt().isBefore(pivot)))
.max(Comparator.comparing(Session::getStartedAt));
}
/** Journal chronologique, une ligne par entrée, plafonné (on garde la FIN — le dénouement). */
private static String buildTranscript(List<SessionEntry> entries) {
StringBuilder sb = new StringBuilder();
for (SessionEntry e : entries) {
if (e.getContent() == null || e.getContent().isBlank()) continue;
sb.append("[").append(e.getType() != null ? e.getType().name() : "NOTE").append("] ")
.append(e.getContent().trim()).append("\n");
}
String transcript = sb.toString();
if (transcript.length() > MAX_TRANSCRIPT_CHARS) {
transcript = "\n" + transcript.substring(transcript.length() - MAX_TRANSCRIPT_CHARS);
}
return transcript;
}
}

View File

@@ -8,6 +8,7 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Optional;
@@ -23,17 +24,21 @@ import java.util.Optional;
public class SessionService {
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd");
private static final String SESSION_NOT_FOUND = "Session introuvable : ";
private final SessionRepository sessionRepository;
private final SessionEntryRepository entryRepository;
private final PlaythroughRepository playthroughRepository;
private final ClockService clockService;
public SessionService(SessionRepository sessionRepository,
SessionEntryRepository entryRepository,
PlaythroughRepository playthroughRepository) {
PlaythroughRepository playthroughRepository,
ClockService clockService) {
this.sessionRepository = sessionRepository;
this.entryRepository = entryRepository;
this.playthroughRepository = playthroughRepository;
this.clockService = clockService;
}
/**
@@ -54,7 +59,7 @@ public class SessionService {
"). Termine-la avant d'en lancer une nouvelle.");
});
LocalDateTime now = LocalDateTime.now();
LocalDateTime now = LocalDateTime.now(ZoneId.systemDefault());
Session session = Session.builder()
.name(generateDefaultName(now))
.playthroughId(playthroughId)
@@ -65,11 +70,26 @@ public class SessionService {
public Session endSession(String id) {
Session session = sessionRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Session introuvable : " + id));
.orElseThrow(() -> new IllegalArgumentException(SESSION_NOT_FOUND + id));
if (!session.isActive()) {
throw new IllegalStateException("Cette session est déjà terminée.");
}
session.setEndedAt(LocalDateTime.now());
session.setEndedAt(LocalDateTime.now(ZoneId.systemDefault()));
Session saved = sessionRepository.save(session);
// Co-MJ : la séance se clôture -> avancer les horloges « fin de séance » de la Partie.
clockService.onSessionEnded(saved.getPlaythroughId());
return saved;
}
/**
* Épingle (ou dés-épingle avec {@code null}) la scène courante de la session — mode
* cockpit : « on en est là ». Weak ref : on ne valide pas l'existence de la scène
* (l'UI ne propose que des scènes réelles ; une scène supprimée rend l'épingle caduque).
*/
public Session setCurrentScene(String id, String sceneId) {
Session session = sessionRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException(SESSION_NOT_FOUND + id));
session.setCurrentSceneId(sceneId != null && !sceneId.isBlank() ? sceneId : null);
return sessionRepository.save(session);
}
@@ -78,7 +98,7 @@ public class SessionService {
throw new IllegalArgumentException("Le nom de la session ne peut pas être vide.");
}
Session session = sessionRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Session introuvable : " + id));
.orElseThrow(() -> new IllegalArgumentException(SESSION_NOT_FOUND + id));
session.setName(newName.trim());
return sessionRepository.save(session);
}
@@ -106,7 +126,7 @@ public class SessionService {
@Transactional
public void deleteSession(String id) {
if (!sessionRepository.existsById(id)) {
throw new IllegalArgumentException("Session introuvable : " + id);
throw new IllegalArgumentException(SESSION_NOT_FOUND + id);
}
entryRepository.deleteBySessionId(id);
sessionRepository.deleteById(id);

View File

@@ -1,14 +0,0 @@
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

@@ -24,6 +24,9 @@ public class Campaign {
private LocalDateTime updatedAt;
private int arcsCount;
/** Nombre de joueurs attendus à la table (métadonnée saisie à la création). */
private int playerCount;
/**
* Référence faible vers un Lore. Nullable.
* Ce n'est qu'un ID : le Campaign Context ne dépend PAS du Lore Context.
@@ -35,6 +38,12 @@ public class Campaign {
*/
private String gameSystemId;
/**
* Positions des nœuds du graphe de campagne (JSON opaque, état de
* présentation possédé par le front). Null = disposition automatique.
*/
private String graphPositions;
public boolean isLinkedToLore() {
return this.loreId != null && !this.loreId.isBlank();
}

View File

@@ -1,4 +1,4 @@
package com.loremind.domain.campaigncontext;
package com.loremind.domain.campaigncontext.bestiary;
import lombok.Builder;
import lombok.Data;

View File

@@ -1,4 +1,4 @@
package com.loremind.domain.campaigncontext;
package com.loremind.domain.campaigncontext.bestiary;
import lombok.Builder;
import lombok.Data;
@@ -47,6 +47,22 @@ public class Enemy {
/** Référence vers la Campaign parente (cross-aggregate via ID). */
private String campaignId;
/**
* Référence vers l'acteur Foundry d'origine (UUID de compendium, ex.
* {@code Compendium.nimble.monsters.Actor.abc123}). Renseigné quand l'ennemi
* a été importé depuis un compendium Foundry ; permet, à l'export, de poser
* un token du VRAI acteur (stats natives). Null pour un ennemi fait main.
*/
private String foundryRef;
/**
* Instantané (figé) des stats de l'acteur Foundry d'origine, aplati en
* cle->valeur, pour AFFICHAGE en prep dans LoreMind. Dependant du systeme,
* non synchronise (les stats vivantes restent cote Foundry). Jamais null apres
* construction.
*/
private Map<String, String> foundryStats;
/** Ordre d'affichage dans la liste. */
private int order;
@@ -67,4 +83,9 @@ public class Enemy {
if (keyValueValues == null) keyValueValues = new HashMap<>();
return keyValueValues;
}
public Map<String, String> getFoundryStats() {
if (foundryStats == null) foundryStats = new HashMap<>();
return foundryStats;
}
}

View File

@@ -1,4 +1,4 @@
package com.loremind.domain.campaigncontext;
package com.loremind.domain.campaigncontext.bestiary;
import lombok.Builder;
import lombok.Data;

View File

@@ -1,4 +1,4 @@
package com.loremind.domain.campaigncontext;
package com.loremind.domain.campaigncontext.generation;
/**
* Évènement d'avancement émis pendant l'import streamé d'un PDF de campagne.

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