Compare commits

..

80 Commits

Author SHA1 Message Date
5aea0a5af2 passage version v0.11.3-beta
All checks were successful
Build & Push Images / build (brain) (push) Successful in 1m23s
Build & Push Images / build (core) (push) Successful in 1m44s
Build & Push Images / build-switcher (push) Successful in 18s
Build & Push Images / build (web) (push) Successful in 1m41s
2026-06-08 17:01:05 +02:00
da2a66d06a Amélioration de l'IA pour la partie atelier PDF
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Build & Push Images / build-switcher (push) Has been cancelled
Mise en place d'un outil permettant de faire des tableau d'objets pour des boutiques par exemple
2026-06-08 17:00:22 +02:00
b968f12a0c Modification du timeout pour permettre d'analyser de plus gros livres
All checks were successful
Build & Push Images / build (brain) (push) Successful in 1m28s
Build & Push Images / build (core) (push) Successful in 1m48s
Build & Push Images / build-switcher (push) Successful in 16s
Build & Push Images / build (web) (push) Successful in 1m41s
2026-06-08 14:49:00 +02:00
ba5f9c7a2e Mise à jour d'un test unitaire qui ne passait plus avec la mise à jour précédente
All checks were successful
Build & Push Images / build (core) (push) Successful in 2m9s
Build & Push Images / build (brain) (push) Successful in 2m25s
Build & Push Images / build-switcher (push) Successful in 17s
Build & Push Images / build (web) (push) Successful in 1m44s
2026-06-08 11:11:50 +02:00
55a202caa8 passage version 0.11.1-beta
Some checks failed
Build & Push Images / build (core) (push) Failing after 1m32s
Build & Push Images / build (brain) (push) Successful in 2m5s
Build & Push Images / build-switcher (push) Successful in 18s
Build & Push Images / build (web) (push) Successful in 2m9s
2026-06-08 11:05:48 +02:00
0bbcf11b5f Résolution d'un bug de switch entre les PNJ lorsqu'on essai de passer directement de l'un à l'autre
Ajout de dossiers pour la partie PNJ pour qu'on puisse les regrouper par cité par exemple et que ce soit plus lisible
2026-06-08 11:05:07 +02:00
75357983a1 Plusieurs gros ajouts :
All checks were successful
Build & Push Images / build (brain) (push) Successful in 1m29s
Build & Push Images / build (core) (push) Successful in 1m51s
Build & Push Images / build-switcher (push) Successful in 16s
Build & Push Images / build (web) (push) Successful in 1m43s
- Possibilité de discuter avec un PDF ; RAG ou analyse approfondie. Enlèvement de l'autre outil PDF de discussion qui analysait d'abord un PDF en proposant directement une intégration sans attendre qu'on pose de question
- Mise en place de l'import directement dans les outils dans la sidebar
- Mise en place d'un outil pour créer des tables aléatoires avec possibilité d'utiliser pendant la partie
- Mise en place d'un outil pour mettre en place des PNJ, scènes, chapitre.... directement à partir de la discussion avec le PDF
- Mise en place RAG avec mistal-embeding ou nomic si on utilise ollama
- Mise en place mistral, google en fournisseurs alternatifs pour l'IA dans le cloud
- version 0.11.0-bêta
2026-06-07 09:52:15 +02:00
76977eda0e passage 0.10.4
All checks were successful
Build & Push Images / build (brain) (push) Successful in 1m33s
Build & Push Images / build (core) (push) Successful in 1m39s
Build & Push Images / build-switcher (push) Successful in 20s
Build & Push Images / build (web) (push) Successful in 1m39s
2026-06-05 16:54:41 +02:00
33d8040098 Meilleure gestion des erreurs, moins permissif au niveau de la créativité du modèle. 2026-06-05 16:54:28 +02:00
0bd4a2d10f Ajout d'open router en fournisseur IA ; ajout de la possibilité de mettre des conditions de déverouillage pour les chapitres optionnels
All checks were successful
Build & Push Images / build (brain) (push) Successful in 1m22s
Build & Push Images / build (core) (push) Successful in 1m42s
Build & Push Images / build-switcher (push) Successful in 22s
Build & Push Images / build (web) (push) Successful in 1m43s
2026-06-05 00:23:19 +02:00
bf91b75b5f changement de version
All checks were successful
Build & Push Images / build (brain) (push) Successful in 1m29s
Build & Push Images / build (core) (push) Successful in 1m49s
Build & Push Images / build-switcher (push) Successful in 15s
Build & Push Images / build (web) (push) Successful in 1m45s
2026-06-04 14:14:19 +02:00
2fffad1b6b Correction de la configuration nginx pour ne pas bloquer un gros PDF 2026-06-04 14:13:41 +02:00
439f43875b Ajout de 2 fonctionnalitées principales : import PDF que ce soit pour les règles ou les campagnes directement.
All checks were successful
Build & Push Images / build (brain) (push) Successful in 1m28s
Build & Push Images / build (core) (push) Successful in 1m40s
Build & Push Images / build-switcher (push) Successful in 19s
Build & Push Images / build (web) (push) Successful in 1m46s
Fonctionnalité de comparaison PDF / campagne pour faire un mix et demander des conseils à l'IA
2026-06-04 13:55:27 +02:00
79a68bc27b Mise à jour des tests unitaires et passage en v0.9.2-beta en conséquence.
All checks were successful
Build & Push Images / build (brain) (push) Successful in 1m36s
Build & Push Images / build (core) (push) Successful in 1m58s
Build & Push Images / build-switcher (push) Successful in 15s
Build & Push Images / build (web) (push) Successful in 3m1s
2026-06-03 15:56:26 +02:00
c3d3394ec7 Plusieurs ajouts :
Some checks failed
Build & Push Images / build (brain) (push) Successful in 1m47s
Build & Push Images / build (core) (push) Failing after 1m56s
Build & Push Images / build-switcher (push) Successful in 20s
Build & Push Images / build (web) (push) Successful in 2m11s
- Possibilité de configurer des lieux dans une scène : permet de configurer un donjon par exemple avec les pièces, les trésors par pièce, la narration..... Mise en place également de conditions permettant de conditionner le déblocage des quêtes.
- Possibilité de transformé un arc en instance non linéaire afin de faire un hub. Permet de jouer de préparer des campagnes type Dragon of Icespire peak plus facilement.
- Configuration de partie : chaque partie va contenir les séances, ce qui permettra de suivre le déblocage des conditions pour les quêtes.

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

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

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

Passage en bêta 0.8.4
2026-05-19 13:37:22 +02:00
7c74c12f3e Changement du readme
Some checks failed
E2E Tests / e2e (push) Failing after 58s
2026-05-17 18:04:24 +02:00
86836ad81c Refonte de toute la partie fiche de personnage avec mise en place d'un nouveau bloc de liste d'attribut (pour tout ce qui sera statistiques, compétences etc....)
Some checks failed
E2E Tests / e2e (push) Has been cancelled
Build & Push Images / build (brain) (push) Successful in 1m5s
Build & Push Images / build (core) (push) Successful in 1m38s
Build & Push Images / build (web) (push) Successful in 1m36s
Passage V0.8.3
2026-04-30 15:53:38 +02:00
7c4a42327d Mise en place du picker d'image pour la partie header / illustration des fiches personnage
Some checks failed
E2E Tests / e2e (push) Has been cancelled
Build & Push Images / build (brain) (push) Successful in 1m1s
Build & Push Images / build (core) (push) Successful in 1m32s
Build & Push Images / build (web) (push) Successful in 1m42s
Migration pour l'ancienne partie des fiches perso vers les nouvelles pages
Vue retravaillée pour les fiches perso
2026-04-30 10:54:27 +02:00
52e389db24 Refonte du système JDR + système de personnage joueurs / non joueurs :
Some checks failed
E2E Tests / e2e (push) Failing after 21s
- Système de templating dans le game system : en effet, les templates sont liés au game system car les fiches personnages ne sont pas forcément les même selon les jeux (perso Dnd possède + de compétences que Nimble par exemple)
- changement des fiches personnages pour adapter le templating au niveau des campagnes et remplir des pages de perso
2026-04-30 10:42:09 +02:00
efaf5a3794 Mise en place d'un composant permettant d'améliorer l'experience de mise à jour (via un rafraichissement de l'appli).
Some checks failed
E2E Tests / e2e (push) Has been cancelled
Build & Push Images / build (brain) (push) Successful in 1m0s
Build & Push Images / build (core) (push) Successful in 1m47s
Build & Push Images / build (web) (push) Successful in 1m38s
Modification de la partie web pour prendre la modification en compte
2026-04-29 14:39:30 +02:00
4fe93b5ff3 Correction problème mise à jour : l'application ne voyait pas les mises à jour quand on lançait docker après avoir push la dernière version.
Some checks failed
E2E Tests / e2e (push) Failing after 21s
Build & Push Images / build (brain) (push) Successful in 1m4s
Build & Push Images / build (core) (push) Successful in 1m31s
Build & Push Images / build (web) (push) Successful in 1m38s
Effectivement : au demarrage, docker ce mettait automatiquement sur la dernière version alors qu'il n'avait pas necessairement récupérer, ducoup comparaison faisait true et on arrivait pas à avoir la derniere version du code.
Push de la clé jwt publique : sinon pas incluse dans le jar finale et la section patreon n'apparaissait pas.
2026-04-29 10:56:37 +02:00
0f2d1b1efe Correction updateCheckServiceTest qui faisait planter le build gitea
Some checks failed
E2E Tests / e2e (push) Failing after 19s
Build & Push Images / build (core) (push) Successful in 1m27s
Build & Push Images / build (web) (push) Successful in 1m34s
Build & Push Images / build (brain) (push) Successful in 53s
2026-04-28 19:12:09 +02:00
5ff05242a8 Mise en place de la connexion au canal privé pour la bêta avec Patreon et passage en v0.8.0
Some checks failed
E2E Tests / e2e (push) Failing after 16s
Build & Push Images / build (brain) (push) Failing after 48s
Build & Push Images / build (core) (push) Failing after 1m18s
Build & Push Images / build (web) (push) Successful in 1m35s
2026-04-28 19:04:11 +02:00
b06c77a1eb Autre patch dockerfile
Some checks failed
E2E Tests / e2e (push) Failing after 20s
Build & Push Images / build (brain) (push) Successful in 58s
Build & Push Images / build (core) (push) Successful in 1m32s
Build & Push Images / build (web) (push) Successful in 1m30s
2026-04-27 22:11:43 +02:00
03bc669efe Patch dockerfile bookworm a lieu de alpine pour corriger le problème de build
Some checks failed
E2E Tests / e2e (push) Failing after 19s
Build & Push Images / build (brain) (push) Successful in 58s
Build & Push Images / build (core) (push) Successful in 1m39s
Build & Push Images / build (web) (push) Failing after 1m48s
2026-04-27 21:56:04 +02:00
c3873ddd84 Patch dockerfile pour ne plus que le build plante
Some checks failed
E2E Tests / e2e (push) Failing after 16s
Build & Push Images / build (brain) (push) Successful in 1m8s
Build & Push Images / build (core) (push) Successful in 1m32s
Build & Push Images / build (web) (push) Failing after 1m42s
2026-04-27 21:43:13 +02:00
d7ceeac1b0 Correction package-lock
Some checks failed
E2E Tests / e2e (push) Failing after 20s
Build & Push Images / build (brain) (push) Successful in 57s
Build & Push Images / build (core) (push) Successful in 1m34s
Build & Push Images / build (web) (push) Failing after 1m41s
2026-04-27 19:17:01 +02:00
cdbd3cd9b4 Modification lors de la création d'élément de campagne : quand on créer un nouvel élément, on arrive sur la modification et non le résumé de l'élément
Some checks failed
E2E Tests / e2e (push) Failing after 23s
Build & Push Images / build (brain) (push) Successful in 58s
Build & Push Images / build (core) (push) Successful in 1m33s
Build & Push Images / build (web) (push) Failing after 1m39s
2026-04-27 19:03:58 +02:00
a708c74425 Correction du soucis de mise à jour via l'application
Some checks failed
E2E Tests / e2e (push) Failing after 19s
Build & Push Images / build (brain) (push) Successful in 59s
Build & Push Images / build (core) (push) Successful in 1m39s
Build & Push Images / build (web) (push) Successful in 1m36s
2026-04-27 16:19:56 +02:00
9ad7651c44 Passage V0.7.0
Some checks failed
E2E Tests / e2e (push) Failing after 17s
Build & Push Images / build (brain) (push) Successful in 1m13s
Build & Push Images / build (core) (push) Successful in 1m37s
Build & Push Images / build (web) (push) Successful in 1m43s
2026-04-27 15:51:13 +02:00
389392fd1d Changement sur le Readme
Ajout d'une partie spécifique pour des PNJ dans la partie campagne
2026-04-27 15:48:04 +02:00
aaebeaa547 Mise à jour du readme d'accueil pour l'accès à la documentation
Some checks failed
E2E Tests / e2e (push) Failing after 17s
2026-04-27 08:27:38 +02:00
03ee3855f5 Passage version 0.6.14 + résolution d'un soucis sur l'updater depuis la migration sur git
Some checks failed
E2E Tests / e2e (push) Failing after 24s
Build & Push Images / build (brain) (push) Successful in 59s
Build & Push Images / build (core) (push) Successful in 1m36s
Build & Push Images / build (web) (push) Successful in 1m47s
2026-04-26 19:08:49 +02:00
94a39cf3b4 Mise en place de la pipeline pour github plutot que gitea ; mise en place des images docker sur GHCR plutôt que gitea
Some checks failed
E2E Tests / e2e (push) Failing after 22s
Build & Push Images / build (brain) (push) Successful in 58s
Build & Push Images / build (core) (push) Successful in 1m32s
Build & Push Images / build (web) (push) Successful in 1m40s
Passage version v0.6.13
2026-04-26 10:46:46 +02:00
efe6f6c2b0 Empêche la modale de ce fermer tant que le llm n'est pas télécharger
Some checks failed
E2E Tests / e2e (push) Failing after 19s
Build & Push Images / build (brain) (push) Successful in 48s
Build & Push Images / build (core) (push) Successful in 1m20s
Build & Push Images / build (web) (push) Successful in 1m30s
2026-04-26 09:12:36 +02:00
73a9d15786 Forçage HTTP/1.1 pour la partie python et passage en v0.6.11
Some checks failed
E2E Tests / e2e (push) Failing after 20s
Build & Push Images / build (brain) (push) Successful in 48s
Build & Push Images / build (core) (push) Successful in 1m17s
Build & Push Images / build (web) (push) Successful in 1m27s
2026-04-26 01:55:02 +02:00
dfe05cf2d2 Correction d'un bug lors de tentative de téléchargement de llm pour ollama
Some checks failed
E2E Tests / e2e (push) Failing after 19s
Build & Push Images / build (brain) (push) Successful in 50s
Build & Push Images / build (core) (push) Successful in 1m22s
Build & Push Images / build (web) (push) Successful in 1m28s
2026-04-26 01:45:39 +02:00
fcba907438 Passage version 0.6.9
Some checks failed
E2E Tests / e2e (push) Failing after 19s
Build & Push Images / build (brain) (push) Successful in 47s
Build & Push Images / build (core) (push) Successful in 1m18s
Build & Push Images / build (web) (push) Successful in 1m24s
2026-04-26 01:30:35 +02:00
5739602702 Changement du watchtower pour une version plus récente : projet originel abandonné, repris par un fork.
Some checks failed
E2E Tests / e2e (push) Failing after 19s
Build & Push Images / build (brain) (push) Failing after 20s
Build & Push Images / build (web) (push) Failing after 21s
Build & Push Images / build (core) (push) Failing after 22s
2026-04-26 01:19:58 +02:00
addf78f01d Mise en place v0.6.8
Some checks failed
E2E Tests / e2e (push) Failing after 19s
Build & Push Images / build (brain) (push) Successful in 52s
Build & Push Images / build (core) (push) Successful in 1m20s
Build & Push Images / build (web) (push) Successful in 1m30s
Amélioration de l'installation automatique
Ajout de la possibilité de télécharger le llm que l'on veut à l'interieur de l'application en communicant avec ollama
2026-04-26 01:11:04 +02:00
5e04e84ee4 Mise à jour de la conf pour être sur que le cache angular est bien refresh
Some checks failed
E2E Tests / e2e (push) Failing after 20s
Mise à jour des installeurs
Mise en place de secure-host pour ne pas exposer Ollama à l'exterieur
2026-04-26 00:18:49 +02:00
8d5c2e2b7f Correction pour éviter que la fenêtre ce ferme sans qu'on voit le message d'erreur
Some checks failed
E2E Tests / e2e (push) Failing after 16s
Build & Push Images / build (brain) (push) Successful in 48s
Build & Push Images / build (core) (push) Successful in 1m18s
Build & Push Images / build (web) (push) Successful in 1m24s
2026-04-25 18:37:40 +02:00
788d2c12f2 Ajout d'un .bat pour l'exécution du .ps1
Some checks failed
E2E Tests / e2e (push) Failing after 16s
2026-04-25 18:34:52 +02:00
b25a9746cf Changement sur l'installation automatique : réduction des patterns suspects dans l'installation pour les antivirus (par exemple, monter automatiquement les privilèges en admin...),
Some checks failed
E2E Tests / e2e (push) Failing after 17s
afin d'éviter que l'appli ne soit détectée comme un virus
2026-04-25 18:24:44 +02:00
41fda9aeee Ajout d'un script pour installation automatique du produit
Some checks failed
E2E Tests / e2e (push) Failing after 19s
Build & Push Images / build (brain) (push) Successful in 45s
Build & Push Images / build (core) (push) Successful in 1m16s
Build & Push Images / build (web) (push) Successful in 1m26s
Ajout d'une partie mise à jour automatique : plus besoin de docker pull en ligne de commande ; on peut passer par l'interface
Refactoring partie Java pour respecter d'avantage le DDD : plus de jackson dans la partie domain

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

Passage version 0.6.5
2026-04-25 11:41:14 +02:00
0582690dca Correction d'un test unitaire
Some checks failed
E2E Tests / e2e (push) Failing after 3m43s
Ajout d'un champs image dans les templates par défaut en + du champs nom, description pour avoir un exemple
Correction du visuel du champs d'ajout lors de la modification d'un template (apparition ligne pleine au lieu de texte en pointillé)
Ajout d'un intercepteur pour la partie démo de l'application afin de bien rafraichir le cache angular lorsque le temps de démo est expiré
2026-04-25 09:23:56 +02:00
88278bd1dd Fix : problème d'ascenseur en bas de la page au niveau des templates
Some checks failed
Build & Push Images / build (brain) (push) Successful in 1m1s
Build & Push Images / build (core) (push) Successful in 1m32s
E2E Tests / e2e (push) Failing after 4m13s
Build & Push Images / build (web) (push) Successful in 1m53s
Sélection du template par défaut lors de la création d'une page en fonction du dossier
Passage v0.6.2
2026-04-25 01:39:05 +02:00
d24d6459a0 Ajout de test, correctif d'un problème d'horloge pour le workflow gitea actions pour le e2e
Some checks failed
E2E Tests / e2e (push) Failing after 3m33s
2026-04-25 00:51:32 +02:00
4b866e5212 Fix workflow gitea action pour e2e (tests automatisés via playwright) + correction d'une incohérence dans l'API coté java. Ajout d'autres tests utilisateur
Some checks failed
E2E Tests / e2e (push) Failing after 2m31s
2026-04-25 00:45:04 +02:00
6c6bd20f0d Mise en place de tests utilisateurs avec playwright pour la partie angular + corrections au niveau des labels avec for et id pour cliquer dessus
Some checks failed
E2E Tests / e2e (push) Failing after 5m30s
2026-04-25 00:25:53 +02:00
2764228abf Fix rate limit derriere Cloudflare + CORS sur POST demo 2026-04-24 08:55:40 +02:00
f95d69c915 Fix CORS 403 sur POST : passer APP_CORS_ALLOWED_ORIGINS au core démo 2026-04-24 08:46:26 +02:00
70351e9d9a Remplace docker SDK par appels HTTP directs (zero deps)
All checks were successful
Build & Push Images / build (brain) (push) Successful in 52s
Build & Push Images / build (core) (push) Successful in 1m28s
Build & Push Images / build (web) (push) Successful in 1m34s
2026-04-24 07:39:49 +02:00
ff4905126d Docker SDK v28 pour resoudre les conflits transitifs 2026-04-24 07:33:48 +02:00
0e5b5a7de4 Correction d'une dépendance go 2026-04-24 07:30:20 +02:00
c8c032336b Mise à jour du dockerfile suite à une dépendance trop ancienne sur go 2026-04-24 07:26:42 +02:00
dda27e55fc Orchestrateur go pour lancer la démo et mettre en place plusieurs instances docker 2026-04-23 17:49:26 +02:00
83ac67471e Changement dans la config pour éviter les url en dur + mise en place d'un mode démo 2026-04-23 17:15:08 +02:00
e3c8232e38 Version 0.6.1
All checks were successful
Build & Push Images / build (brain) (push) Successful in 48s
Build & Push Images / build (core) (push) Successful in 1m19s
Build & Push Images / build (web) (push) Successful in 1m31s
2026-04-23 14:36:09 +02:00
a4df9fc759 Ajout des personnage dans la sidebar de la campagne 2026-04-23 14:34:07 +02:00
f1989c1d77 Mutualisation de la version pour ne pas l'oublier dans le footer du front ;
All checks were successful
Build & Push Images / build (brain) (push) Successful in 50s
Build & Push Images / build (core) (push) Successful in 1m29s
Build & Push Images / build (web) (push) Successful in 1m24s
Passage en version v 0.6.0
2026-04-23 14:12:24 +02:00
8efdf5d0e0 Correction bug suppression complète coté lore (et suppression dans tout ce qui est campagne de la partie lore liée).
Améliorations ux :
- Bandeau en haut qui reste accessible lors de la création d'un élément (chapitre, page, scène etc...)
- Mise en place d'un surlignage pour voir su quel élément on est positionné
2026-04-23 14:06:50 +02:00
96bc5de942 Mise en place de la possibilité de supprimer des lores / campagnes d'un seul coup 2026-04-23 11:51:03 +02:00
84ccdd53ad Corrections d'ordre graphique / ergonomique :
- Lorsqu'on part de zéro : la création de dossier / page / template ce fait de manière plus fluide à la création d'un lore (par exemple création de page sans template et dossier : parcours facilité)
- Ajout d'un bouton "+" dans le header templates
- Harmonisation création / modification template

Correction de tests unitaires
2026-04-23 11:25:58 +02:00
29978058ee Correction d'un test unitaire
All checks were successful
Build & Push Images / build (brain) (push) Successful in 49s
Build & Push Images / build (core) (push) Successful in 1m24s
Build & Push Images / build (web) (push) Successful in 1m22s
2026-04-22 13:38:48 +02:00
e510f64336 Passage en v 0.5.0
Some checks failed
Build & Push Images / build (brain) (push) Successful in 47s
Build & Push Images / build (core) (push) Failing after 1m19s
Build & Push Images / build (web) (push) Successful in 1m23s
2026-04-22 13:33:47 +02:00
f189f67aaf Mise en place d'un bouton + au hover plutot qu'un affichage constant 2026-04-22 13:31:06 +02:00
8efa148739 Corrections visuel ; optimisation du chargement des pages (préchargement anticité, sinon temps de latence chaque fois qu'on visite un type de page une première fois) 2026-04-22 13:17:05 +02:00
8f4dd3e9d6 Ajout de la partie "Système de jeu" avec toute la partie stockage de règles de notre jeu.
Ajout de possibilité de stocker des fiches de personnages associés à une campagne également (personnages joueurs pour le moment)
2026-04-22 11:58:50 +02:00
bf38b6695f Ecriture de tests unitaires coté java pour améliorer la stabilité de l'application 2026-04-22 07:46:24 +02:00
243 changed files with 12052 additions and 18406 deletions

View File

@@ -1,5 +0,0 @@
"""Adapter web (architecture hexagonale) : routers FastAPI, DTOs et factories DI.
C'est la FRONTIÈRE HTTP du Brain : validation Pydantic, mapping DTO ↔ domaine,
traduction des erreurs domaine → HTTP. Aucune logique métier ici.
"""

View File

@@ -1,215 +0,0 @@
"""DTOs Pydantic du chat contextuel — frontière HTTP avec le Core Java.
C'est ici (et seulement ici, avec les autres modules de `app.api`) qu'on
utilise Pydantic : le domaine ne voit que des dataclasses (voir chat_mapping).
"""
from pydantic import BaseModel, Field
class ChatMessageDTO(BaseModel):
"""Un message de la conversation. Rôles acceptés : user, assistant, system."""
role: str = Field(pattern="^(user|assistant|system)$")
content: str
class PageSummaryDTO(BaseModel):
"""Résumé enrichi d'une page : identité + contenu + interconnexions.
Depuis b9 : values/tags/related_page_titles sont optionnels côté JSON —
le Core Java ne les sérialise que s'ils sont non-vides (payload léger
pour un Lore avec beaucoup de pages vierges).
"""
title: str
template_name: str
values: dict[str, str] = Field(default_factory=dict)
tags: list[str] = Field(default_factory=list)
related_page_titles: list[str] = Field(default_factory=list)
class LoreContextDTO(BaseModel):
"""Carte structurelle du Lore avec contenu des pages (b9+)."""
lore_name: str
lore_description: str | None = None
folders: dict[str, list[PageSummaryDTO]] = Field(default_factory=dict)
tags: list[str] = Field(default_factory=list)
class PageContextDTO(BaseModel):
"""Contexte d'une page spécifique pour focaliser le chat (optionnel)."""
title: str
template_name: str
template_fields: list[str] = Field(default_factory=list)
values: dict[str, str] = Field(default_factory=dict)
class SceneBranchHintDTO(BaseModel):
"""Indice d'une branche narrative (le Core a deja resolu le nom cible)."""
label: str
target_scene_name: str
condition: str | None = None
class RoomBranchHintDTO(BaseModel):
"""Sortie d'une pièce vers une autre pièce du même lieu (donjon)."""
label: str
target_room_name: str
condition: str | None = None
class RoomSummaryDTO(BaseModel):
"""Pièce d'un lieu explorable. Omise par le Core si la scène est classique."""
name: str
floor: int | None = None
description: str | None = None
enemies: str | None = None
branches: list[RoomBranchHintDTO] = Field(default_factory=list)
class SceneSummaryDTO(BaseModel):
"""Résumé d'une scène : nom + description courte (synopsis)."""
name: str
description: str | None = None
# Optionnel : le Core Java ne serialise illustration_count QUE si > 0
# (payload plus leger). Defaut 0 = pas d'illustrations ou champ absent.
illustration_count: int = 0
# Branches narratives sortantes, omises cote Core si vides.
branches: list[SceneBranchHintDTO] = Field(default_factory=list)
# Pièces du lieu explorable, omises par Core si scène classique.
rooms: list[RoomSummaryDTO] = Field(default_factory=list)
class ChapterSummaryDTO(BaseModel):
"""Résumé d'un chapitre : nom + description courte + ses scènes."""
name: str
description: str | None = None
scenes: list[SceneSummaryDTO] = Field(default_factory=list)
illustration_count: int = 0
class ArcSummaryDTO(BaseModel):
"""Résumé d'un arc narratif : nom + description courte + ses chapitres."""
name: str
description: str | None = None
chapters: list[ChapterSummaryDTO] = Field(default_factory=list)
illustration_count: int = 0
class CharacterSummaryDTO(BaseModel):
"""Résumé d'un PJ : nom + snippet. Pas de fiche complète au niveau résumé."""
name: str
snippet: str = ""
class NpcSummaryDTO(BaseModel):
"""Résumé d'un PNJ : symétrique à CharacterSummaryDTO."""
name: str
snippet: str = ""
class CampaignContextDTO(BaseModel):
"""Carte narrative enrichie : arcs → chapitres → scènes avec synopsis."""
campaign_name: str
campaign_description: str | None = None
arcs: list[ArcSummaryDTO] = Field(default_factory=list)
characters: list[CharacterSummaryDTO] = Field(default_factory=list)
npcs: list[NpcSummaryDTO] = Field(default_factory=list)
class NarrativeEntityDTO(BaseModel):
"""Entité narrative (arc/chapter/scene/character) en cours d'édition — focus optionnel."""
entity_type: str = Field(pattern="^(arc|chapter|scene|character|npc)$")
title: str
fields: dict[str, str] = Field(default_factory=dict)
class GameSystemContextDTO(BaseModel):
"""Règles de JDR présélectionnées par le Core (filtrées par intent).
Les sections sont un dict titre_H2 → contenu_markdown. Peuvent être
vides si aucune section ne matchait l'intent de génération courant.
"""
system_name: str
system_description: str | None = None
sections: dict[str, str] = Field(default_factory=dict)
class JournalEntrySummaryDTO(BaseModel):
"""Une entrée du journal de session.
`source_session_name` est présent uniquement pour les évènements issus
des sessions précédentes — sert à ancrer temporellement dans le prompt.
"""
type: str
content: str
occurred_at: str | None = None
source_session_name: str | None = None
class QuestSummaryDTO(BaseModel):
"""Résumé d'une quête (Chapter dans un Arc HUB). Voir QuestSummary côté domaine."""
name: str
arc_name: str
description: str | None = None
class SessionContextDTO(BaseModel):
"""Contexte d'une Session de jeu en cours (Play Context).
Combine le journal complet (`entries`), les EVENTs des sessions précédentes
(`previous_events`), et — depuis l'ajout du mode Hub — l'état des quêtes
Hub de la campagne (disponibles / en cours / verrouillées) plus les flags
narratifs actuellement actifs.
"""
session_name: str
active: bool
started_at: str | None = None
entries: list[JournalEntrySummaryDTO] = Field(default_factory=list)
previous_events: list[JournalEntrySummaryDTO] = Field(default_factory=list)
available_quests: list[QuestSummaryDTO] = Field(default_factory=list)
in_progress_quests: list[QuestSummaryDTO] = Field(default_factory=list)
locked_quest_titles: list[str] = Field(default_factory=list)
active_flags: list[str] = Field(default_factory=list)
class ChatStreamRequestDTO(BaseModel):
"""Requête de chat streamé : historique + contextes structurels.
Les contextes (lore, page, campaign, narrative_entity, session) sont
optionnels, mais au moins l'un des contextes "racines" (lore_context,
campaign_context ou session_context) doit être fourni. Le validateur
`check_scope` applique cette règle à la frontière HTTP.
"""
messages: list[ChatMessageDTO] = Field(min_length=1)
lore_context: LoreContextDTO | None = None
page_context: PageContextDTO | None = None
campaign_context: CampaignContextDTO | None = None
narrative_entity: NarrativeEntityDTO | None = None
game_system_context: GameSystemContextDTO | None = None
session_context: SessionContextDTO | None = None
def has_scope(self) -> bool:
"""Vrai si au moins un contexte racine (Lore, Campagne ou Session) est fourni."""
return (
self.lore_context is not None
or self.campaign_context is not None
or self.session_context is not None
)

View File

@@ -1,192 +0,0 @@
"""Mapping DTO → domaine (couche anti-corruption de la frontière HTTP).
Traduit les DTOs Pydantic du chat contextuel en dataclasses du domaine :
le cœur métier ne dépend ainsi jamais de Pydantic ni du format JSON du Core.
"""
from app.api.chat_dto import (
CampaignContextDTO,
GameSystemContextDTO,
JournalEntrySummaryDTO,
LoreContextDTO,
NarrativeEntityDTO,
PageContextDTO,
PageSummaryDTO,
QuestSummaryDTO,
SessionContextDTO,
)
from app.domain.models import (
ArcSummary,
CampaignStructuralContext,
ChapterSummary,
CharacterSummary,
GameSystemContext,
JournalEntrySummary,
LoreStructuralContext,
NarrativeEntityContext,
NpcSummary,
PageContext,
PageSummary,
QuestSummary,
RoomBranchHint,
RoomSummary,
SceneBranchHint,
SceneSummary,
SessionContext,
)
def to_lore_context(dto: LoreContextDTO | None) -> LoreStructuralContext | None:
if dto is None:
return None
return LoreStructuralContext(
lore_name=dto.lore_name,
lore_description=dto.lore_description,
folders={
folder: [_to_page_summary(p) for p in pages]
for folder, pages in dto.folders.items()
},
tags=dto.tags,
)
def _to_page_summary(dto: PageSummaryDTO) -> PageSummary:
return PageSummary(
title=dto.title,
template_name=dto.template_name,
values=dict(dto.values),
tags=list(dto.tags),
related_page_titles=list(dto.related_page_titles),
)
def to_page_context(dto: PageContextDTO | None) -> PageContext | None:
if dto is None:
return None
return PageContext(
title=dto.title,
template_name=dto.template_name,
template_fields=dto.template_fields,
values=dto.values,
)
def to_campaign_context(dto: CampaignContextDTO | None) -> CampaignStructuralContext | None:
if dto is None:
return None
arcs = [
ArcSummary(
name=arc.name,
description=arc.description,
illustration_count=arc.illustration_count,
chapters=[
ChapterSummary(
name=ch.name,
description=ch.description,
illustration_count=ch.illustration_count,
scenes=[
SceneSummary(
name=sc.name,
description=sc.description,
illustration_count=sc.illustration_count,
branches=[
SceneBranchHint(
label=br.label,
target_scene_name=br.target_scene_name,
condition=br.condition,
)
for br in sc.branches
],
rooms=[
RoomSummary(
name=room.name,
floor=room.floor,
description=room.description,
enemies=room.enemies,
branches=[
RoomBranchHint(
label=rb.label,
target_room_name=rb.target_room_name,
condition=rb.condition,
)
for rb in room.branches
],
)
for room in sc.rooms
],
)
for sc in ch.scenes
],
)
for ch in arc.chapters
],
)
for arc in dto.arcs
]
characters = [
CharacterSummary(name=c.name, snippet=c.snippet)
for c in dto.characters
]
npcs = [
NpcSummary(name=n.name, snippet=n.snippet)
for n in dto.npcs
]
return CampaignStructuralContext(
campaign_name=dto.campaign_name,
campaign_description=dto.campaign_description,
arcs=arcs,
characters=characters,
npcs=npcs,
)
def to_narrative_entity(dto: NarrativeEntityDTO | None) -> NarrativeEntityContext | None:
if dto is None:
return None
return NarrativeEntityContext(
entity_type=dto.entity_type,
title=dto.title,
fields=dict(dto.fields),
)
def to_game_system_context(dto: GameSystemContextDTO | None) -> GameSystemContext | None:
if dto is None:
return None
return GameSystemContext(
system_name=dto.system_name,
system_description=dto.system_description,
sections=dict(dto.sections),
)
def to_session_context(dto: SessionContextDTO | None) -> SessionContext | None:
if dto is None:
return None
return SessionContext(
session_name=dto.session_name,
active=dto.active,
started_at=dto.started_at,
entries=[_to_journal_entry(e) for e in dto.entries],
previous_events=[_to_journal_entry(e) for e in dto.previous_events],
available_quests=[_to_quest_summary(q) for q in dto.available_quests],
in_progress_quests=[_to_quest_summary(q) for q in dto.in_progress_quests],
locked_quest_titles=list(dto.locked_quest_titles),
active_flags=list(dto.active_flags),
)
def _to_quest_summary(dto: QuestSummaryDTO) -> QuestSummary:
return QuestSummary(
name=dto.name,
arc_name=dto.arc_name,
description=dto.description,
)
def _to_journal_entry(dto: JournalEntrySummaryDTO) -> JournalEntrySummary:
return JournalEntrySummary(
type=dto.type,
content=dto.content,
occurred_at=dto.occurred_at,
source_session_name=dto.source_session_name,
)

View File

@@ -1,26 +0,0 @@
"""Utilitaires partagés des routers : encodage SSE + garde-fous d'upload PDF."""
from __future__ import annotations
import json
# Garde-fou taille : un livre de règles dépasse rarement quelques dizaines de Mo.
# Au-delà, on refuse (probable erreur d'upload) plutôt que d'OOM le conteneur.
MAX_PDF_BYTES = 60 * 1024 * 1024 # 60 Mo
def sse_event(event: str, data: dict) -> str:
"""Encode un évènement Server-Sent Events (accents préservés)."""
return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
def pdf_upload_error(content: bytes) -> str | None:
"""Message d'erreur si l'upload PDF est invalide (vide / trop gros), sinon None.
Utilisé par les flux SSE, où l'erreur doit partir en évènement `error`
plutôt qu'en HTTPException (le flux est déjà ouvert en 200).
"""
if not content:
return "Fichier PDF vide."
if len(content) > MAX_PDF_BYTES:
return f"PDF trop volumineux (> {MAX_PDF_BYTES // (1024 * 1024)} Mo)."
return None

View File

@@ -1,191 +0,0 @@
"""Factories d'injection de dépendance — le point d'inversion de l'hexagone.
C'est ICI (et seulement ici) qu'on choisit QUEL adapter concret incarne chaque
port (LLM, embeddings, extracteur PDF), en fonction des Settings — modifiables
à chaud depuis l'écran Paramètres de l'UI. Les routers ne connaissent que les
ports et les use cases, jamais Ollama/Mistral/etc.
"""
import logging
from typing import Annotated
from fastapi import Depends, HTTPException
from app.application.adapt_campaign import AdaptCampaignUseCase
from app.application.chat import ChatUseCase
from app.application.embeddings import EmbeddingError
from app.application.generate_page import GeneratePageUseCase
from app.application.import_campaign import ImportCampaignUseCase
from app.application.import_rules import ImportRulesUseCase
from app.application.notebook_chat import NotebookChatUseCase
from app.application.notebook_deep import NotebookDeepUseCase
from app.application.notebook_rag import NotebookRagUseCase
from app.core.config import Settings, get_settings
from app.domain.ports import LLMProvider, LLMProviderError
from app.infrastructure.gemini_adapter import GeminiLLMProvider
from app.infrastructure.mistral_adapter import MistralLLMProvider
from app.infrastructure.mistral_embedding_adapter import MistralEmbeddingProvider
from app.infrastructure.ollama_adapter import OllamaLLMProvider
from app.infrastructure.ollama_embedding_adapter import OllamaEmbeddingProvider
from app.infrastructure.onemin_adapter import OneMinAiLLMProvider
from app.infrastructure.openrouter_adapter import OpenRouterLLMProvider
from app.infrastructure.pdf_extractor import PyMuPdfTextExtractor
logger = logging.getLogger(__name__)
# Extracteur PDF partagé : la détection OCR (version Tesseract) a un coût
# (subprocess) qu'on ne veut pas payer à chaque requête → singleton module.
_PDF_EXTRACTOR = PyMuPdfTextExtractor()
def _effective_import_chunk_tokens(settings: Settings) -> int:
"""Taille de morceau réellement utilisable pour l'import.
Avec Ollama, le morceau (entrée) ET sa réécriture en sections (sortie ≈ même
taille) doivent tenir ensemble dans `num_ctx` — sinon Ollama remplit la fenêtre
avec le prompt et la génération s'arrête après quelques tokens (JSON coupé net,
morceau perdu). Budget : entrée×~1.3 (les morceaux sont mesurés en tokens
cl100k, plus compacts que les tokenizers locaux) + consignes + sortie×~1.4
≤ num_ctx → morceau ≤ (num_ctx 800) / 2.7. On plafonne, avec un log pour
rester transparent. Les providers cloud (gros contexte) ne sont pas plafonnés.
"""
requested = settings.import_chunk_tokens
if settings.llm_provider != "ollama":
return requested
cap = max(1000, int((settings.llm_num_ctx - 800) / 2.7))
if requested > cap:
logger.warning(
"Taille de morceau d'import réduite de %s à %s tokens : avec num_ctx=%s, "
"un morceau plus gros ne laisserait pas la place à la sortie du modèle "
"(génération coupée). Augmentez num_ctx pour utiliser de plus gros morceaux.",
requested, cap, settings.llm_num_ctx,
)
return cap
return requested
def get_llm_provider(
settings: Annotated[Settings, Depends(get_settings)],
) -> LLMProvider:
"""Factory d'adapter — point d'inversion de dépendance.
C'est ici (et uniquement ici) qu'on choisit QUEL adapter concret
incarne le port, en fonction du champ `llm_provider` des Settings
(modifiable a chaud depuis l'ecran Parametres de l'UI).
"""
try:
if settings.llm_provider == "onemin":
return OneMinAiLLMProvider(settings)
if settings.llm_provider == "openrouter":
return OpenRouterLLMProvider(settings)
if settings.llm_provider == "mistral":
return MistralLLMProvider(settings)
if settings.llm_provider == "gemini":
return GeminiLLMProvider(settings)
return OllamaLLMProvider(settings)
except LLMProviderError as exc:
# Ex : cle 1min.ai manquante. On renvoie du 400 plutot que du 500
# pour que le frontend puisse afficher un message actionnable.
raise HTTPException(status_code=400, detail=str(exc)) from exc
def get_generate_page_use_case(
llm: Annotated[LLMProvider, Depends(get_llm_provider)],
) -> GeneratePageUseCase:
"""Factory du use case — injecte le port LLMProvider sans connaître l'adapter."""
return GeneratePageUseCase(llm=llm)
def get_chat_use_case(
llm: Annotated[LLMProvider, Depends(get_llm_provider)],
) -> ChatUseCase:
"""Factory du use case chat.
L'adapter OllamaLLMProvider satisfait les deux protocoles (LLMProvider
et LLMChatProvider) par duck typing ; on lui passe la même instance.
"""
return ChatUseCase(llm=llm) # type: ignore[arg-type]
def get_import_rules_use_case(
llm: Annotated[LLMProvider, Depends(get_llm_provider)],
settings: Annotated[Settings, Depends(get_settings)],
) -> ImportRulesUseCase:
"""Factory du use case d'import de règles PDF (extraction + structuration)."""
# Modèle LOCAL → mode segmentation : le LLM ne renvoie que les frontières des
# sections (~200 tokens) et le texte original est découpé localement. Réécrire
# tout le contenu à ~100 tokens/s prendrait des dizaines de minutes par livre.
# Les providers cloud (rapides, grand contexte) gardent la réécriture nettoyée.
return ImportRulesUseCase(
llm=llm, extractor=_PDF_EXTRACTOR,
chunk_target_tokens=_effective_import_chunk_tokens(settings),
segment_only=settings.llm_provider == "ollama")
def get_import_campaign_use_case(
llm: Annotated[LLMProvider, Depends(get_llm_provider)],
settings: Annotated[Settings, Depends(get_settings)],
) -> ImportCampaignUseCase:
"""Factory du use case d'import de campagne PDF (extraction + arborescence)."""
return ImportCampaignUseCase(
llm=llm,
extractor=_PDF_EXTRACTOR,
chunk_target_tokens=_effective_import_chunk_tokens(settings),
map_concurrency=settings.llm_map_concurrency,
)
def get_adapt_campaign_use_case(
llm: Annotated[LLMProvider, Depends(get_llm_provider)],
settings: Annotated[Settings, Depends(get_settings)],
) -> AdaptCampaignUseCase:
"""Factory du use case d'adaptation d'un PDF à une campagne (conseils streamés)."""
# L'adapter satisfait aussi LLMChatProvider (stream_chat) par duck typing.
# Budget d'entrée = taille de morceau configurée (qui passe déjà côté provider).
return AdaptCampaignUseCase( # type: ignore[arg-type]
llm=llm, extractor=_PDF_EXTRACTOR, max_input_tokens=settings.import_chunk_tokens)
def get_embedding_provider(
settings: Annotated[Settings, Depends(get_settings)],
):
"""Factory de l'adapter d'embeddings (RAG) selon `embedding_provider`."""
try:
if settings.embedding_provider == "mistral":
return MistralEmbeddingProvider(settings)
return OllamaEmbeddingProvider(settings)
except EmbeddingError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
def get_notebook_rag_use_case(
embedder: Annotated[object, Depends(get_embedding_provider)],
settings: Annotated[Settings, Depends(get_settings)],
) -> NotebookRagUseCase:
return NotebookRagUseCase(
extractor=_PDF_EXTRACTOR,
embedder=embedder, # type: ignore[arg-type]
min_score=settings.rag_min_score,
)
def get_notebook_chat_use_case(
llm: Annotated[LLMProvider, Depends(get_llm_provider)],
rag: Annotated[NotebookRagUseCase, Depends(get_notebook_rag_use_case)],
settings: Annotated[Settings, Depends(get_settings)],
) -> NotebookChatUseCase:
return NotebookChatUseCase(
rag=rag, llm=llm, rerank_enabled=settings.rag_rerank) # type: ignore[arg-type]
def get_notebook_deep_use_case(
llm: Annotated[LLMProvider, Depends(get_llm_provider)],
embedder: Annotated[object, Depends(get_embedding_provider)],
settings: Annotated[Settings, Depends(get_settings)],
) -> NotebookDeepUseCase:
return NotebookDeepUseCase(
llm=llm,
batch_tokens=settings.import_chunk_tokens,
map_concurrency=settings.llm_map_concurrency,
embedder=embedder,
summary_filter=settings.deep_summary_filter,
)

View File

@@ -1,5 +0,0 @@
"""Routers FastAPI du Brain, un par responsabilité métier.
Chemins inchangés par rapport à l'ancien main.py monolithique : le Core Java
et le frontend ne voient AUCUNE différence.
"""

View File

@@ -1,119 +0,0 @@
"""Endpoint du chat contextuel (/chat/stream) : Structural Context + jauge tokens."""
import json
from typing import Annotated, AsyncIterator
import tiktoken
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import StreamingResponse
from app.api.chat_dto import ChatStreamRequestDTO
from app.api.chat_mapping import (
to_campaign_context,
to_game_system_context,
to_lore_context,
to_narrative_entity,
to_page_context,
to_session_context,
)
from app.api.deps import get_chat_use_case
from app.application.chat import ChatUseCase
from app.core.config import get_settings
from app.domain.models import ChatMessage
from app.domain.ports import LLMProviderError
router = APIRouter()
# Encodeur tiktoken partagé — chargé une fois pour éviter le coût de lookup
# à chaque requête. On utilise cl100k_base (GPT-3.5/4) comme tokenizer
# universel approximatif : ±10% d'écart avec Llama/Gemma mais largement
# suffisant pour une jauge visuelle à l'utilisateur.
_TOKEN_ENCODER: tiktoken.Encoding | None = None
def _count_tokens(text: str | None) -> int:
"""Compte les tokens d'un texte via tiktoken. Null/empty → 0."""
if not text:
return 0
global _TOKEN_ENCODER
if _TOKEN_ENCODER is None:
_TOKEN_ENCODER = tiktoken.get_encoding("cl100k_base")
return len(_TOKEN_ENCODER.encode(text))
@router.post("/chat/stream")
async def chat_stream(
body: ChatStreamRequestDTO,
use_case: Annotated[ChatUseCase, Depends(get_chat_use_case)],
) -> StreamingResponse:
"""Chat streamé (Server-Sent Events) avec Structural Context.
Accepte jusqu'à 4 contextes optionnels (Lore, Page focalisée, Campagne,
entité narrative focalisée). Au moins un contexte racine (Lore ou
Campagne) est requis pour que la requête ait du sens.
Format de flux :
- Chaque token : `data: {"token": "..."}\\n\\n`
- Fin normale : `event: done\\ndata: {}\\n\\n`
- Erreur LLM : `event: error\\ndata: {"message": "..."}\\n\\n`
"""
if not body.has_scope():
raise HTTPException(
status_code=422,
detail="Au moins un des deux contextes racines (lore_context ou campaign_context) est requis.",
)
messages = [ChatMessage(role=m.role, content=m.content) for m in body.messages]
lore_context = to_lore_context(body.lore_context)
page_context = to_page_context(body.page_context)
campaign_context = to_campaign_context(body.campaign_context)
narrative_entity = to_narrative_entity(body.narrative_entity)
game_system_context = to_game_system_context(body.game_system_context)
session_context = to_session_context(body.session_context)
# --- Comptage tokens pour la jauge de contexte frontend ---
# On construit le system prompt une fois ici pour le compter — le use case
# le reconstruira à l'identique en interne (coût négligeable : concat de str).
# Cette duplication évite de complexifier le contrat stream() avec un
# paramètre optionnel system_prompt précalculé.
system_prompt_preview = use_case.build_system_prompt(
lore_context=lore_context,
page_context=page_context,
campaign_context=campaign_context,
narrative_entity=narrative_entity,
game_system_context=game_system_context,
session_context=session_context,
)
# Dernier message = "current" (souvent user), le reste = historique accumulé.
current_msg = messages[-1] if messages else None
history_msgs = messages[:-1] if messages else []
settings = get_settings()
usage_payload = {
"system": _count_tokens(system_prompt_preview),
"history": sum(_count_tokens(m.content) for m in history_msgs),
"current": _count_tokens(current_msg.content) if current_msg else 0,
# Plafond connu seulement pour Ollama (num_ctx). Pour le cloud (1min/OpenRouter)
# on ne connaît pas la fenêtre réelle → 0 = "pas de max" (jauge sans dénominateur).
"max": settings.llm_num_ctx if settings.llm_provider == "ollama" else 0,
}
async def event_stream() -> AsyncIterator[str]:
# Event 'usage' émis en tout premier : le frontend peut afficher la
# jauge avant même le premier token de réponse.
yield f"event: usage\ndata: {json.dumps(usage_payload, ensure_ascii=False)}\n\n"
try:
async for token in use_case.stream(
messages,
lore_context=lore_context,
page_context=page_context,
campaign_context=campaign_context,
narrative_entity=narrative_entity,
game_system_context=game_system_context,
session_context=session_context,
):
# json.dumps avec ensure_ascii=False pour préserver les accents
yield f"data: {json.dumps({'token': token}, ensure_ascii=False)}\n\n"
yield "event: done\ndata: {}\n\n"
except LLMProviderError as exc:
yield f"event: error\ndata: {json.dumps({'message': str(exc)})}\n\n"
return StreamingResponse(event_stream(), media_type="text/event-stream")

View File

@@ -1,137 +0,0 @@
"""Endpoints de génération « simple » : prompt libre, page de Lore, auto-titre."""
from typing import Annotated, Literal
from fastapi import APIRouter, Depends, HTTPException
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.core.config import Settings, get_settings
from app.domain.models import PageGenerationContext
from app.domain.ports import LLMProvider, LLMProviderError
router = APIRouter()
class GenerateRequest(BaseModel):
prompt: str
class GenerateResponse(BaseModel):
model: str
response: str
@router.post("/generate", response_model=GenerateResponse)
async def generate(
body: GenerateRequest,
settings: Annotated[Settings, Depends(get_settings)],
llm: Annotated[LLMProvider, Depends(get_llm_provider)],
) -> GenerateResponse:
"""Endpoint libre : prompt → texte brut. Utile pour debug et exploration."""
try:
text = await llm.generate(body.prompt)
except LLMProviderError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
return GenerateResponse(model=settings.llm_model, response=text)
class GeneratePageRequestDTO(BaseModel):
"""Contexte envoyé par le Core Java pour remplir une page via le LLM."""
lore_name: str
folder_name: str
template_name: str
template_fields: list[str] = Field(min_length=1)
page_title: str
lore_description: str | None = None
class GeneratePageResponseDTO(BaseModel):
"""Retour : une valeur textuelle par champ du template (clé = field name)."""
values: dict[str, str]
@router.post("/generate-page", response_model=GeneratePageResponseDTO)
async def generate_page(
body: GeneratePageRequestDTO,
use_case: Annotated[
GeneratePageUseCase, Depends(get_generate_page_use_case)
],
) -> GeneratePageResponseDTO:
"""Endpoint métier : contexte LoreMind → valeurs structurées par champ.
Branche tout le use case `GeneratePageUseCase`. Ce controller ne fait
que le mapping DTO ↔ dataclass et la traduction d'erreur domaine → HTTP.
"""
context = PageGenerationContext(
lore_name=body.lore_name,
lore_description=body.lore_description,
folder_name=body.folder_name,
template_name=body.template_name,
template_fields=body.template_fields,
page_title=body.page_title,
)
try:
result = await use_case.execute(context)
except LLMProviderError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
return GeneratePageResponseDTO(values=result.values)
# --- Auto-titre d'une conversation persistee --------------------------------
class SummarizeTitleMessageDTO(BaseModel):
role: Literal["user", "assistant", "system"]
content: str
class SummarizeTitleRequestDTO(BaseModel):
"""Premiers messages d'une conversation pour auto-generer un titre court."""
messages: list[SummarizeTitleMessageDTO] = Field(default_factory=list)
class SummarizeTitleResponseDTO(BaseModel):
title: str
_TITLE_SYSTEM_PROMPT = (
"Tu generes un titre court (4 a 7 mots max) qui resume le sujet de la "
"conversation ci-dessous. Reponds UNIQUEMENT par le titre, sans guillemets, "
"sans ponctuation finale, sans prefixe type 'Titre :'. Le titre doit etre "
"en francais et capturer le sujet metier (pas 'Conversation IA')."
)
@router.post("/summarize/conversation-title", response_model=SummarizeTitleResponseDTO)
async def summarize_conversation_title(
body: SummarizeTitleRequestDTO,
llm: Annotated[LLMProvider, Depends(get_llm_provider)],
) -> SummarizeTitleResponseDTO:
"""Genere un titre court a partir des premiers echanges de la conversation.
Appele par le core apres le 1er couple user/assistant, pour remplacer le
titre provisoire "Nouvelle conversation" par quelque chose de parlant.
"""
if not body.messages:
raise HTTPException(status_code=422, detail="Au moins un message requis")
transcript = "\n".join(f"{m.role.upper()}: {m.content}" for m in body.messages[:6])
prompt = f"{_TITLE_SYSTEM_PROMPT}\n\nConversation :\n{transcript}\n\nTitre :"
try:
raw = await llm.generate(prompt)
except LLMProviderError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
title = raw.strip().splitlines()[0].strip().strip('"').strip("'").rstrip(".")
if len(title) > 80:
title = title[:80].rstrip()
if not title:
title = "Nouvelle conversation"
return SummarizeTitleResponseDTO(title=title)

View File

@@ -1,184 +0,0 @@
"""Endpoints d'import/adaptation de PDF (règles, campagne) — REST + flux SSE."""
import json
import logging
from typing import Annotated, AsyncIterator
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from app.api.common import MAX_PDF_BYTES, pdf_upload_error, sse_event
from app.api.deps import (
get_adapt_campaign_use_case,
get_import_campaign_use_case,
get_import_rules_use_case,
)
from app.application.adapt_campaign import AdaptCampaignUseCase
from app.application.import_campaign import ImportCampaignUseCase
from app.application.import_rules import ImportRulesUseCase
from app.domain.models import ChatMessage
from app.domain.ports import LLMProviderError, PdfExtractionError
logger = logging.getLogger(__name__)
router = APIRouter()
class RulesImportResponseDTO(BaseModel):
"""Proposition de sections de règles extraites d'un PDF.
`sections` = {titre → contenu markdown}. C'est une PROPOSITION : le Core
et l'UI laissent l'utilisateur réviser/éditer avant toute persistance.
`ocr_page_count` permet d'indiquer si le PDF était un scan (OCR utilisé).
"""
sections: dict[str, str]
page_count: int
ocr_page_count: int
@router.post("/import/rules", response_model=RulesImportResponseDTO)
async def import_rules(
use_case: Annotated[ImportRulesUseCase, Depends(get_import_rules_use_case)],
file: UploadFile = File(...),
) -> RulesImportResponseDTO:
"""Import d'un PDF de règles → sections markdown structurées (proposition).
Extrait le texte (couche texte + repli OCR par page pour les scans), découpe,
et demande au LLM de répartir les règles en sections thématiques. Ne persiste
rien : renvoie la proposition au Core, qui la présente pour révision.
"""
content = await file.read()
if not content:
raise HTTPException(status_code=422, detail="Fichier PDF vide.")
if len(content) > MAX_PDF_BYTES:
raise HTTPException(
status_code=413,
detail=f"PDF trop volumineux (> {MAX_PDF_BYTES // (1024 * 1024)} Mo).",
)
try:
result = await use_case.execute(content)
except PdfExtractionError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except LLMProviderError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
return RulesImportResponseDTO(
sections=result.sections,
page_count=result.page_count,
ocr_page_count=result.ocr_page_count,
)
@router.post("/import/rules/stream")
async def import_rules_stream(
use_case: Annotated[ImportRulesUseCase, Depends(get_import_rules_use_case)],
file: UploadFile = File(...),
) -> StreamingResponse:
"""Import streamé : émet l'avancement (SSE) puis le résultat final.
Évènements SSE :
- `event: extracting` → data: {} (extraction en cours)
- `event: start` → data: {page_count, ocr_page_count, total}
- `event: progress` → data: {current, total, new_sections:[...]}
- `event: done` → data: {sections, page_count, ocr_page_count}
- `event: error` → data: {message}
"""
content = await file.read()
async def event_stream() -> AsyncIterator[str]:
upload_error = pdf_upload_error(content)
if upload_error:
yield sse_event("error", {"message": upload_error})
return
try:
async for ev in use_case.stream(content):
event_type = ev.pop("type")
yield sse_event(event_type, ev)
except PdfExtractionError as exc:
yield sse_event("error", {"message": str(exc)})
except LLMProviderError as exc:
yield sse_event("error", {"message": str(exc)})
except Exception as exc: # noqa: BLE001 — filet : une erreur inattendue ne doit
# PAS casser le flux SSE brutalement (sinon le Core n'a qu'un message générique
# sans détail). On la transforme en évènement `error` propre + log avec trace.
logger.exception("Import règles : erreur inattendue dans le flux.")
yield sse_event("error", {"message": f"Erreur inattendue du Brain : {type(exc).__name__} : {exc}"})
return StreamingResponse(event_stream(), media_type="text/event-stream")
@router.post("/import/campaign/stream")
async def import_campaign_stream(
use_case: Annotated[ImportCampaignUseCase, Depends(get_import_campaign_use_case)],
file: UploadFile = File(...),
) -> StreamingResponse:
"""Import streamé d'un PDF de campagne → arbre arc→chapitre→scène (SSE).
Évènements : `extracting`, `start` {page_count, ocr_page_count, total},
`progress` {current, total, arc_count, chapter_count, scene_count},
`done` {arcs:[...], page_count, ocr_page_count}, `error` {message}.
"""
content = await file.read()
async def event_stream() -> AsyncIterator[str]:
upload_error = pdf_upload_error(content)
if upload_error:
yield sse_event("error", {"message": upload_error})
return
try:
async for ev in use_case.stream(content):
event_type = ev.pop("type")
yield sse_event(event_type, ev)
except PdfExtractionError as exc:
yield sse_event("error", {"message": str(exc)})
except LLMProviderError as exc:
yield sse_event("error", {"message": str(exc)})
except Exception as exc: # noqa: BLE001 — voir import règles : on ne laisse pas
# une erreur inattendue casser le flux sans détail.
logger.exception("Import campagne : erreur inattendue dans le flux.")
yield sse_event("error", {"message": f"Erreur inattendue du Brain : {type(exc).__name__} : {exc}"})
return StreamingResponse(event_stream(), media_type="text/event-stream")
@router.post("/adapt/campaign/stream")
async def adapt_campaign_stream(
use_case: Annotated[AdaptCampaignUseCase, Depends(get_adapt_campaign_use_case)],
file: UploadFile = File(...),
brief: str = Form(""),
messages: str = Form("[]"),
) -> StreamingResponse:
"""Adaptation CONVERSATIONNELLE d'un PDF à une campagne (SSE markdown).
`brief` = description de la campagne (Core). `messages` = JSON de l'échange
([{role, content}, …]) ; vide au 1er tour. Évènements : `token`, `done`, `error`.
"""
content = await file.read()
try:
raw_messages = json.loads(messages) if messages else []
except json.JSONDecodeError:
raw_messages = []
convo = [
ChatMessage(role=str(m.get("role", "user")), content=str(m.get("content", "")))
for m in raw_messages
if isinstance(m, dict) and str(m.get("content", "")).strip()
]
async def event_stream() -> AsyncIterator[str]:
upload_error = pdf_upload_error(content)
if upload_error:
yield sse_event("error", {"message": upload_error})
return
try:
async for token in use_case.stream(content, brief, convo):
yield sse_event("token", {"token": token})
yield sse_event("done", {})
except PdfExtractionError as exc:
yield sse_event("error", {"message": str(exc)})
except LLMProviderError as exc:
yield sse_event("error", {"message": str(exc)})
return StreamingResponse(event_stream(), media_type="text/event-stream")

View File

@@ -1,365 +0,0 @@
"""Endpoints de catalogue de modèles (Ollama, OpenRouter, Mistral, Gemini, 1min.ai).
Proxifie les APIs des providers pour que l'UI propose des listes de modèles ;
repli statique quand l'API est injoignable ou la clé absente (pas de 500 à l'UI).
"""
import json
from typing import Annotated, AsyncIterator
import httpx
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from app.core.config import Settings, get_settings
router = APIRouter()
@router.get("/models/ollama")
async def list_ollama_models(
settings: Annotated[Settings, Depends(get_settings)],
) -> dict[str, list[str]]:
"""Liste les modeles disponibles sur le serveur Ollama configure.
Retourne une liste vide si Ollama est injoignable — l'UI affichera un
message plutot qu'une 500.
"""
url = f"{settings.ollama_base_url}/api/tags"
try:
async with httpx.AsyncClient(timeout=5) as client:
response = await client.get(url)
response.raise_for_status()
data = response.json()
except httpx.HTTPError:
return {"models": []}
models = [m.get("name", "") for m in data.get("models", []) if m.get("name")]
return {"models": sorted(models)}
class OllamaModelInfoDTO(BaseModel):
"""Info utile extraite de /api/show pour un modele Ollama donne.
`context_length` = fenetre de contexte max supportee par le modele
(extraite des metadonnees GGUF). 0 si inconnue. Le frontend s'en sert
pour borner le slider de num_ctx dans les Parametres.
"""
context_length: int = 0
@router.post("/models/ollama/info", response_model=OllamaModelInfoDTO)
async def get_ollama_model_info(
body: dict[str, str],
settings: Annotated[Settings, Depends(get_settings)],
) -> OllamaModelInfoDTO:
"""Retourne les metadonnees d'un modele Ollama via /api/show.
On passe par POST (et pas GET /models/ollama/{name}) parce que les noms
Ollama contiennent souvent un `:` (ex: `gemma3:e2b`) qui se segmente
mal dans une URL — le body JSON evite le probleme d'escaping.
Le champ qui nous interesse est `model_info["<arch>.context_length"]`
(ex: `gemma3.context_length: 131072`). L'arch varie selon le modele, on
scanne donc tous les champs finissant par `.context_length`.
"""
name = (body.get("name") or "").strip()
if not name:
raise HTTPException(status_code=400, detail="name requis")
url = f"{settings.ollama_base_url}/api/show"
try:
async with httpx.AsyncClient(timeout=5) as client:
response = await client.post(url, json={"model": name})
response.raise_for_status()
data = response.json()
except httpx.HTTPError:
return OllamaModelInfoDTO(context_length=0)
model_info = data.get("model_info") or {}
for key, value in model_info.items():
if key.endswith(".context_length") and isinstance(value, int):
return OllamaModelInfoDTO(context_length=value)
return OllamaModelInfoDTO(context_length=0)
@router.post("/models/ollama/pull")
async def pull_ollama_model(
body: dict[str, str],
settings: Annotated[Settings, Depends(get_settings)],
) -> StreamingResponse:
"""Telecharge un modele depuis Ollama et streame la progression.
Proxifie l'endpoint `/api/pull` d'Ollama qui renvoie du JSON ligne par
ligne (NDJSON) avec le statut de chaque etape : manifest, layers,
digest, success. On reemet ce flux tel quel au client (le front
parsera les lignes et affichera une barre de progression).
Le timeout est intentionnellement tres long (60 min) car certains
modeles font 30+ Go.
"""
name = (body.get("name") or "").strip()
if not name:
raise HTTPException(status_code=400, detail="name requis")
url = f"{settings.ollama_base_url}/api/pull"
async def stream() -> AsyncIterator[bytes]:
# On utilise un timeout long pour la lecture (60 min) mais court pour
# la connexion (10s) — si Ollama n'est pas joignable, on echoue vite.
timeout = httpx.Timeout(connect=10, read=3600, write=10, pool=10)
try:
async with httpx.AsyncClient(timeout=timeout) as client:
async with client.stream("POST", url, json={"model": name, "stream": True}) as r:
if r.status_code != 200:
# Ollama renvoie un message JSON d'erreur. On le passe
# tel quel au client en preservant le code HTTP.
body_text = await r.aread()
yield body_text
return
async for chunk in r.aiter_bytes():
yield chunk
except httpx.HTTPError as e:
# Erreur reseau : on emet une ligne JSON d'erreur compatible
# avec le format NDJSON d'Ollama.
err = json.dumps({"error": f"Connexion a Ollama impossible : {e}"}) + "\n"
yield err.encode("utf-8")
# application/x-ndjson : un objet JSON par ligne, pas de wrapping SSE.
# C'est le format natif d'Ollama, le front le parsera ligne par ligne.
return StreamingResponse(stream(), media_type="application/x-ndjson")
@router.delete("/models/ollama/{name:path}")
async def delete_ollama_model(
name: str,
settings: Annotated[Settings, Depends(get_settings)],
) -> dict[str, str]:
"""Supprime un modele du serveur Ollama.
Le `:path` dans le pattern autorise les `:` du nom (ex: `gemma4:e4b`)
sans avoir besoin de URL-encoder cote client.
"""
if not name.strip():
raise HTTPException(status_code=400, detail="name requis")
url = f"{settings.ollama_base_url}/api/delete"
try:
async with httpx.AsyncClient(timeout=10) as client:
response = await client.request("DELETE", url, json={"model": name})
if response.status_code == 404:
raise HTTPException(status_code=404, detail=f"Modele '{name}' introuvable")
response.raise_for_status()
except httpx.HTTPError as e:
raise HTTPException(status_code=502, detail=f"Ollama injoignable : {e}")
return {"status": "deleted", "name": name}
@router.get("/models/openrouter")
async def list_openrouter_models() -> dict[str, list[dict[str, object]]]:
"""Catalogue DYNAMIQUE des modeles OpenRouter (API publique, sans cle).
Renvoie {models: [{id, name, context_length, free}]}, trie gratuits d'abord
puis contexte decroissant. `free` = id finissant par ':free' OU prix nul.
"""
try:
async with httpx.AsyncClient(timeout=20) as client:
response = await client.get("https://openrouter.ai/api/v1/models")
response.raise_for_status()
data = response.json()
except httpx.HTTPError as exc:
raise HTTPException(status_code=502, detail=f"OpenRouter injoignable : {exc}")
def _is_zero(value: object) -> bool:
try:
return float(value) == 0.0 # type: ignore[arg-type]
except (TypeError, ValueError):
return False
models: list[dict[str, object]] = []
for m in data.get("data", []) or []:
mid = str(m.get("id") or "")
if not mid:
continue
pricing = m.get("pricing") or {}
is_free = mid.endswith(":free") or (
_is_zero(pricing.get("prompt")) and _is_zero(pricing.get("completion"))
)
try:
ctx = int(m.get("context_length") or 0)
except (TypeError, ValueError):
ctx = 0
models.append({
"id": mid,
"name": str(m.get("name") or mid),
"context_length": ctx,
"free": is_free,
})
models.sort(key=lambda x: (not x["free"], -int(x["context_length"]))) # type: ignore[index]
return {"models": models}
# Repli statique si la cle Mistral n'est pas (encore) configuree ou si l'API est
# injoignable — l'utilisateur peut quand meme choisir un modele. Liste curee
# (juin 2026) ; pour l'extraction de PDF, prefere `large` (fidele, 128k) ou `small`.
_MISTRAL_FALLBACK_MODELS = [
"mistral-large-latest",
"mistral-medium-latest",
"mistral-small-latest",
"open-mistral-nemo",
"ministral-8b-latest",
"ministral-3b-latest",
"magistral-medium-latest",
"magistral-small-latest",
"pixtral-large-latest",
"codestral-latest",
]
@router.get("/models/mistral")
async def list_mistral_models(
settings: Annotated[Settings, Depends(get_settings)],
) -> dict[str, list[dict[str, object]]]:
"""Catalogue des modeles Mistral. Dynamique si une cle est configuree
(GET /v1/models, qui requiert l'auth), sinon repli statique.
Renvoie {models: [{id}]} (tous accessibles sur le tier gratuit Experiment)."""
key = settings.mistral_api_key
if not key:
return {"models": [{"id": m} for m in _MISTRAL_FALLBACK_MODELS]}
try:
async with httpx.AsyncClient(timeout=20) as client:
response = await client.get(
"https://api.mistral.ai/v1/models",
headers={"Authorization": f"Bearer {key}"},
)
response.raise_for_status()
data = response.json()
except httpx.HTTPError:
# Cle invalide / API down : on ne casse pas l'UI, on propose le repli.
return {"models": [{"id": m} for m in _MISTRAL_FALLBACK_MODELS]}
ids = sorted({str(m.get("id")) for m in data.get("data", []) or [] if m.get("id")})
if not ids:
ids = _MISTRAL_FALLBACK_MODELS
return {"models": [{"id": i} for i in ids]}
# Repli statique Gemini (juin 2026). Pour l'extraction, prefere un Flash a grand
# contexte ; `gemini-2.0-flash` a le quota gratuit le plus genereux.
_GEMINI_FALLBACK_MODELS = [
"gemini-2.0-flash",
"gemini-2.0-flash-lite",
"gemini-2.5-flash",
"gemini-2.5-flash-lite",
"gemini-2.5-pro",
"gemini-1.5-flash",
"gemini-1.5-pro",
]
@router.get("/models/gemini")
async def list_gemini_models(
settings: Annotated[Settings, Depends(get_settings)],
) -> dict[str, list[dict[str, object]]]:
"""Catalogue des modeles Gemini. Dynamique si une cle est configuree (endpoint
OpenAI-compatible /openai/models), sinon repli statique. Renvoie {models:[{id}]}."""
key = settings.gemini_api_key
if not key:
return {"models": [{"id": m} for m in _GEMINI_FALLBACK_MODELS]}
try:
async with httpx.AsyncClient(timeout=20) as client:
response = await client.get(
"https://generativelanguage.googleapis.com/v1beta/openai/models",
headers={"Authorization": f"Bearer {key}"},
)
response.raise_for_status()
data = response.json()
except httpx.HTTPError:
return {"models": [{"id": m} for m in _GEMINI_FALLBACK_MODELS]}
# Les ids peuvent arriver prefixes "models/" → on nettoie pour que la valeur
# selectionnee soit directement utilisable dans l'appel chat. On garde les
# modeles "gemini-*" (hors embeddings/aqa) pour ne pas noyer la liste.
ids: set[str] = set()
for m in data.get("data", []) or []:
mid = str(m.get("id") or "")
if mid.startswith("models/"):
mid = mid[len("models/"):]
if mid.startswith("gemini-"):
ids.add(mid)
clean = sorted(ids) if ids else _GEMINI_FALLBACK_MODELS
return {"models": [{"id": i} for i in clean]}
@router.get("/models/onemin")
def list_onemin_models() -> dict[str, list[dict[str, object]]]:
"""Catalogue statique des modeles 1min.ai, groupes par fournisseur.
Liste construite par probing direct de l'endpoint chat-with-ai avec
une vraie cle API (avril 2026) : chaque ID renvoie 200, les IDs
absents renvoient 400 UNSUPPORTED_MODEL.
Nota : les IDs Anthropic utilisent la nomenclature propre a 1min.ai
(`claude-<family>-<version>`), pas la convention officielle Anthropic.
"""
return {
"groups": [
{
"provider": "Anthropic",
"models": ["claude-opus-4-6", "claude-sonnet-4-6"],
},
{
"provider": "OpenAI",
"models": [
"gpt-5",
"gpt-5-mini",
"gpt-5-nano",
"gpt-4.1",
"gpt-4.1-mini",
"gpt-4.1-nano",
"gpt-4o",
"gpt-4o-mini",
"gpt-4-turbo",
"gpt-3.5-turbo",
"o3",
"o3-pro",
"o3-mini",
"o4-mini",
],
},
{
"provider": "Google",
"models": ["gemini-2.5-pro", "gemini-2.5-flash"],
},
{
"provider": "Mistral",
"models": [
"mistral-large-latest",
"mistral-medium-latest",
"mistral-small-latest",
"open-mistral-nemo",
],
},
{
"provider": "DeepSeek",
"models": ["deepseek-chat", "deepseek-reasoner"],
},
{
"provider": "xAI",
"models": ["grok-3", "grok-3-mini"],
},
{
"provider": "Meta",
"models": [
"meta/meta-llama-3.1-405b-instruct",
"meta/meta-llama-3-70b-instruct",
],
},
{
"provider": "Alibaba",
"models": ["qwen-plus", "qwen3-max"],
},
{
"provider": "Perplexity",
"models": ["sonar", "sonar-pro"],
},
]
}

View File

@@ -1,130 +0,0 @@
"""Endpoints des notebooks (atelier RAG) : indexation des sources + chats ancrés."""
import logging
from typing import Annotated, AsyncIterator
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from app.api.common import MAX_PDF_BYTES, sse_event
from app.api.deps import (
get_notebook_chat_use_case,
get_notebook_deep_use_case,
get_notebook_rag_use_case,
)
from app.application.embeddings import EmbeddingError
from app.application.notebook_chat import NotebookChatUseCase
from app.application.notebook_deep import NotebookDeepUseCase
from app.application.notebook_rag import NotebookRagUseCase
from app.core.config import Settings, get_settings
from app.domain.models import ChatMessage
from app.domain.ports import LLMProviderError, PdfExtractionError
from app.infrastructure import vector_store
logger = logging.getLogger(__name__)
router = APIRouter()
class IndexSourceResponseDTO(BaseModel):
chunks: int
page_count: int
ocr_page_count: int
@router.post("/index/notebook-source", response_model=IndexSourceResponseDTO)
async def index_notebook_source(
rag: Annotated[NotebookRagUseCase, Depends(get_notebook_rag_use_case)],
source_id: str = Form(...),
file: UploadFile = File(...),
) -> IndexSourceResponseDTO:
"""Indexe une source PDF (extraction + embeddings + stockage vectoriel)."""
content = await file.read()
if not content:
raise HTTPException(status_code=422, detail="Fichier PDF vide.")
if len(content) > MAX_PDF_BYTES:
raise HTTPException(
status_code=413, detail=f"PDF trop volumineux (> {MAX_PDF_BYTES // (1024 * 1024)} Mo).")
try:
recap = await rag.index_source(source_id, content)
except PdfExtractionError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
except EmbeddingError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
return IndexSourceResponseDTO(**recap)
@router.delete("/index/notebook-source/{source_id}")
def delete_notebook_source(source_id: str) -> dict[str, str]:
"""Supprime les vecteurs d'une source (au DELETE d'une source/notebook)."""
vector_store.delete(source_id)
return {"status": "deleted", "source_id": source_id}
class NotebookChatMessageDTO(BaseModel):
role: str
content: str
class NotebookChatRequestDTO(BaseModel):
source_ids: list[str] = Field(default_factory=list)
messages: list[NotebookChatMessageDTO] = Field(default_factory=list)
context: str = Field(default="")
@router.post("/chat/notebook/stream")
async def chat_notebook_stream(
body: NotebookChatRequestDTO,
use_case: Annotated[NotebookChatUseCase, Depends(get_notebook_chat_use_case)],
settings: Annotated[Settings, Depends(get_settings)],
) -> StreamingResponse:
"""Chat ANCRÉ sur les sources (RAG) : récupère les passages pertinents puis
streame la réponse. Évènements SSE : `token` {token}, `done` {}, `error` {message}."""
messages = [ChatMessage(role=m.role, content=m.content) for m in body.messages]
top_k = max(1, min(settings.rag_top_k, 200))
async def event_stream() -> AsyncIterator[str]:
try:
async for ev in use_case.stream(body.source_ids, messages, context=body.context, top_k=top_k):
if ev["type"] == "token":
if ev.get("token"):
yield sse_event("token", {"token": ev["token"]})
else:
# 'sources' (et tout futur évènement typé) : relayé tel quel.
ev_type = ev.pop("type")
yield sse_event(ev_type, ev)
yield sse_event("done", {})
except (LLMProviderError, EmbeddingError) as exc:
yield sse_event("error", {"message": str(exc)})
except Exception as exc: # noqa: BLE001 — filet : pas de coupure brutale du flux.
logger.exception("Chat notebook : erreur inattendue.")
yield sse_event("error", {"message": f"Erreur inattendue du Brain : {type(exc).__name__} : {exc}"})
return StreamingResponse(event_stream(), media_type="text/event-stream")
@router.post("/chat/notebook/deep/stream")
async def chat_notebook_deep_stream(
body: NotebookChatRequestDTO,
use_case: Annotated[NotebookDeepUseCase, Depends(get_notebook_deep_use_case)],
) -> StreamingResponse:
"""Analyse APPROFONDIE (map-reduce sur tout le document). Évènements SSE :
`progress` {current,total} pendant la lecture, puis `token` {token}, puis `done`."""
messages = [ChatMessage(role=m.role, content=m.content) for m in body.messages]
question = next((m.content for m in reversed(messages) if m.role == "user"), "")
async def event_stream() -> AsyncIterator[str]:
if not question.strip():
yield sse_event("error", {"message": "Question vide."})
return
try:
async for ev in use_case.stream(body.source_ids, messages, context=body.context):
ev_type = ev.pop("type")
yield sse_event(ev_type, ev)
except (LLMProviderError, EmbeddingError) as exc:
yield sse_event("error", {"message": str(exc)})
except Exception as exc: # noqa: BLE001 — filet : pas de coupure brutale.
logger.exception("Analyse approfondie : erreur inattendue.")
yield sse_event("error", {"message": f"Erreur inattendue du Brain : {type(exc).__name__} : {exc}"})
return StreamingResponse(event_stream(), media_type="text/event-stream")

View File

@@ -1,115 +0,0 @@
"""Endpoints de paramétrage runtime (écran Paramètres de l'UI)."""
from typing import Annotated, Literal
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from app.core.config import Settings, get_settings
from app.core.settings_store import save_overrides
router = APIRouter()
class SettingsDTO(BaseModel):
"""Vue serialisable des settings modifiables depuis l'UI.
Expose uniquement les champs que l'utilisateur peut changer a chaud.
Les secrets (onemin_api_key) sont masques en lecture.
"""
llm_provider: Literal["ollama", "onemin", "openrouter", "mistral", "gemini"]
ollama_base_url: str
llm_model: str
onemin_model: str
# True si une cle 1min.ai est deja configuree — pas de leak de la cle elle-meme.
onemin_api_key_set: bool
openrouter_model: str
# True si une cle OpenRouter est deja configuree (cle elle-meme jamais renvoyee).
openrouter_api_key_set: bool
mistral_model: str
# True si une cle Mistral est deja configuree (cle elle-meme jamais renvoyee).
mistral_api_key_set: bool
gemini_model: str
# True si une cle Gemini est deja configuree (cle elle-meme jamais renvoyee).
gemini_api_key_set: bool
# Embeddings (RAG des ateliers) : provider + modeles + auto-pull Ollama.
embedding_provider: Literal["ollama", "mistral"]
ollama_embedding_model: str
mistral_embedding_model: str
auto_pull_embedding_model: bool
rag_top_k: int
# Fenetre de contexte effective passee au modele (num_ctx Ollama) — sert
# aussi de plafond a la jauge de contexte UI.
llm_num_ctx: int
# Taille cible d'un morceau (tokens) pour l'import de PDF (regles/campagne).
import_chunk_tokens: int
# Timeout HTTP des appels LLM (s). A monter si les imports lourds expirent.
llm_timeout_seconds: int
class SettingsUpdateDTO(BaseModel):
"""Patch partiel des settings. Tous les champs sont optionnels."""
llm_provider: Literal["ollama", "onemin", "openrouter", "mistral", "gemini"] | None = None
ollama_base_url: str | None = None
llm_model: str | None = None
onemin_model: str | None = None
# Chaine vide => on efface la cle. None => pas de changement.
onemin_api_key: str | None = None
openrouter_model: str | None = None
openrouter_api_key: str | None = None
mistral_model: str | None = None
mistral_api_key: str | None = None
gemini_model: str | None = None
gemini_api_key: str | None = None
embedding_provider: Literal["ollama", "mistral"] | None = None
ollama_embedding_model: str | None = None
mistral_embedding_model: str | None = None
auto_pull_embedding_model: bool | None = None
rag_top_k: int | None = None
llm_num_ctx: int | None = None
import_chunk_tokens: int | None = None
llm_timeout_seconds: int | None = None
def _to_settings_dto(s: Settings) -> SettingsDTO:
return SettingsDTO(
llm_provider=s.llm_provider,
ollama_base_url=s.ollama_base_url,
llm_model=s.llm_model,
onemin_model=s.onemin_model,
onemin_api_key_set=bool(s.onemin_api_key),
openrouter_model=s.openrouter_model,
openrouter_api_key_set=bool(s.openrouter_api_key),
mistral_model=s.mistral_model,
mistral_api_key_set=bool(s.mistral_api_key),
gemini_model=s.gemini_model,
gemini_api_key_set=bool(s.gemini_api_key),
embedding_provider=s.embedding_provider,
ollama_embedding_model=s.ollama_embedding_model,
mistral_embedding_model=s.mistral_embedding_model,
auto_pull_embedding_model=s.auto_pull_embedding_model,
rag_top_k=s.rag_top_k,
llm_num_ctx=s.llm_num_ctx,
import_chunk_tokens=s.import_chunk_tokens,
llm_timeout_seconds=s.llm_timeout_seconds,
)
@router.get("/settings", response_model=SettingsDTO)
def read_settings(settings: Annotated[Settings, Depends(get_settings)]) -> SettingsDTO:
"""Retourne la config courante (secrets masques)."""
return _to_settings_dto(settings)
@router.put("/settings", response_model=SettingsDTO)
def update_settings(patch: SettingsUpdateDTO) -> SettingsDTO:
"""Applique un patch partiel aux settings et persiste les overrides.
Toute requete HTTP suivante verra les nouvelles valeurs (pas de cache).
"""
overrides = {k: v for k, v in patch.model_dump().items() if v is not None}
if overrides:
save_overrides(overrides)
# Relit .env + overrides fusionnes pour confirmation.
return _to_settings_dto(get_settings())

View File

@@ -1,216 +0,0 @@
"""Endpoints « outils de table » : tables aléatoires, improvisation, catalogues d'objets."""
import re
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from app.api.deps import get_llm_provider
from app.application.llm_json import load_json_object
from app.application.llm_retry import generate_with_retry
from app.domain.ports import LLMProvider, LLMProviderError
router = APIRouter()
_DICE_FORMULA_RE = re.compile(r"^\s*(\d*)\s*[dD]\s*(\d+)\s*$")
def _dice_total_range(formula: str) -> tuple[int, int] | None:
"""(min, max) des totaux possibles d'une formule NdM, ou None si invalide."""
match = _DICE_FORMULA_RE.match(formula or "")
if not match:
return None
count = int(match.group(1)) if match.group(1) else 1
faces = int(match.group(2))
if count < 1 or count > 100 or faces < 2 or faces > 10000:
return None
return count, count * faces
class GenerateTableRequestDTO(BaseModel):
description: str
dice_formula: str = Field(default="1d20")
# Contexte libre assemblé par le Core (nom de campagne, système, ambiance…).
context: str = Field(default="")
class GeneratedTableEntryDTO(BaseModel):
min_roll: int
max_roll: int
label: str
detail: str = ""
class GenerateTableResponseDTO(BaseModel):
name: str
description: str = ""
entries: list[GeneratedTableEntryDTO]
@router.post("/generate/random-table", response_model=GenerateTableResponseDTO)
async def generate_random_table(
body: GenerateTableRequestDTO,
llm: Annotated[LLMProvider, Depends(get_llm_provider)],
) -> GenerateTableResponseDTO:
"""Génère une table aléatoire (entrées par plage) couvrant la formule de dé."""
rng = _dice_total_range(body.dice_formula)
if rng is None:
raise HTTPException(status_code=422, detail="Formule de dé invalide (ex. 1d20, 2d6, d100).")
lo, hi = rng
context_block = f"\nContexte de la campagne :\n{body.context.strip()}\n" if body.context.strip() else ""
prompt = (
"Tu es un assistant de jeu de rôle. Génère une TABLE ALÉATOIRE évocatrice.\n"
f"Dé : {body.dice_formula} (résultats possibles de {lo} à {hi}).\n"
f"Sujet : {body.description.strip()}\n"
f"{context_block}\n"
"Règles IMPÉRATIVES :\n"
"- Réponds UNIQUEMENT par un objet JSON valide, sans texte autour.\n"
'- Format : {"name": "...", "description": "...", "entries": '
'[{"min_roll": N, "max_roll": M, "label": "résultat court", "detail": "1-2 phrases"}]}\n'
f"- Les plages (min_roll..max_roll) doivent COUVRIR EXACTEMENT {lo}..{hi}, "
"sans trou ni chevauchement, dans l'ordre croissant.\n"
"- Des résultats variés, cohérents avec le sujet (et le contexte s'il est fourni).\n"
"- En français. 'label' = résultat bref ; 'detail' = description/effet concret.\n"
"Renvoie maintenant le JSON."
)
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 table exploitable.")
entries: list[GeneratedTableEntryDTO] = []
for e in parsed.get("entries", []) or []:
if not isinstance(e, dict):
continue
try:
mn = int(e["min_roll"])
mx = int(e["max_roll"])
except (KeyError, TypeError, ValueError):
continue
label = str(e.get("label") or "").strip()
if not label:
continue
entries.append(GeneratedTableEntryDTO(
min_roll=mn, max_roll=max(mn, mx), label=label[:200],
detail=str(e.get("detail") or "").strip(),
))
if not entries:
raise HTTPException(status_code=502, detail="Aucune entrée générée — réessaie ou reformule.")
name = str(parsed.get("name") or body.description).strip()[:120] or "Table générée"
return GenerateTableResponseDTO(
name=name,
description=str(parsed.get("description") or "").strip(),
entries=entries,
)
class ImproviseRollRequestDTO(BaseModel):
table_name: str
result_label: str
result_detail: str = Field(default="")
context: str = Field(default="")
class ImproviseRollResponseDTO(BaseModel):
narration: str
@router.post("/improvise/table-roll", response_model=ImproviseRollResponseDTO)
async def improvise_table_roll(
body: ImproviseRollRequestDTO,
llm: Annotated[LLMProvider, Depends(get_llm_provider)],
) -> ImproviseRollResponseDTO:
"""Brode un court récit (2-3 phrases) sur un résultat tiré, pour lancer la scène."""
detail = f" ({body.result_detail.strip()})" if body.result_detail.strip() else ""
context_block = f"\nContexte : {body.context.strip()}" if body.context.strip() else ""
prompt = (
"Tu es le Maître du Jeu. Les joueurs viennent de tirer sur la table "
f"« {body.table_name.strip()} » et ont obtenu : « {body.result_label.strip()} »{detail}."
f"{context_block}\n\n"
"Décris en 2-3 phrases vivantes et immédiates ce qui se passe, pour lancer la scène. "
"Pas de méta, pas d'options : juste la narration, en français."
)
try:
raw = await llm.generate(prompt, temperature=0.8)
except LLMProviderError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
return ImproviseRollResponseDTO(narration=raw.strip())
# --- Catalogues d'objets (boutiques) : génération IA -------------------------
class GenerateCatalogRequestDTO(BaseModel):
description: str
context: str = Field(default="")
class GeneratedCatalogItemDTO(BaseModel):
name: str
price: str = ""
category: str = ""
description: str = ""
class GenerateCatalogResponseDTO(BaseModel):
name: str
description: str = ""
items: list[GeneratedCatalogItemDTO]
@router.post("/generate/item-catalog", response_model=GenerateCatalogResponseDTO)
async def generate_item_catalog(
body: GenerateCatalogRequestDTO,
llm: Annotated[LLMProvider, Depends(get_llm_provider)],
) -> GenerateCatalogResponseDTO:
"""Génère un catalogue d'objets (boutique, butin…) — nom, prix, catégorie, description."""
context_block = f"\nContexte de la campagne :\n{body.context.strip()}\n" if body.context.strip() else ""
prompt = (
"Tu es un assistant de jeu de rôle. Génère un CATALOGUE D'OBJETS (boutique, butin, trésor…).\n"
f"Sujet : {body.description.strip()}\n"
f"{context_block}\n"
"Règles IMPÉRATIVES :\n"
"- Réponds UNIQUEMENT par un objet JSON valide, sans texte autour.\n"
'- Format : {"name": "...", "description": "...", "items": '
'[{"name": "Objet", "price": "ex. 50 po", "category": "ex. Armes", "description": "effet/détails"}]}\n'
"- Des objets variés et cohérents avec le sujet (et le contexte s'il est fourni).\n"
"- 'price' = prix court dans la monnaie du jeu ; 'category' = regroupement (Armes, Potions…) ; "
"'description' = effet/détails en une phrase. En français.\n"
"Renvoie maintenant le JSON."
)
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 catalogue exploitable.")
items: list[GeneratedCatalogItemDTO] = []
for it in parsed.get("items", []) or []:
if not isinstance(it, dict):
continue
name = str(it.get("name") or "").strip()
if not name:
continue
items.append(GeneratedCatalogItemDTO(
name=name[:200],
price=str(it.get("price") or "").strip(),
category=str(it.get("category") or "").strip(),
description=str(it.get("description") or "").strip(),
))
if not items:
raise HTTPException(status_code=502, detail="Aucun objet généré — réessaie ou reformule.")
name = str(parsed.get("name") or body.description).strip()[:120] or "Catalogue généré"
return GenerateCatalogResponseDTO(
name=name,
description=str(parsed.get("description") or "").strip(),
items=items,
)

View File

@@ -13,19 +13,8 @@ from __future__ import annotations
CHUNK_TARGET_TOKENS = 6000
def chunk_text(
full_text: str,
target_tokens: int = CHUNK_TARGET_TOKENS,
overlap_tokens: int = 0,
) -> list[str]:
"""Découpe `full_text` en morceaux ~`target_tokens` tokens (frontières de §).
`overlap_tokens` > 0 : chaque morceau reprend la fin du précédent (les derniers
paragraphes, jusqu'à ~`overlap_tokens` tokens). Utile pour le RAG : une phrase-clé
à cheval sur deux morceaux reste retrouvable dans au moins l'un des deux. À
laisser à 0 pour les imports (recopie) : un overlap y DUPLIQUERAIT du texte.
Un morceau peut légèrement dépasser la cible (jusqu'à target + overlap).
"""
def chunk_text(full_text: str, target_tokens: int = CHUNK_TARGET_TOKENS) -> list[str]:
"""Découpe `full_text` en morceaux ~`target_tokens` tokens (frontières de §)."""
if not full_text.strip():
return []
@@ -37,65 +26,32 @@ def chunk_text(
chunks: list[str] = []
current: list[str] = []
current_tokens = 0
fresh = False # `current` contient-il du contenu pas encore émis ? (évite de
# ré-émettre un morceau composé uniquement de l'overlap en fin de texte)
for para in paragraphs:
para_tokens = len(enc.encode(para))
# Un paragraphe seul plus gros que la cible : on le coupe en sous-blocs.
if para_tokens > target_tokens:
if current and fresh:
if current:
chunks.append("\n\n".join(current))
current, current_tokens, fresh = [], 0, False
chunks.extend(_split_oversized(para, enc, target_tokens, overlap_tokens))
current, current_tokens = [], 0
chunks.extend(_split_oversized(para, enc, target_tokens))
continue
if current_tokens + para_tokens > target_tokens and current:
if fresh:
chunks.append("\n\n".join(current))
current, current_tokens = _overlap_tail(current, enc, overlap_tokens)
fresh = False
chunks.append("\n\n".join(current))
current, current_tokens = [], 0
current.append(para)
current_tokens += para_tokens
fresh = True
if current and fresh:
if current:
chunks.append("\n\n".join(current))
return chunks
def _overlap_tail(parts: list[str], enc, overlap_tokens: int) -> tuple[list[str], int]:
"""Derniers paragraphes de `parts` totalisant au plus `overlap_tokens` tokens
le « rappel » recopié en tête du morceau suivant."""
if overlap_tokens <= 0 or not parts:
return [], 0
tail: list[str] = []
total = 0
for para in reversed(parts):
para_tokens = len(enc.encode(para))
if total + para_tokens > overlap_tokens:
break
tail.insert(0, para)
total += para_tokens
if not tail:
# Aucun paragraphe entier ne tient dans le budget (paragraphes longs) :
# on reprend la FIN du dernier paragraphe pour garantir le recouvrement.
tokens = enc.encode(parts[-1])
tail = [enc.decode(tokens[-overlap_tokens:])]
total = min(overlap_tokens, len(tokens))
return tail, total
def _split_oversized(paragraph: str, enc, target_tokens: int, overlap_tokens: int = 0) -> list[str]:
"""Coupe un paragraphe géant en sous-blocs ~`target_tokens` tokens (fenêtre
glissante avec recouvrement si `overlap_tokens` > 0)."""
def _split_oversized(paragraph: str, enc, target_tokens: int) -> list[str]:
"""Coupe un paragraphe géant en sous-blocs ~`target_tokens` tokens."""
tokens = enc.encode(paragraph)
step = max(1, target_tokens - overlap_tokens)
out: list[str] = []
i = 0
while i < len(tokens):
for i in range(0, len(tokens), target_tokens):
out.append(enc.decode(tokens[i : i + target_tokens]))
if i + target_tokens >= len(tokens):
break
i += step
return out

View File

@@ -14,13 +14,7 @@ class EmbeddingError(Exception):
class EmbeddingProvider(Protocol):
"""Calcule les vecteurs d'une liste de textes (ordre préservé).
"""Calcule les vecteurs d'une liste de textes (ordre préservé)."""
`kind` distingue les DOCUMENTS indexés ("document") de la QUESTION posée
("query") : certains modèles (nomic-embed-text) sont entraînés avec des
préfixes de tâche distincts et perdent en pertinence sans eux. Les adapters
qui n'en ont pas besoin (mistral-embed) ignorent simplement le paramètre.
"""
async def embed(self, texts: list[str], kind: str = "document") -> list[list[float]]:
async def embed(self, texts: list[str]) -> list[list[float]]:
...

View File

@@ -10,7 +10,6 @@ PROPOSITION non persistée : le Core crée les entités seulement après revue.
"""
from __future__ import annotations
import asyncio
import logging
from app.application.chunking import chunk_text, split_in_half
@@ -25,16 +24,10 @@ from app.domain.models import (
ArcProposal,
CampaignImportResult,
ChapterProposal,
NpcImportProposal,
RoomProposal,
SceneProposal,
)
from app.domain.ports import (
LLMGenerationTimeout,
LLMProvider,
LLMProviderError,
PdfTextExtractor,
)
from app.domain.ports import LLMProvider, LLMProviderError, PdfTextExtractor
logger = logging.getLogger(__name__)
@@ -89,14 +82,6 @@ PIÈCES (rooms) — uniquement pour les scènes qui sont des lieux explorables :
- `enemies` = créatures/boss de la salle (vide si aucune). `loot` = trésor/récompense (vide si aucun).
- Pour une scène narrative classique (pas un donjon), "rooms" est un tableau vide [].
PNJ ET CRÉATURES NOTABLES ("npcs", tableau au niveau racine) :
- Recense les PNJ NOMMÉS (alliés, marchands, antagonistes) et les créatures UNIQUES
(boss, monstre récurrent) présents dans l'extrait.
- `description` = courte fiche utile au MJ : rôle dans l'histoire, apparence,
motivations, où on le rencontre. 2 à 4 phrases, fidèles au livre.
- N'inclus PAS les monstres génériques sans nom (« 3 gobelins », « un loup »).
- Aucun PNJ nommé dans l'extrait → "npcs": [].
Format de réponse :
- Tu réponds UNIQUEMENT par un objet JSON valide, sans markdown ni commentaire autour.
- Schéma EXACT :
@@ -105,139 +90,12 @@ Format de réponse :
{{"name": "...", "description": "...", "player_narration": "...", "gm_notes": "...",
"rooms": [{{"name": "...", "description": "...", "enemies": "...", "loot": "..."}}]}}
]}}]}}
],
"npcs": [{{"name": "...", "description": "..."}}]}}
]}}
- Utilise les VRAIS titres du livre pour les noms (pas de paraphrase).
- Si le livre n'est PAS découpé en actes/parties, regroupe tout sous un seul arc nommé "{default_arc}".
- N'invente pas de contenu : tu réorganises et recopies ce qui est présent dans l'extrait.
- Si l'extrait ne contient aucune matière narrative, renvoie {{"arcs": []}}."""
# Schéma de l'arbre attendu, passé aux providers à sorties structurées (Ollama
# contraint la grammaire : un modèle local ne PEUT plus produire de clés
# inventées, d'objets bavards type "thought" ni de texte hors JSON). Les
# adapters cloud le traduisent en mode JSON natif. Seuls les "name" sont
# requis : le _TreeMerger tolère déjà tous les champs absents.
_TREE_SCHEMA: dict = {
"type": "object",
"properties": {
"arcs": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {"type": "string"},
"description": {"type": "string"},
"type": {"type": "string", "enum": ["LINEAR", "HUB"]},
"chapters": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {"type": "string"},
"description": {"type": "string"},
"scenes": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {"type": "string"},
"description": {"type": "string"},
"player_narration": {"type": "string"},
"gm_notes": {"type": "string"},
"rooms": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {"type": "string"},
"description": {"type": "string"},
"enemies": {"type": "string"},
"loot": {"type": "string"},
},
"required": ["name"],
"additionalProperties": False,
},
},
},
"required": ["name"],
"additionalProperties": False,
},
},
},
"required": ["name"],
"additionalProperties": False,
},
},
},
"required": ["name"],
"additionalProperties": False,
},
},
"npcs": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {"type": "string"},
"description": {"type": "string"},
},
"required": ["name"],
"additionalProperties": False,
},
},
},
"required": ["arcs"],
"additionalProperties": False,
}
# Bloc TOC injecté quand le PDF a des bookmarks : les morceaux étant traités
# séparément, c'est CE référentiel commun qui garantit que tous nomment les
# mêmes chapitres à l'identique → la fusion par nom du _TreeMerger recolle
# les chapitres coupés au lieu de créer des doublons.
_TOC_BLOCK = """
--- STRUCTURE OFFICIELLE DU LIVRE (table des matières du PDF) ---
{toc}
--- FIN DE LA STRUCTURE ---
IMPORTANT : pour nommer les arcs et chapitres, reprends EXACTEMENT les titres
de cette structure (caractère pour caractère). Rattache le contenu de l'extrait
au bon chapitre de la structure, même si son titre n'apparaît pas dans l'extrait."""
# Garde-fou prompt : une TOC de gros livre peut compter des centaines d'entrées
# (sous-sous-sections). On la limite aux niveaux hauts et à un nombre raisonnable.
_TOC_MAX_LEVEL = 2
_TOC_MAX_ENTRIES = 80
# Consolidation finale : le squelette (noms seuls) est minuscule, donc l'appel
# est quasi gratuit comparé aux MAP. Température 0 et consigne CONSERVATRICE :
# ne fusionner que les doublons évidents, jamais des entités distinctes.
_CONSOLIDATE_PROMPT = """Voici le squelette d'une arborescence arc → chapitre → scène issue d'une
fusion AUTOMATIQUE de morceaux d'un livre de campagne de jeu de rôle. La fusion par nom exact
peut avoir laissé des QUASI-DOUBLONS : le même chapitre ou la même scène sous deux libellés
légèrement différents (ex: "La Crypte" et "Crypte de Karrak", "3. Salle des gardes" et
"Salle des gardes").
{skeleton}
Identifie UNIQUEMENT les fusions ÉVIDENTES (même entité du livre sous deux noms). Sois
CONSERVATEUR : dans le doute, ne fusionne PAS. Deux lieux/évènements distincts ne doivent
JAMAIS être fusionnés.
Réponds UNIQUEMENT par un objet JSON valide :
{{"chapter_merges": [{{"into": "nom du chapitre à garder", "merge": ["nom à fusionner", ...]}}],
"scene_merges": [{{"chapter": "nom du chapitre", "into": "nom de la scène à garder",
"merge": ["nom à fusionner", ...]}}]}}
S'il n'y a RIEN à fusionner (cas le plus fréquent) : {{"chapter_merges": [], "scene_merges": []}}"""
def _format_toc(toc) -> str:
"""Formate la TOC du PDF en liste indentée, bornée (niveaux hauts d'abord)."""
entries = [e for e in toc if e.level <= _TOC_MAX_LEVEL][:_TOC_MAX_ENTRIES]
if not entries:
return ""
return "\n".join(f"{' ' * (e.level - 1)}- {e.title} (p. {e.page})" for e in entries)
class _TreeMerger:
"""Fusionne les sous-arbres des morceaux en un seul arbre, ordre préservé.
@@ -250,8 +108,6 @@ class _TreeMerger:
def __init__(self) -> None:
# arc_key -> {"name", "description", "chapters": {chap_key -> {...}}}
self._arcs: dict[str, dict] = {}
# npc_key (nom en minuscules) -> {"name", "description"}
self._npcs: dict[str, dict] = {}
def add(self, arcs_json: list[dict]) -> None:
for arc in arcs_json or []:
@@ -280,12 +136,8 @@ class _TreeMerger:
{"name": sname, "description": "", "player_narration": "",
"gm_notes": "", "rooms": {}})
self._fill_desc(s, sc)
# Narration/notes : CONCATÉNATION (pas premier-gagne) — une
# scène coupée entre deux morceaux apporte la suite de son
# contenu dans le morceau suivant ; la jeter perdrait la
# moitié du donjon. Le doublon exact (overlap) est filtré.
self._append_field(s, sc, "player_narration")
self._append_field(s, sc, "gm_notes")
self._fill_field(s, sc, "player_narration")
self._fill_field(s, sc, "gm_notes")
for rm in sc.get("rooms", []) or []:
rname = str(rm.get("name", "")).strip()
if not rname:
@@ -297,22 +149,6 @@ class _TreeMerger:
self._fill_field(r, rm, "enemies")
self._fill_field(r, rm, "loot")
def add_npcs(self, npcs_json: list[dict]) -> None:
"""Accumule les PNJ détectés. Un PNJ revu dans un autre morceau garde la
description la plus COMPLÈTE (la plus longue) — un PNJ récurrent est
souvent décrit en détail une seule fois."""
for npc in npcs_json or []:
name = str(npc.get("name", "")).strip()
if not name:
continue
desc = str(npc.get("description") or "").strip()
entry = self._npcs.setdefault(name.lower(), {"name": name, "description": ""})
if len(desc) > len(entry["description"]):
entry["description"] = desc
def npcs(self) -> list[NpcImportProposal]:
return [NpcImportProposal(n["name"], n["description"]) for n in self._npcs.values()]
@staticmethod
def _fill_desc(node: dict, src: dict) -> None:
if not node["description"]:
@@ -323,23 +159,6 @@ class _TreeMerger:
if not node[field_name]:
node[field_name] = str(src.get(field_name) or "").strip()
@staticmethod
def _append_field(node: dict, src: dict, field_name: str) -> None:
"""Accumule la valeur de `src` à la suite de l'existante (scène coupée
entre deux morceaux). Ignore le vide et le contenu déjà présent (un
morceau redondant — relecture d'overlap — ne duplique rien)."""
new = str(src.get(field_name) or "").strip()
if not new:
return
current = node[field_name]
if not current:
node[field_name] = new
elif new not in current and current not in new:
node[field_name] = current + "\n\n" + new
elif current in new:
# Le nouveau contenu ENGLOBE l'ancien (version plus complète) → on le prend.
node[field_name] = new
def result(self) -> list[ArcProposal]:
arcs: list[ArcProposal] = []
for a in self._arcs.values():
@@ -363,79 +182,6 @@ class _TreeMerger:
scenes = sum(len(c["scenes"]) for a in self._arcs.values() for c in a["chapters"].values())
return arcs, chapters, scenes
# --- Consolidation : fusion des quasi-doublons détectés par le LLM ---------
def skeleton_text(self) -> str:
"""Squelette de l'arbre (noms seuls) — entrée compacte de la consolidation."""
lines: list[str] = []
for a in self._arcs.values():
lines.append(f"ARC: {a['name']}")
for c in a["chapters"].values():
lines.append(f" CHAPITRE: {c['name']}")
for s in c["scenes"].values():
lines.append(f" SCENE: {s['name']}")
return "\n".join(lines)
def merge_chapters(self, into_name: str, merge_names: list[str]) -> bool:
"""Fusionne les chapitres `merge_names` dans `into_name` (tous arcs).
Best-effort : les noms inconnus sont ignorés. Renvoie True si modifié.
"""
target = self._find_chapter(into_name)
if target is None:
return False
changed = False
for mname in merge_names:
key = str(mname).strip().lower()
if not key or key == str(into_name).strip().lower():
continue
for a in self._arcs.values():
src = a["chapters"].pop(key, None)
if src is None or src is target:
continue
self._fill_desc(target, src)
for skey, sdict in src["scenes"].items():
if skey in target["scenes"]:
self._merge_scene_into(target["scenes"][skey], sdict)
else:
target["scenes"][skey] = sdict
changed = True
return changed
def merge_scenes(self, chapter_name: str, into_name: str, merge_names: list[str]) -> bool:
"""Fusionne les scènes `merge_names` dans `into_name` au sein du chapitre."""
chapter = self._find_chapter(chapter_name)
if chapter is None:
return False
target = chapter["scenes"].get(str(into_name).strip().lower())
if target is None:
return False
changed = False
for mname in merge_names:
key = str(mname).strip().lower()
if not key or key == str(into_name).strip().lower():
continue
src = chapter["scenes"].pop(key, None)
if src is None or src is target:
continue
self._merge_scene_into(target, src)
changed = True
return changed
def _find_chapter(self, name: str) -> dict | None:
key = str(name).strip().lower()
for a in self._arcs.values():
if key in a["chapters"]:
return a["chapters"][key]
return None
def _merge_scene_into(self, target: dict, src: dict) -> None:
self._fill_desc(target, src)
self._append_field(target, src, "player_narration")
self._append_field(target, src, "gm_notes")
for rkey, rdict in src.get("rooms", {}).items():
target["rooms"].setdefault(rkey, rdict)
class ImportCampaignUseCase:
"""Transforme un PDF de campagne en proposition d'arbre arc→chapitre→scène."""
@@ -445,39 +191,22 @@ class ImportCampaignUseCase:
llm: LLMProvider,
extractor: PdfTextExtractor,
chunk_target_tokens: int = _CHUNK_TARGET_TOKENS,
map_concurrency: int = 1,
) -> None:
self._llm = llm
self._extractor = extractor
self._chunk_target_tokens = chunk_target_tokens
# Appels MAP par VAGUES de cette taille : l'ordre narratif est préservé
# (fusion vague par vague, dans l'ordre du livre) mais le mur d'attente
# des appels LLM est divisé d'autant. 1 = comportement séquentiel.
self._map_concurrency = max(1, map_concurrency)
async def execute(self, pdf_bytes: bytes) -> CampaignImportResult:
"""Variante non-streamée : traite tout puis renvoie l'arbre complet."""
doc = self._extractor.extract(pdf_bytes)
chunks = chunk_text(doc.full_text, self._chunk_target_tokens)
toc_block = _format_toc(doc.toc)
merger = _TreeMerger()
total = len(chunks)
for start in range(0, total, self._map_concurrency):
wave = list(enumerate(chunks))[start:start + self._map_concurrency]
results = await asyncio.gather(*(
self._map_chunk(c, index=i, total=total, toc_block=toc_block)
for i, c in wave
))
for res in results:
merger.add(res["arcs"])
merger.add_npcs(res["npcs"])
if total > 1:
await self._consolidate(merger)
for i, chunk in enumerate(chunks):
merger.add(await self._map_chunk(chunk, index=i, total=len(chunks)))
return CampaignImportResult(
arcs=merger.result(),
page_count=doc.page_count,
ocr_page_count=doc.ocr_page_count,
npcs=merger.npcs(),
)
async def stream(self, pdf_bytes: bytes):
@@ -492,12 +221,10 @@ class ImportCampaignUseCase:
doc = self._extractor.extract(pdf_bytes)
chunks = chunk_text(doc.full_text, self._chunk_target_tokens)
toc_block = _format_toc(doc.toc)
total = len(chunks)
logger.info(
"Import campagne (stream) : %s page(s) (%s via OCR), %s morceau(x), TOC %s.",
"Import campagne (stream) : %s page(s) (%s via OCR), %s morceau(x).",
doc.page_count, doc.ocr_page_count, total,
"présente" if toc_block else "absente",
)
yield {
"type": "start",
@@ -509,52 +236,38 @@ class ImportCampaignUseCase:
merger = _TreeMerger()
skipped = 0
last_error: str | None = None
done_count = 0
# PARALLÉLISME : les morceaux sont traités par VAGUES de `map_concurrency`
# appels simultanés. L'ordre narratif est préservé : la fusion se fait
# vague par vague, dans l'ordre du livre.
# RÉSILIENCE : un morceau qui échoue (provider saturé, quota, etc.) est
# SAUTÉ — on ne perd pas tout l'import pour autant. On n'abandonne que
# si AUCUN morceau ne passe (cf. après la boucle).
# HEARTBEAT : keep-alive pendant la vague d'appels LLM pour ne jamais
# laisser le flux SSE silencieux (sinon le Core coupe sur inactivité).
for start in range(0, total, self._map_concurrency):
wave = list(enumerate(chunks))[start:start + self._map_concurrency]
gathered = asyncio.gather(
*(self._map_chunk(c, index=i, total=total, toc_block=toc_block)
for i, c in wave),
return_exceptions=True,
)
results: list | None = None
async for kind, payload in with_heartbeat(gathered):
if kind == "heartbeat":
yield {"type": "heartbeat", "current": done_count + 1, "total": total}
else:
results = payload
for (i, _), res in zip(wave, results or []):
done_count += 1
if isinstance(res, LLMProviderError):
skipped += 1
last_error = str(res)
logger.warning("Morceau %s/%s ignoré (échec LLM) : %s", i + 1, total, res)
yield {"type": "chunk_failed", "current": i + 1, "total": total,
"message": str(res)[:300]}
elif isinstance(res, BaseException):
raise res # bug inattendu : ne pas l'avaler en silence
else:
merger.add((res or {}).get("arcs") or [])
merger.add_npcs((res or {}).get("npcs") or [])
arcs, chapters, scenes = merger.counts()
yield {
"type": "progress",
"current": done_count,
"total": total,
"arc_count": arcs,
"chapter_count": chapters,
"scene_count": scenes,
"npc_count": len(merger.npcs()),
"skipped": skipped,
}
for i, chunk in enumerate(chunks):
# RÉSILIENCE : un morceau qui échoue (provider saturé, quota, etc.) est
# SAUTÉ — on ne perd pas tout l'import pour autant. On n'abandonne que
# si AUCUN morceau ne passe (cf. après la boucle).
# HEARTBEAT : keep-alive pendant l'appel LLM pour ne jamais laisser le
# flux SSE silencieux (sinon le Core coupe sur timeout d'inactivité).
try:
arcs_payload: list[dict] | None = None
async for kind, payload in with_heartbeat(
self._map_chunk(chunk, index=i, total=total)
):
if kind == "heartbeat":
yield {"type": "heartbeat", "current": i + 1, "total": total}
else:
arcs_payload = payload
merger.add(arcs_payload or [])
except LLMProviderError as exc:
skipped += 1
last_error = str(exc)
logger.warning("Morceau %s/%s ignoré (échec LLM) : %s", i + 1, total, exc)
yield {"type": "chunk_failed", "current": i + 1, "total": total,
"message": str(exc)[:300]}
arcs, chapters, scenes = merger.counts()
yield {
"type": "progress",
"current": i + 1,
"total": total,
"arc_count": arcs,
"chapter_count": chapters,
"scene_count": scenes,
"skipped": skipped,
}
if total > 0 and skipped == total:
# Tout a échoué : "done" vide serait trompeur → erreur explicite.
@@ -563,115 +276,33 @@ class ImportCampaignUseCase:
f"Dernier message : {last_error or 'inconnu'}"}
return
if total > 0 and merger.counts()[0] == 0 and not merger.npcs():
# Le texte a été extrait mais le modèle n'a produit AUCUNE structure
# exploitable : sans ce signal, l'UI reçoit un `done` vide et
# l'utilisateur conclut à tort que le PDF est illisible.
yield {"type": "error",
"message": "Le texte du PDF a été extrait, mais le modèle n'a produit "
"aucune structure exploitable (réponses JSON vides ou coupées). "
"Réduisez la taille des morceaux d'import, augmentez la fenêtre "
"de contexte (num_ctx) ou essayez un autre modèle."}
return
# Consolidation finale : fusion des quasi-doublons inter-morceaux
# (best-effort, voir _consolidate). Inutile sur un import mono-morceau.
if total > 1:
yield {"type": "consolidating", "total": total}
async for kind, _ in with_heartbeat(self._consolidate(merger)):
if kind == "heartbeat":
yield {"type": "heartbeat", "current": total, "total": total}
yield {
"type": "done",
"arcs": _serialize_arcs(merger.result()),
"npcs": [{"name": n.name, "description": n.description} for n in merger.npcs()],
"page_count": doc.page_count,
"ocr_page_count": doc.ocr_page_count,
"skipped": skipped,
}
# --- Consolidation finale (fusion des quasi-doublons) ---------------------
async def _consolidate(self, merger: _TreeMerger) -> None:
"""Une passe LLM sur le squelette pour fusionner les quasi-doublons.
BEST-EFFORT : toute erreur (LLM indisponible, JSON invalide, noms
inconnus) laisse l'arbre tel quel — la consolidation ne peut qu'améliorer,
jamais bloquer un import.
"""
_, chapters, scenes = merger.counts()
if chapters + scenes < 3:
return # rien à dédoublonner sur un arbre minuscule
skeleton = merger.skeleton_text()
try:
raw = await generate_with_retry(
self._llm, _CONSOLIDATE_PROMPT.format(skeleton=skeleton),
output_format="json", temperature=0.0)
except Exception as exc: # noqa: BLE001 — best-effort STRICT : une erreur ici
# (LLM, réseau, bug) ne doit JAMAIS faire perdre un import terminé.
logger.warning("Consolidation ignorée (échec) : %s", exc)
return
parsed, _ = load_json_object(raw)
if not isinstance(parsed, dict):
logger.warning("Consolidation ignorée (réponse non-JSON).")
return
merged = 0
for cm in parsed.get("chapter_merges") or []:
if isinstance(cm, dict) and merger.merge_chapters(
str(cm.get("into") or ""), list(cm.get("merge") or [])):
merged += 1
for sm in parsed.get("scene_merges") or []:
if isinstance(sm, dict) and merger.merge_scenes(
str(sm.get("chapter") or ""), str(sm.get("into") or ""),
list(sm.get("merge") or [])):
merged += 1
if merged:
logger.info("Consolidation : %s fusion(s) de quasi-doublons appliquée(s).", merged)
# --- MAP : un morceau → sous-arbre ---------------------------------------
async def _map_chunk(
self, chunk: str, *, index: int, total: int, toc_block: str = ""
) -> dict:
"""Phase MAP d'un morceau → {"arcs": [...], "npcs": [...]}."""
return await self._extract_payload(
chunk, index=index, total=total, depth=0, toc_block=toc_block)
async def _map_chunk(self, chunk: str, *, index: int, total: int) -> list[dict]:
return await self._extract_arcs(chunk, index=index, total=total, depth=0)
async def _extract_payload(
self, text: str, *, index: int, total: int, depth: int, toc_block: str = ""
) -> dict:
"""Extrait l'arborescence + les PNJ d'un texte. Si la SORTIE est tronquée,
retraite le texte en DEUX moitiés et concatène — le `_TreeMerger` final
dédoublonne par nom (un arc/chapitre coupé entre les moitiés est recollé)."""
toc_section = _TOC_BLOCK.format(toc=toc_block) if toc_block else ""
async def _extract_arcs(
self, text: str, *, index: int, total: int, depth: int
) -> list[dict]:
"""Extrait l'arborescence d'un texte. Si la SORTIE est tronquée, retraite le
texte en DEUX moitiés et concatène — le `_TreeMerger` final dédoublonne par
nom (un arc/chapitre coupé entre les moitiés est recollé)."""
prompt = (
_MAP_SYSTEM.format(default_arc=_DEFAULT_ARC_NAME)
+ toc_section
+ f"\n\n--- EXTRAIT {index + 1}/{total} ---\n{text}\n\n"
"Renvoie maintenant le JSON de l'arborescence."
)
try:
raw = await generate_with_retry(
self._llm, prompt, output_format=_TREE_SCHEMA, temperature=_TEMPERATURE)
except LLMGenerationTimeout:
# Génération trop lente pour la taille demandée (fréquent en local /
# tier gratuit) : même remède que la troncature, deux moitiés →
# sortie 2× plus courte. Re-lever si plus découpable.
if depth >= _MAX_SPLIT_DEPTH:
raise
left, right = split_in_half(text)
if not left or not right:
raise
logger.info(
"Morceau %s : timeout de génération → re-découpage en 2 moitiés (niveau %s).",
index, depth + 1)
a = await self._extract_payload(
left, index=index, total=total, depth=depth + 1, toc_block=toc_block)
b = await self._extract_payload(
right, index=index, total=total, depth=depth + 1, toc_block=toc_block)
return {"arcs": a["arcs"] + b["arcs"], "npcs": a["npcs"] + b["npcs"]}
payload, truncated = self._parse_payload(raw, index=index)
raw = await generate_with_retry(
self._llm, prompt, output_format="json", temperature=_TEMPERATURE)
arcs, truncated = self._parse_arcs(raw, index=index)
if truncated and depth < _MAX_SPLIT_DEPTH:
left, right = split_in_half(text)
@@ -679,20 +310,17 @@ class ImportCampaignUseCase:
logger.info(
"Morceau %s : sortie tronquée → re-découpage en 2 moitiés (niveau %s).",
index, depth + 1)
a = await self._extract_payload(
left, index=index, total=total, depth=depth + 1, toc_block=toc_block)
b = await self._extract_payload(
right, index=index, total=total, depth=depth + 1, toc_block=toc_block)
return {"arcs": a["arcs"] + b["arcs"], "npcs": a["npcs"] + b["npcs"]}
a = await self._extract_arcs(left, index=index, total=total, depth=depth + 1)
b = await self._extract_arcs(right, index=index, total=total, depth=depth + 1)
return a + b
if truncated:
logger.warning(
"Morceau %s : sortie tronquée, profondeur max atteinte — partiel conservé.", index)
return payload
return arcs
@staticmethod
def _parse_payload(raw: str, *, index: int) -> tuple[dict, bool]:
"""Parse robuste → ({"arcs", "npcs"}, tronqué). `tronqué`=True si partiel."""
empty = {"arcs": [], "npcs": []}
def _parse_arcs(raw: str, *, index: int) -> tuple[list[dict], bool]:
"""Parse robuste → (arcs, tronqué). `tronqué`=True si récupération partielle."""
parsed, recovered = load_json_object(raw)
if parsed is None:
truncated = looks_like_truncated_json(raw)
@@ -701,15 +329,11 @@ class ImportCampaignUseCase:
"Morceau %s : aucun objet JSON exploitable, ignoré. "
"Début de la réponse du modèle : %r",
index, (raw or "").strip()[:300] or "(réponse VIDE)")
return empty, truncated
return [], truncated
if isinstance(parsed, dict):
arcs = parsed.get("arcs", [])
npcs = parsed.get("npcs", [])
return {
"arcs": arcs if isinstance(arcs, list) else [],
"npcs": npcs if isinstance(npcs, list) else [],
}, recovered
return empty, recovered
return (arcs if isinstance(arcs, list) else []), recovered
return [], recovered
def _serialize_arcs(arcs: list[ArcProposal]) -> list[dict]:

View File

@@ -13,7 +13,6 @@ Ne dépend que des abstractions du domaine (ports LLMProvider + PdfTextExtractor
from __future__ import annotations
import logging
import re
from app.application.chunking import CHUNK_TARGET_TOKENS, chunk_text, split_in_half
from app.application.llm_json import load_json_object, looks_like_truncated_json
@@ -26,12 +25,7 @@ from app.application.streaming import with_heartbeat
# 1-2 niveaux suffisent en pratique, le reste est un garde-fou).
_MAX_SPLIT_DEPTH = 3
from app.domain.models import RulesImportResult
from app.domain.ports import (
LLMGenerationTimeout,
LLMProvider,
LLMProviderError,
PdfTextExtractor,
)
from app.domain.ports import LLMProvider, LLMProviderError, PdfTextExtractor
logger = logging.getLogger(__name__)
@@ -40,16 +34,6 @@ logger = logging.getLogger(__name__)
# Plus la valeur est haute, plus le modèle "brode" (invente du contenu absent).
_TEMPERATURE = 0.1
# Schéma de la sortie attendue : objet PLAT {titre: markdown}. Passé tel quel à
# Ollama (structured outputs : la grammaire interdit physiquement les objets
# imbriqués, les clés "thought" à valeur non-string, le bavardage hors JSON…
# indispensable pour les petits modèles locaux qui ne suivent pas les consignes).
# Les adapters cloud le traduisent en mode JSON natif (json_object).
_SECTIONS_SCHEMA: dict = {
"type": "object",
"additionalProperties": {"type": "string"},
}
# Taxonomie canonique suggérée au modèle pour homogénéiser les titres entre
# morceaux (sinon "Combat" / "Le combat" / "Règles de combat" se dispersent).
# Le modèle reste libre d'en créer d'autres si rien ne correspond.
@@ -72,13 +56,9 @@ On te donne un EXTRAIT brut d'un PDF de règles (texte parfois mal coupé par la
Ta tâche : répartir le contenu de cet extrait dans des SECTIONS THÉMATIQUES.
Format EXACT attendu — un objet JSON plat {{titre de section: contenu markdown}} :
{{"Combat": "## Initiative\\n\\nChaque participant lance 1d20...", "Magie et sorts": "## Sorts\\n\\n..."}}
Règles impératives :
- Tu réponds UNIQUEMENT par cet objet JSON, sans texte avant ni après.
- Les CLÉS sont des titres de section (texte court). Les VALEURS sont le contenu de la règle en markdown (chaîne de caractères, jamais un objet ou une liste).
- INTERDIT : des clés génériques comme "title", "content", "sections", "thought" ou "notes" ; des objets imbriqués ; tout commentaire sur ta démarche ou ton raisonnement.
- Tu réponds UNIQUEMENT par un objet JSON valide, sans markdown ni commentaire autour.
- Les CLÉS sont des titres de section (texte court). Les VALEURS sont le contenu de la règle en markdown.
- Utilise EN PRIORITÉ ces titres canoniques quand le contenu y correspond :
{canonical}
- Si un contenu ne rentre dans aucun, crée un titre clair et concis (en français).
@@ -87,55 +67,6 @@ Règles impératives :
- N'INVENTE AUCUNE règle, ne résume pas abusivement : tu réorganises, tu ne réécris pas le fond.
- Ignore les pages de garde, sommaires, crédits, pages vides (renvoie {{}} si l'extrait n'a aucune règle)."""
# --- Mode SEGMENTATION (modèles locaux) --------------------------------------
# Réécrire tout le texte en JSON impose une SORTIE ≈ taille de l'ENTRÉE : à
# ~100 tokens/s en local, un livre = des dizaines de minutes et des troncatures
# en cascade. Ici le modèle ne renvoie que les FRONTIÈRES des sections (titre +
# premiers mots exacts) — ~200 tokens quel que soit le morceau — et c'est NOUS
# qui découpons le texte original. ~50× plus rapide, fidélité parfaite du
# contenu (texte source intact), plus de troncature possible.
_SEGMENT_SYSTEM = """Tu analyses un EXTRAIT brut d'un livre de règles de jeu de rôle.
Ta tâche : repérer où COMMENCENT les sections thématiques. Tu ne réécris RIEN.
Format EXACT attendu :
{{"sections": [{{"titre": "Combat", "debut": "Le combat se déroule en tours de"}}, ...]}}
Règles impératives :
- "debut" = les 5 à 10 PREMIERS MOTS du passage où la section commence, COPIÉS À L'IDENTIQUE
depuis l'extrait (même orthographe, même ponctuation, même langue). JAMAIS un résumé.
- La PREMIÈRE entrée commence aux tout premiers mots de l'extrait (même si le contenu
poursuit une section entamée avant cet extrait).
- Les entrées suivent l'ordre du texte. Vise des sections LARGES (un thème), pas un titre
par paragraphe : un extrait contient typiquement 1 à 6 sections.
- Titres : EN PRIORITÉ parmi :
{canonical}
sinon un titre court et clair en français.
- Pages de garde, sommaires, crédits : n'en fais pas des sections. Si l'extrait n'est que ça,
renvoie {{"sections": []}}."""
# Schéma passé à Ollama (structured outputs) : un objet {"sections": [...]}.
# Racine objet (pas tableau) car l'extraction côté Brain repère le premier {…}.
_ANCHORS_SCHEMA: dict = {
"type": "object",
"properties": {
"sections": {
"type": "array",
"items": {
"type": "object",
"properties": {
"titre": {"type": "string"},
"debut": {"type": "string"},
},
"required": ["titre", "debut"],
"additionalProperties": False,
},
},
},
"required": ["sections"],
"additionalProperties": False,
}
class _SectionMerger:
"""Fusionne les sections issues des différents morceaux, ordre préservé.
@@ -171,84 +102,6 @@ class _SectionMerger:
return {title: "\n\n".join(parts) for title, parts in self._merged.items()}
# Clés "méta" que certains modèles glissent dans le JSON (fuite de raisonnement,
# schéma title/content inventé…) : jamais des titres de section voulus.
_META_KEYS = frozenset({
"thought", "thoughts", "thinking", "reasoning", "raisonnement",
"comment", "commentaire", "commentaires", "note", "notes", "explanation",
})
def _normalize_sections(parsed: dict) -> dict:
"""Ramène les formes déviantes courantes au format attendu {titre: contenu}.
Observé sur les petits modèles locaux (gemma 12b) malgré les consignes :
- enveloppe {"sections": {...}} ou {"règles": {...}} autour du vrai contenu ;
- schéma inventé {"title": "...", "content": "...", "thought": "..."} →
une seule section dont le titre est la valeur de "title" ;
- clés méta ("thought", "notes"…) mêlées aux vraies sections → retirées.
"""
by_lower = {str(k).strip().lower(): k for k in parsed}
# Enveloppe : un unique conteneur connu dont la valeur est l'objet attendu.
if len(parsed) == 1:
only_key, only_val = next(iter(parsed.items()))
if (isinstance(only_val, dict)
and str(only_key).strip().lower() in {"sections", "règles", "regles", "rules"}):
return _normalize_sections(only_val)
# Schéma {"title": ..., "content": ...} : le titre est une VALEUR, pas une clé.
if "title" in by_lower and "content" in by_lower:
title = str(parsed[by_lower["title"]]).strip()
content = parsed[by_lower["content"]]
if title and not isinstance(content, dict):
return {title: content}
return {k: v for k, v in parsed.items()
if str(k).strip().lower() not in _META_KEYS}
def _coerce_markdown(value: object) -> str:
"""Convertit une valeur de section renvoyée par le LLM en markdown plat.
Malgré la consigne « valeurs = markdown », certains modèles nichent des
sous-sections ({titre: {sous-titre: contenu}}) ou des listes. Un `str(v)`
naïf produirait du repr Python ({'k': 'v'}) ; on aplatit récursivement à la
place pour ne perdre aucun contenu.
"""
if isinstance(value, str):
return value
if isinstance(value, dict):
parts = []
for k, v in value.items():
content = _coerce_markdown(v)
# Clé = sous-titre (cas normal) ; si la "valeur" est vide, la clé
# elle-même porte le contenu (dérive observée sur certains modèles).
parts.append(f"{k}\n\n{content}".strip() if content else str(k))
return "\n\n".join(parts)
if isinstance(value, list):
return "\n\n".join(_coerce_markdown(v) for v in value)
return "" if value is None else str(value)
def _find_anchor(text: str, anchor: str, start: int) -> int | None:
"""Position de `anchor` dans `text` à partir de `start`, ou None.
Le modèle recopie les premiers mots d'un passage, mais le texte extrait du
PDF contient des sauts de ligne/espaces multiples au même endroit, et le
modèle normalise parfois la casse. Trois passes, de la plus stricte à la
plus tolérante : exacte → espaces≈\\s+ → idem insensible à la casse."""
pos = text.find(anchor, start)
if pos != -1:
return pos
words = anchor.split()
if not words:
return None
pattern = r"\s+".join(re.escape(w) for w in words)
match = re.compile(pattern).search(text, start)
if match:
return match.start()
match = re.compile(pattern, re.IGNORECASE).search(text, start)
return match.start() if match else None
def _combine_sections(a: dict[str, str], b: dict[str, str]) -> dict[str, str]:
"""Fusionne deux dicts de sections (issus des 2 moitiés d'un morceau re-découpé).
@@ -275,16 +128,10 @@ class ImportRulesUseCase:
llm: LLMProvider,
extractor: PdfTextExtractor,
chunk_target_tokens: int = CHUNK_TARGET_TOKENS,
segment_only: bool = False,
) -> None:
"""`segment_only=True` (modèles locaux) : le LLM ne renvoie que les
frontières des sections (titre + premiers mots) et le texte original est
découpé localement — sortie minuscule, pas de réécriture. False (cloud) :
le LLM réécrit le contenu en sections markdown nettoyées."""
self._llm = llm
self._extractor = extractor
self._chunk_target_tokens = chunk_target_tokens
self._segment_only = segment_only
async def execute(self, pdf_bytes: bytes) -> RulesImportResult:
"""Variante non-streamée : traite tout puis renvoie le résultat complet."""
@@ -368,21 +215,9 @@ class ImportRulesUseCase:
f"Dernier message : {last_error or 'inconnu'}"}
return
sections = merger.result()
if total > 0 and not sections:
# Le texte a bien été extrait mais AUCUN morceau n'a produit de JSON
# exploitable (sorties coupées/illisibles). Sans ce signal, l'UI reçoit
# un `done` vide et l'utilisateur conclut à tort que le PDF est illisible.
yield {"type": "error",
"message": "Le texte du PDF a été extrait, mais le modèle n'a produit "
"aucune section exploitable (réponses JSON vides ou coupées). "
"Réduisez la taille des morceaux d'import, augmentez la fenêtre "
"de contexte (num_ctx) ou essayez un autre modèle."}
return
yield {
"type": "done",
"sections": sections,
"sections": merger.result(),
"page_count": doc.page_count,
"ocr_page_count": doc.ocr_page_count,
"skipped": skipped,
@@ -399,37 +234,16 @@ class ImportRulesUseCase:
"""Extrait les sections d'un texte. Si la SORTIE est tronquée, retraite le
texte en DEUX moitiés (chacune produit une réponse complète) et fusionne —
ainsi aucune section n'est perdue, quel que soit le plafond de sortie."""
system = _SEGMENT_SYSTEM if self._segment_only else _MAP_SYSTEM
schema = _ANCHORS_SCHEMA if self._segment_only else _SECTIONS_SCHEMA
prompt = (
system.format(
_MAP_SYSTEM.format(
canonical="\n".join(f" - {s}" for s in _CANONICAL_SECTIONS)
)
+ f"\n\n--- EXTRAIT {index + 1}/{total} ---\n{text}\n\n"
"Renvoie maintenant le JSON des sections."
)
try:
raw = await generate_with_retry(
self._llm, prompt, output_format=schema, temperature=_TEMPERATURE)
except LLMGenerationTimeout:
# Le modèle générait mais trop lentement pour réécrire tout le morceau
# dans le temps imparti (fréquent sur tier gratuit + gros morceaux).
# Même remède que la troncature : deux moitiés → sortie 2× plus courte.
if depth >= _MAX_SPLIT_DEPTH:
raise
left, right = split_in_half(text)
if not left or not right:
raise
logger.info(
"Morceau %s : timeout de génération → re-découpage en 2 moitiés (niveau %s).",
index, depth + 1)
a = await self._extract_sections(left, index=index, total=total, depth=depth + 1)
b = await self._extract_sections(right, index=index, total=total, depth=depth + 1)
return _combine_sections(a, b)
if self._segment_only:
sections, truncated = self._parse_anchors(raw, text, index=index)
else:
sections, truncated = self._parse_sections(raw, index=index)
raw = await generate_with_retry(
self._llm, prompt, output_format="json", temperature=_TEMPERATURE)
sections, truncated = self._parse_sections(raw, index=index)
if truncated and depth < _MAX_SPLIT_DEPTH:
left, right = split_in_half(text)
@@ -445,68 +259,6 @@ class ImportRulesUseCase:
"Morceau %s : sortie tronquée, profondeur max atteinte — partiel conservé.", index)
return sections
@staticmethod
def _parse_anchors(raw: str, text: str, *, index: int) -> tuple[dict[str, str], bool]:
"""Mode segmentation : réponse {"sections": [{titre, debut}, …]} → on localise
chaque `debut` dans le texte ORIGINAL et on découpe entre les ancres.
Une ancre introuvable est abandonnée (son contenu reste dans la section
précédente — aucun texte n'est perdu). Le texte avant la première ancre
trouvée est rattaché à la première section (le prompt demande au modèle de
faire démarrer la première entrée aux premiers mots de l'extrait)."""
parsed, recovered = load_json_object(raw)
if parsed is None:
truncated = looks_like_truncated_json(raw)
if not truncated:
logger.warning(
"Morceau %s : aucun objet JSON exploitable (segmentation), ignoré. "
"Début de la réponse du modèle : %r",
index, (raw or "").strip()[:300] or "(réponse VIDE)")
return {}, truncated
entries = parsed.get("sections") if isinstance(parsed, dict) else None
if not isinstance(entries, list):
logger.warning("Morceau %s : pas de liste 'sections' exploitable, ignoré.", index)
return {}, False
# Localisation séquentielle : chaque ancre est cherchée APRÈS la précédente
# (préserve l'ordre du texte, évite qu'une phrase répétée matche trop tôt).
located: list[tuple[str, int]] = []
cursor = 0
dropped = 0
for entry in entries:
if not isinstance(entry, dict):
continue
title = str(entry.get("titre") or "").strip()
anchor = str(entry.get("debut") or "").strip()
if not title or not anchor:
continue
pos = _find_anchor(text, anchor, cursor)
if pos is None:
dropped += 1
continue
located.append((title, pos))
cursor = pos + 1
if dropped:
logger.info(
"Morceau %s : %s ancre(s) de section introuvable(s) — contenu rattaché "
"à la section précédente.", index, dropped)
if not located:
return {}, False
# Découpe entre ancres ; le préambule éventuel rejoint la première section.
located[0] = (located[0][0], 0)
sections: dict[str, str] = {}
for i, (title, start) in enumerate(located):
end = located[i + 1][1] if i + 1 < len(located) else len(text)
content = text[start:end].strip()
if not content:
continue
if title in sections:
sections[title] = f"{sections[title]}\n\n{content}"
else:
sections[title] = content
return sections, recovered
@staticmethod
def _parse_sections(raw: str, *, index: int) -> tuple[dict[str, str], bool]:
"""Parse robuste → (sections, tronqué). `tronqué`=True si récupération partielle."""
@@ -524,5 +276,4 @@ class ImportRulesUseCase:
if not isinstance(parsed, dict):
logger.warning("Morceau %s : le LLM n'a pas renvoyé un objet, ignoré.", index)
return {}, False
normalized = _normalize_sections(parsed)
return {str(k): _coerce_markdown(v) for k, v in normalized.items()}, recovered
return {str(k): str(v) for k, v in parsed.items()}, recovered

View File

@@ -38,16 +38,13 @@ def load_json_object(raw: str) -> tuple[object | None, bool]:
obj = extract_json_object(raw)
if obj is not None:
try:
# strict=False : tolère les caractères de contrôle BRUTS (retours à la
# ligne non échappés…) dans les chaînes — erreur fréquente des LLM hors
# mode JSON natif, qui invalidait toute la réponse.
return json.loads(obj, strict=False), False
return json.loads(obj), False
except json.JSONDecodeError:
pass
repaired = repair_truncated_json(raw)
if repaired is not None:
try:
return json.loads(repaired, strict=False), True
return json.loads(repaired), True
except json.JSONDecodeError:
pass
return None, False
@@ -57,20 +54,12 @@ def looks_like_truncated_json(raw: str) -> bool:
"""La sortie ressemble-t-elle à un JSON COUPÉ (accolades/crochets non refermés)
plutôt qu'à de la prose ? Sert à déclencher un re-découpage même quand RIEN n'a
pu être récupéré (cas où le 1er contenu est si long qu'il est coupé avant toute
sous-structure complète).
Une réponse qui COMMENCE par `{` est jugée sur le seul équilibre des accolades,
même très courte : en mode JSON un `{"` de 2 caractères est une génération
interrompue net (contexte plein, plafond de sortie), pas de la prose — c'est le
signal de re-découpage. Pour le reste (prose contenant des accolades), on exige
un contenu substantiel pour éviter les faux positifs."""
s = _strip_reasoning(raw or "").strip()
if "{" not in s:
sous-structure complète). On exige un contenu substantiel pour éviter les
faux positifs sur une courte réponse non-JSON."""
s = (raw or "").strip()
if "{" not in s or len(s) < 100:
return False
unbalanced = s.count("{") > s.count("}") or s.count("[") > s.count("]")
if s.startswith("{"):
return unbalanced
return len(s) >= 100 and unbalanced
return s.count("{") > s.count("}") or s.count("[") > s.count("]")
def extract_json_object(raw: str) -> str | None:

View File

@@ -14,7 +14,7 @@ import asyncio
import logging
import re
from app.domain.ports import LLMGenerationTimeout, LLMProvider, LLMProviderError
from app.domain.ports import LLMProvider, LLMProviderError
logger = logging.getLogger(__name__)
@@ -60,7 +60,7 @@ async def generate_with_retry(
llm: LLMProvider,
prompt: str,
*,
output_format: str | dict | None = None,
output_format: str | None = None,
temperature: float | None = None,
) -> str:
"""Comme `llm.generate`, mais réessaie les erreurs transitoires (backoff).
@@ -74,12 +74,6 @@ async def generate_with_retry(
for attempt in range(_ATTEMPTS):
try:
return await llm.generate(prompt, output_format=output_format, temperature=temperature)
except LLMGenerationTimeout:
# Timeout de DÉBIT (génération trop lente pour la sortie demandée) :
# rejouer le même prompt re-timeoutera à l'identique — on a déjà perdu
# `timeout` secondes. On remonte tout de suite : l'appelant (import)
# sait re-découper le morceau en deux pour réduire la sortie.
raise
except LLMProviderError as exc:
last_error = exc
# Quota JOURNALIER épuisé : inutile d'insister, on remonte tout de suite

View File

@@ -9,8 +9,6 @@ from __future__ import annotations
from typing import AsyncIterator
from app.application.notebook_rag import NotebookRagUseCase
from app.application.query_rewrite import standalone_question
from app.application.rerank import pool_size, rerank
from app.domain.models import ChatMessage
from app.domain.ports import LLMChatProvider
@@ -59,13 +57,9 @@ Réponds en français, de façon utile et concise. Mets le texte explicatif AVAN
class NotebookChatUseCase:
def __init__(
self, rag: NotebookRagUseCase, llm: LLMChatProvider, rerank_enabled: bool = False
) -> None:
def __init__(self, rag: NotebookRagUseCase, llm: LLMChatProvider) -> None:
self._rag = rag
self._llm = llm
# Reranking LLM d'un pool élargi avant injection (voir app.application.rerank).
self._rerank_enabled = rerank_enabled
async def stream(
self,
@@ -73,32 +67,9 @@ class NotebookChatUseCase:
messages: list[ChatMessage],
context: str = "",
top_k: int = 6,
) -> AsyncIterator[dict]:
"""Yield des évènements : {type:'sources', sources:[…]} (une fois, avant la
réponse — transparence sur les passages utilisés), puis {type:'token', token}."""
# Question AUTONOME pour la recherche : sur une relance (« et ses
# faiblesses ? »), l'embedding du dernier message seul ne contient pas
# le sujet → on le résout depuis l'historique (best-effort, 1 appel léger,
# uniquement à partir du 2e tour). La réponse, elle, voit tout l'historique.
search_query = await standalone_question(self._llm, messages)
if self._rerank_enabled:
# Pool élargi → notation LLM → top_k final (meilleure précision sur
# les questions ambiguës, au prix d'un appel avant le premier token).
pool = await self._rag.retrieve(
source_ids, search_query, top_k=pool_size(top_k))
passages = await rerank(self._llm, search_query, pool, top_k)
else:
passages = await self._rag.retrieve(source_ids, search_query, top_k=top_k)
# Évènement 'sources' AVANT le premier token : l'UI peut afficher les
# pages utilisées (« 📖 p. 12, 47 ») dès le début de la réponse.
yield {"type": "sources", "sources": [
{
"source_id": p.get("source_id"),
"page": p.get("page"),
"score": round(float(p.get("score") or 0.0), 3),
}
for p in passages
]}
) -> AsyncIterator[str]:
last_user = next((m.content for m in reversed(messages) if m.role == "user"), "")
passages = await self._rag.retrieve(source_ids, last_user, top_k=top_k)
sources_block = (
"\n\n".join(self._format_passage(p) for p in passages)
if passages else "(aucun passage pertinent trouvé dans les sources)"
@@ -110,7 +81,7 @@ class NotebookChatUseCase:
system_prompt = _SYSTEM_PROMPT.format(
context_block=context_block, sources_block=sources_block)
async for token in self._llm.stream_chat(messages, system_prompt=system_prompt):
yield {"type": "token", "token": token}
yield token
@staticmethod
def _format_passage(p: dict) -> str:

View File

@@ -13,14 +13,12 @@ lots ; avec un petit modèle local, plus de lots (mais ça reste exhaustif).
"""
from __future__ import annotations
import asyncio
import logging
from typing import AsyncIterator
import tiktoken
from app.application.llm_retry import generate_with_retry
from app.application.query_rewrite import standalone_question
from app.domain.models import ChatMessage
from app.domain.ports import LLMChatProvider, LLMProvider, LLMProviderError
from app.infrastructure import vector_store
@@ -30,28 +28,6 @@ logger = logging.getLogger(__name__)
_NO_MATCH = "RAS"
_MAP_TEMPERATURE = 0.2
# --- Index de résumés (pré-filtrage des lots) --------------------------------
# Sans index : CHAQUE question relit TOUT le document (1 appel LLM par lot).
# Avec : les résumés de lots (construits UNE fois, cache disque) sont comparés
# à la question par embedding, et seuls les lots plausiblement pertinents sont
# relus. Sélection volontairement CONSERVATRICE (on préfère relire un lot de
# trop que rater une mention) ; désactivable via deep_summary_filter=False.
_SUMMARY_PROMPT = """Résume l'EXTRAIT ci-dessous en 4 à 8 puces factuelles : lieux, PNJ et
créatures nommés, objets notables, évènements, règles particulières. Pas d'analyse, pas
d'introduction — uniquement les puces, pour servir d'index de recherche.
--- EXTRAIT ---
{excerpt}
--- FIN EXTRAIT ---
Résumé :"""
# Un lot est gardé si son score est proche du meilleur (marge) OU bon dans
# l'absolu ; et on garde toujours au moins _MIN_KEPT lots.
_SELECT_MARGIN = 0.10
_SELECT_FLOOR = 0.5
_MIN_KEPT = 3
_MAP_PROMPT = """Voici un EXTRAIT d'un document. Extrais UNIQUEMENT les informations
pertinentes pour répondre à la question ci-dessous. Conserve les détails utiles et
indique les numéros de page (format « p. X »). Si l'extrait ne contient RIEN de
@@ -86,22 +62,9 @@ Réponds en français."""
class NotebookDeepUseCase:
def __init__(
self,
llm: LLMProvider,
batch_tokens: int = 10000,
map_concurrency: int = 1,
embedder=None,
summary_filter: bool = True,
) -> None:
def __init__(self, llm: LLMProvider, batch_tokens: int = 10000) -> None:
self._llm = llm
self._batch_tokens = max(2000, batch_tokens)
# Lots MAP traités par vagues de cette taille (parallélisme LLM).
self._map_concurrency = max(1, map_concurrency)
# EmbeddingProvider (duck typing) pour l'index de résumés ; None = pas
# de pré-filtrage (plein scan, comportement historique).
self._embedder = embedder
self._summary_filter = summary_filter
async def stream(
self,
@@ -117,57 +80,33 @@ class NotebookDeepUseCase:
SYNTHÈSE (reduce) reçoit les `history_limit` derniers messages → les relances
conversationnelles (« et pour les autres ? ») fonctionnent aussi en approfondi.
"""
# Question autonome : la phase MAP lit chaque lot avec LA question — sur
# une relance conversationnelle, il faut y résoudre les références
# implicites, sinon les lots sont filtrés sur un texte sans sujet.
question = await standalone_question(self._llm, messages)
# Lots PAR SOURCE (l'index de résumés est caché par source).
per_source: list[tuple[str, list[dict]]] = []
question = next((m.content for m in reversed(messages) if m.role == "user"), "")
chunks: list[dict] = []
for sid in source_ids:
chunks = vector_store.all_chunks(sid)
for batch in self._group(chunks):
per_source.append((sid, batch))
if not per_source:
chunks.extend(vector_store.all_chunks(sid))
if not chunks:
yield {"type": "token", "token": "Aucune source indexée à analyser."}
yield {"type": "done"}
return
# Pré-filtrage par index de résumés (best-effort : tout échec → plein scan).
selected: set[int] | None = None
if self._summary_filter and self._embedder is not None:
try:
async for ev_or_result in self._select_batches(per_source, question):
if isinstance(ev_or_result, dict):
yield ev_or_result # progress de construction de l'index
else:
selected = ev_or_result
except Exception as exc: # noqa: BLE001 — le filtre ne doit jamais bloquer
logger.warning("Index de résumés ignoré (échec) : %s", exc)
selected = None
if selected is not None:
logger.info(
"Analyse approfondie : %s/%s lot(s) retenus via l'index de résumés.",
len(selected), len(per_source))
indices = sorted(selected) if selected is not None else list(range(len(per_source)))
total = len(indices)
batches = self._group(chunks)
total = len(batches)
notes: list[str] = []
# Lots traités par VAGUES parallèles ; les notes restent dans l'ordre du
# document (gather préserve l'ordre des tâches de la vague).
for start in range(0, total, self._map_concurrency):
yield {"type": "progress", "current": start, "total": total}
wave = indices[start:start + self._map_concurrency]
results = await asyncio.gather(
*(self._map_batch(question, per_source[i][1]) for i in wave),
return_exceptions=True)
for j, res in enumerate(results):
if isinstance(res, LLMProviderError):
logger.warning(
"Analyse approfondie : lot %s/%s ignoré : %s", start + j + 1, total, res)
elif isinstance(res, BaseException):
raise res # bug inattendu : ne pas l'avaler
elif res:
notes.append(res)
for i, batch in enumerate(batches):
yield {"type": "progress", "current": i, "total": total}
excerpt = "\n\n".join(
f"(p. {c['page']}) {c['text'].strip()}" if c.get("page") else c["text"].strip()
for c in batch
)
prompt = _MAP_PROMPT.format(no_match=_NO_MATCH, question=question, excerpt=excerpt)
try:
raw = await generate_with_retry(self._llm, prompt, temperature=_MAP_TEMPERATURE)
except LLMProviderError as exc:
logger.warning("Analyse approfondie : lot %s/%s ignoré : %s", i + 1, total, exc)
continue
answer = raw.strip()
if answer and answer.upper().rstrip(".") != _NO_MATCH:
notes.append(answer)
yield {"type": "progress", "current": total, "total": total}
notes_block = "\n\n".join(notes) if notes else "(aucune information pertinente trouvée dans le document)"
@@ -197,76 +136,6 @@ class NotebookDeepUseCase:
)}
yield {"type": "done"}
# --- Index de résumés ------------------------------------------------------
async def _select_batches(self, per_source: list[tuple[str, list[dict]]], question: str):
"""Générateur : yield des évènements `progress` pendant la construction de
l'index (1ère analyse d'une source), puis le set des indices retenus —
ou None si le filtre n'apporte rien (tous retenus)."""
# 1. Charge/construit les résumés par source (cache disque).
by_sid: dict[str, list[int]] = {}
for i, (sid, _) in enumerate(per_source):
by_sid.setdefault(sid, []).append(i)
vectors: list[list[float] | None] = [None] * len(per_source)
to_build = []
for sid, idxs in by_sid.items():
cached = vector_store.load_summaries(sid, self._batch_tokens)
if cached is not None and len(cached) == len(idxs):
for i, entry in zip(idxs, cached):
vectors[i] = entry.get("vector")
else:
to_build.append((sid, idxs))
total_build = sum(len(idxs) for _, idxs in to_build)
done_build = 0
for sid, idxs in to_build:
summaries: list[str] = []
for start in range(0, len(idxs), self._map_concurrency):
yield {"type": "progress", "current": done_build, "total": total_build}
wave = idxs[start:start + self._map_concurrency]
results = await asyncio.gather(
*(self._summarize_batch(per_source[i][1]) for i in wave))
summaries.extend(results)
done_build += len(wave)
vecs = await self._embedder.embed(summaries, kind="document")
entries = [{"summary": s, "vector": v} for s, v in zip(summaries, vecs)]
vector_store.save_summaries(sid, self._batch_tokens, entries)
for i, entry in zip(idxs, entries):
vectors[i] = entry["vector"]
# 2. Score de chaque lot face à la question, sélection conservatrice.
qv = (await self._embedder.embed([question], kind="query"))[0]
scores = [
vector_store.cosine_similarity(qv, v) if v else 0.0
for v in vectors
]
best = max(scores)
keep = {i for i, s in enumerate(scores) if s >= best - _SELECT_MARGIN or s >= _SELECT_FLOOR}
floor = min(_MIN_KEPT, len(scores))
if len(keep) < floor:
keep = set(sorted(range(len(scores)), key=lambda i: -scores[i])[:floor])
yield keep if len(keep) < len(scores) else None
async def _summarize_batch(self, batch: list[dict]) -> str:
excerpt = "\n\n".join(c.get("text", "").strip() for c in batch)
raw = await generate_with_retry(
self._llm, _SUMMARY_PROMPT.format(excerpt=excerpt), temperature=_MAP_TEMPERATURE)
return (raw or "").strip()
async def _map_batch(self, question: str, batch: list[dict]) -> str:
"""Phase MAP d'un lot : extrait les infos pertinentes ('' si RAS)."""
excerpt = "\n\n".join(
f"(p. {c['page']}) {c['text'].strip()}" if c.get("page") else c["text"].strip()
for c in batch
)
prompt = _MAP_PROMPT.format(no_match=_NO_MATCH, question=question, excerpt=excerpt)
raw = await generate_with_retry(self._llm, prompt, temperature=_MAP_TEMPERATURE)
answer = raw.strip()
if answer and answer.upper().rstrip(".") != _NO_MATCH:
return answer
return ""
def _group(self, chunks: list[dict]) -> list[list[dict]]:
"""Regroupe les extraits en lots ~`batch_tokens` (compte tiktoken)."""
enc = tiktoken.get_encoding("cl100k_base")

View File

@@ -20,9 +20,6 @@ from app.infrastructure import vector_store
logger = logging.getLogger(__name__)
_RAG_CHUNK_TOKENS = 600
# Recouvrement entre extraits consécutifs (~13% de la cible) : une phrase-clé à
# cheval sur deux extraits reste retrouvable dans au moins l'un des deux.
_RAG_OVERLAP_TOKENS = 80
# Un extrait avec quasi aucun texte réel (en-tête/pied de page, fragment de numéro
# de page isolé « 249 250 ») ne sert à rien en RAG → on l'écarte. Seuil bas et
# conservateur : on ne coupe QUE les fragments quasi-vides, jamais une vraie phrase.
@@ -39,14 +36,10 @@ class NotebookRagUseCase:
extractor: PdfTextExtractor,
embedder: EmbeddingProvider,
chunk_target_tokens: int = _RAG_CHUNK_TOKENS,
min_score: float = 0.0,
) -> None:
self._extractor = extractor
self._embedder = embedder
self._chunk_target_tokens = chunk_target_tokens
# Cosinus minimal pour qu'un extrait soit injecté dans le prompt : sous ce
# seuil, l'extrait n'a aucun rapport avec la question → bruit. 0 = désactivé.
self._min_score = min_score
async def index_source(self, source_id: str, pdf_bytes: bytes) -> dict:
"""Extrait, découpe PAR PAGE (pour garder le n° de page → citations), embed
@@ -55,9 +48,7 @@ class NotebookRagUseCase:
chunks: list[str] = []
pages: list[int] = []
for page in doc.pages:
for piece in chunk_text(
page.text, self._chunk_target_tokens, overlap_tokens=_RAG_OVERLAP_TOKENS
):
for piece in chunk_text(page.text, self._chunk_target_tokens):
if not _has_enough_text(piece):
continue # fragment quasi-vide (en-tête/pied/numéro) → ignoré
chunks.append(piece)
@@ -78,17 +69,11 @@ class NotebookRagUseCase:
}
async def retrieve(self, source_ids: list[str], query: str, top_k: int = 6) -> list[dict]:
"""Passages les plus pertinents (toutes sources) pour `query`.
Recherche hybride (cosinus + bonus lexical sur les mots de la question) ;
peut renvoyer moins de `top_k` passages si le seuil de pertinence écarte
les extraits hors-sujet."""
"""Passages les plus pertinents (toutes sources) pour `query`."""
ids = [s for s in source_ids if vector_store.exists(s)]
if not ids or not query.strip():
return []
query_vectors = await self._embedder.embed([query], kind="query")
query_vectors = await self._embedder.embed([query])
if not query_vectors:
return []
return vector_store.search(
ids, query_vectors[0], top_k, query_text=query, min_score=self._min_score
)
return vector_store.search(ids, query_vectors[0], top_k)

View File

@@ -1,66 +0,0 @@
"""Réécriture de la question courante en question AUTONOME (chat des ateliers).
Problème : le retrieval (embedding) et la phase MAP de l'analyse approfondie ne
voient que le DERNIER message. Une relance comme « et ses faiblesses ? » ne
contient pas le sujet (Strahd) → recherche aveugle. La parade standard
(conversational query rewriting) : un appel LLM léger condense la conversation
en une question autonome, utilisée UNIQUEMENT pour la recherche — la réponse
finale, elle, voit toujours l'historique complet.
"""
from __future__ import annotations
import logging
from app.domain.models import ChatMessage
logger = logging.getLogger(__name__)
# Nombre de messages récents fournis au réécrivain (assez pour résoudre les
# pronoms, pas plus — la latence de cet appel doit rester négligeable).
_MAX_HISTORY = 6
# Garde-fou : une « question » réécrite anormalement longue est suspecte (le
# modèle a divagué) → on retombe sur la question brute.
_MAX_REWRITE_CHARS = 400
_REWRITE_PROMPT = """Voici la fin d'une conversation entre un Maître de Jeu et son assistant.
Réécris le DERNIER message de l'utilisateur en une question AUTONOME et complète :
remplace les pronoms et références implicites (« il », « ses », « ce lieu », « et pour
les autres ? ») par ce qu'ils désignent dans la conversation.
Règles :
- Réponds UNIQUEMENT par la question réécrite, sans guillemets ni préfixe.
- Conserve la langue et l'intention d'origine. N'ajoute RIEN qui n'est pas demandé.
- Si le dernier message est déjà autonome, recopie-le tel quel.
--- CONVERSATION ---
{conversation}
--- FIN ---
Question autonome :"""
async def standalone_question(llm, messages: list[ChatMessage]) -> str:
"""Condense `messages` en une question autonome pour la RECHERCHE.
Best-effort : premier message de la conversation, échec LLM ou réponse
suspecte → on renvoie simplement la dernière question brute (comportement
historique). `llm` doit exposer `generate()` (duck typing des adapters).
"""
last_user = next((m.content for m in reversed(messages) if m.role == "user"), "")
user_turns = sum(1 for m in messages if m.role == "user" and m.content.strip())
if user_turns <= 1 or not last_user.strip():
return last_user # pas d'historique à résoudre → appel LLM inutile
recent = [m for m in messages if m.content.strip()][-_MAX_HISTORY:]
conversation = "\n".join(f"{m.role.upper()}: {m.content.strip()}" for m in recent)
try:
raw = await llm.generate(
_REWRITE_PROMPT.format(conversation=conversation), temperature=0.0)
except Exception as exc: # noqa: BLE001 — la recherche dégradée vaut mieux que pas de réponse
logger.warning("Réécriture de question ignorée (échec LLM) : %s", exc)
return last_user
rewritten = (raw or "").strip().strip('"').strip()
if not rewritten or len(rewritten) > _MAX_REWRITE_CHARS:
return last_user
return rewritten

View File

@@ -1,73 +0,0 @@
"""Reranking LLM des passages RAG (chat des ateliers).
Le cosinus classe par similarité de SURFACE ; sur les questions ambiguës, des
passages proches lexicalement mais inutiles passent devant l'extrait qui répond
vraiment. Le reranking récupère un POOL élargi (ex. 3× top_k), fait noter la
pertinence de chaque extrait par le LLM en UN appel, et garde les top_k mieux
notés. Coût : ~1 appel LLM avant le premier token — opt-in via RAG_RERANK.
"""
from __future__ import annotations
import logging
from app.application.llm_json import load_json_object
logger = logging.getLogger(__name__)
# Taille du pool élargi : multiple du top_k demandé, plafonné (le prompt de
# notation doit rester raisonnable même avec rag_top_k élevé).
POOL_FACTOR = 3
POOL_MAX = 24
# Un extrait long n'a pas besoin d'être noté en entier : tronquer borne le
# prompt sans changer le jugement de pertinence.
_EXCERPT_CHARS = 600
_RERANK_PROMPT = """Tu évalues la PERTINENCE d'extraits d'un document pour répondre à une question.
Note chaque extrait de 0 (sans rapport) à 10 (répond directement), indépendamment des autres.
QUESTION : {question}
{passages}
Réponds UNIQUEMENT par un objet JSON : {{"scores": [note_extrait_1, note_extrait_2, ...]}}
Le tableau doit contenir EXACTEMENT {count} notes, dans l'ordre des extraits."""
def pool_size(top_k: int) -> int:
"""Taille du pool à récupérer avant reranking."""
return min(max(top_k * POOL_FACTOR, top_k), POOL_MAX)
async def rerank(llm, question: str, passages: list[dict], top_k: int) -> list[dict]:
"""Renvoie les `top_k` passages les mieux notés par le LLM (tri stable :
à note égale, l'ordre cosinus d'origine est préservé).
BEST-EFFORT : échec LLM, JSON invalide ou nombre de notes incohérent →
on renvoie simplement les `top_k` premiers du classement cosinus.
"""
if len(passages) <= top_k:
return passages
numbered = "\n\n".join(
f"--- EXTRAIT {i + 1} ---\n{(p.get('text') or '')[:_EXCERPT_CHARS]}"
for i, p in enumerate(passages)
)
prompt = _RERANK_PROMPT.format(
question=question, passages=numbered, count=len(passages))
try:
raw = await llm.generate(prompt, temperature=0.0)
except Exception as exc: # noqa: BLE001 — un chat dégradé vaut mieux que pas de chat
logger.warning("Reranking ignoré (échec LLM) : %s", exc)
return passages[:top_k]
parsed, _ = load_json_object(raw)
scores = parsed.get("scores") if isinstance(parsed, dict) else None
if not isinstance(scores, list) or len(scores) != len(passages):
logger.warning("Reranking ignoré (notes inexploitables).")
return passages[:top_k]
try:
scored = [(float(s), i) for i, s in enumerate(scores)]
except (TypeError, ValueError):
logger.warning("Reranking ignoré (notes non numériques).")
return passages[:top_k]
order = sorted(range(len(passages)), key=lambda i: (-scored[i][0], i))
return [passages[i] for i in order[:top_k]]

View File

@@ -83,32 +83,6 @@ class Settings(BaseSettings):
# mais prompt plus long. 8 par défaut (montable jusqu'à ~20 sur grand contexte).
rag_top_k: int = 8
# Analyse approfondie : pré-filtrage des lots via un index de résumés
# (construit une fois par source, cache disque). Les questions ciblées ne
# relisent que les lots plausiblement pertinents (3-5x moins d'appels) ;
# False = relire TOUT le document à chaque question (exhaustivité maximale).
deep_summary_filter: bool = True
# Reranking LLM du chat atelier : recupere un pool elargi (3x top_k, max 24)
# puis fait NOTER la pertinence de chaque extrait par le LLM avant d'injecter
# les top_k meilleurs. Meilleure precision sur les questions ambigues, MAIS
# +1 appel LLM avant le premier token (quelques secondes sur un petit modele
# local). Desactive par defaut ; recommande avec un provider cloud rapide.
rag_rerank: bool = False
# Cosinus minimal pour qu'un extrait soit injecté dans le prompt du chat
# atelier : en dessous, l'extrait n'a aucun rapport avec la question → mieux
# vaut moins d'extraits que du bruit. Défaut conservateur (0.30) : les paires
# pertinentes scorent typiquement 0.6+ avec nomic-embed-text/mistral-embed,
# les hors-sujet 0.2-0.4. Montable à ~0.4 si trop de bruit, 0 = désactivé.
rag_min_score: float = 0.30
# Nombre d'appels LLM MAP menes EN PARALLELE (import de campagne, analyse
# approfondie). 3 = bon defaut cloud (divise le temps d'un gros livre par ~3).
# Ollama local sequence les requetes de toute facon (pas de gain, pas de mal).
# Baisser a 1 si un provider gratuit rate-limite agressivement.
llm_map_concurrency: int = 3
# Taille cible d'un morceau (en tokens) pour l'import de PDF (regles/campagne).
# Plus c'est gros, moins il y a de morceaux => moins de fragmentation et un
# import plus rapide, MAIS il faut que ca tienne dans la fenetre du modele.

View File

@@ -320,26 +320,11 @@ class ExtractedPage:
used_ocr: bool
@dataclass(frozen=True)
class TocEntry:
"""Une entrée de la table des matières (bookmarks/outline) du PDF.
`level` : profondeur 1-based (1 = chapitre, 2 = section…). `page` : 1-based.
"""
level: int
title: str
page: int
@dataclass(frozen=True)
class ExtractedDocument:
"""Résultat brut de l'extraction d'un PDF : une entrée par page."""
pages: list[ExtractedPage]
# Table des matières (bookmarks PDF). Vide si le PDF n'en a pas — fréquent
# pour les scans ; les livres born-digital en ont presque toujours une.
toc: list[TocEntry] = field(default_factory=list)
@property
def page_count(self) -> int:
@@ -424,18 +409,6 @@ class ArcProposal:
chapters: list[ChapterProposal] = field(default_factory=list)
@dataclass(frozen=True)
class NpcImportProposal:
"""PNJ/créature notable détecté à l'import d'un PDF de campagne.
PNJ NOMMÉS et créatures uniques (boss) — pas les monstres génériques.
`description` = courte fiche (rôle, apparence, motivations, où on le croise).
"""
name: str
description: str = ""
@dataclass(frozen=True)
class CampaignImportResult:
"""Proposition d'arborescence narrative extraite d'un PDF de campagne.
@@ -447,9 +420,6 @@ class CampaignImportResult:
arcs: list[ArcProposal]
page_count: int
ocr_page_count: int
# PNJ/créatures notables détectés au fil des morceaux (proposition, à cocher
# dans l'écran de revue avant création).
npcs: list[NpcImportProposal] = field(default_factory=list)
def counts(self) -> tuple[int, int, int]:
"""(nb arcs, nb chapitres, nb scènes) — pour le diagnostic / la progression."""

View File

@@ -24,20 +24,17 @@ class LLMProvider(Protocol):
self,
prompt: str,
*,
output_format: str | dict | None = None,
output_format: str | None = None,
temperature: float | None = None,
) -> str:
"""Génère une réponse textuelle à partir d'un prompt donné.
Args:
prompt: le texte envoyé au modèle.
output_format: contrainte de format optionnelle. "json" pour forcer
un JSON valide ; un dict = SCHÉMA JSON décrivant la structure
attendue (les fournisseurs qui supportent les sorties
structurées — ex. Ollama — contraignent la génération au schéma,
les autres retombent sur leur mode JSON natif). Les fournisseurs
qui ne supportent pas une valeur donnée doivent l'ignorer
silencieusement ou la traduire au mieux.
output_format: contrainte de format optionnelle. Exemple : "json"
pour forcer le modèle à renvoyer du JSON valide. Les
fournisseurs qui ne supportent pas une valeur donnée doivent
l'ignorer silencieusement ou la traduire au mieux.
temperature: créativité du modèle, 0.0 (déterministe/factuel) à
1.0+ (très créatif, hallucine plus facilement). None =
valeur par défaut de l'adapter. Recommandation LoreMind :
@@ -116,14 +113,3 @@ class LLMProviderError(Exception):
Définie dans le domaine (pas dans l'infra) pour que les couches
supérieures puissent l'attraper sans connaître l'adapter concret.
"""
class LLMGenerationTimeout(LLMProviderError):
"""La génération a démarré mais n'a pas FINI dans le temps imparti.
Cas distinct d'un échec transitoire (file d'attente, 503) : le modèle
produisait des tokens mais trop lentement pour la taille de sortie demandée.
Réessayer à l'identique est inutile (même entrée → même lenteur) ; la bonne
réaction est de RÉDUIRE la sortie demandée (ex. import : re-découper le
morceau en deux moitiés).
"""

View File

@@ -20,7 +20,7 @@ import httpx
from app.core.config import Settings
from app.domain.models import ChatMessage
from app.domain.ports import LLMGenerationTimeout, LLMProviderError
from app.domain.ports import LLMProviderError
logger = logging.getLogger(__name__)
@@ -95,7 +95,7 @@ class GeminiLLMProvider:
try:
return await asyncio.wait_for(_collect(), timeout=self._timeout)
except asyncio.TimeoutError as exc:
raise LLMGenerationTimeout(
raise LLMProviderError(
f"Erreur Gemini : génération non terminée en {self._timeout}s. Réduisez la "
"taille des morceaux d'import ou augmentez le timeout."
) from exc
@@ -130,12 +130,6 @@ class GeminiLLMProvider:
}
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:

View File

@@ -20,7 +20,7 @@ import httpx
from app.core.config import Settings
from app.domain.models import ChatMessage
from app.domain.ports import LLMGenerationTimeout, LLMProviderError
from app.domain.ports import LLMProviderError
logger = logging.getLogger(__name__)
@@ -101,7 +101,7 @@ class MistralLLMProvider:
try:
return await asyncio.wait_for(_collect(), timeout=self._timeout)
except asyncio.TimeoutError as exc:
raise LLMGenerationTimeout(
raise LLMProviderError(
f"Erreur Mistral : génération non terminée en {self._timeout}s. Réduisez la "
"taille des morceaux d'import, augmentez le timeout, ou changez de modèle."
) from exc
@@ -136,13 +136,6 @@ class MistralLLMProvider:
}
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:

View File

@@ -28,8 +28,7 @@ class MistralEmbeddingProvider:
self._model = settings.mistral_embedding_model
self._timeout = settings.llm_timeout_seconds
async def embed(self, texts: list[str], kind: str = "document") -> list[list[float]]:
# `kind` ignoré : mistral-embed n'utilise pas de préfixe de tâche.
async def embed(self, texts: list[str]) -> list[list[float]]:
if not texts:
return []
out: list[list[float]] = []

View File

@@ -5,16 +5,13 @@ Isole le reste de l'application des spécificités du protocole Ollama
demain, on écrit un nouvel adapter sans toucher au reste du code.
"""
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__)
from app.domain.ports import LLMProviderError
class OllamaLLMProvider:
@@ -48,7 +45,7 @@ class OllamaLLMProvider:
self,
prompt: str,
*,
output_format: str | dict | None = None,
output_format: str | None = None,
temperature: float | None = None,
) -> str:
url = f"{self._base_url}/api/generate"
@@ -58,10 +55,6 @@ class OllamaLLMProvider:
"stream": False,
"options": self._build_options(temperature),
}
# "json" (mode JSON simple) ou un SCHÉMA JSON complet (structured outputs) :
# Ollama contraint alors la grammaire de génération au schéma — un petit
# modèle local ne PEUT physiquement plus produire d'objets imbriqués, de
# clés "thought" bavardes ou de texte hors JSON.
if output_format is not None:
payload["format"] = output_format
@@ -78,45 +71,12 @@ class OllamaLLMProvider:
raise LLMProviderError(
f"Ollama HTTP {response.status_code} : {err_msg.strip()[:500]}"
)
except httpx.ConnectTimeout as exc:
# Serveur injoignable : erreur d'infrastructure, pas de lenteur.
raise LLMProviderError(
f"Erreur lors de l'appel à Ollama : {exc}"
) from exc
except httpx.TimeoutException as exc:
# `stream: False` → le read-timeout court jusqu'à la réponse COMPLÈTE,
# donc le dépasser = génération trop lente pour la sortie demandée
# (fréquent : modèle local modeste + gros morceau d'import à réécrire).
# Type dédié → pas de retry à l'identique ; l'import re-découpe le
# morceau en deux moitiés (sortie 2× plus courte) à la place.
raise LLMGenerationTimeout(
f"Erreur Ollama : génération non terminée en {self._timeout}s. Réduisez "
"la taille des morceaux d'import, augmentez le timeout, ou utilisez un "
"modèle plus rapide."
) from exc
except httpx.HTTPError as exc:
raise LLMProviderError(
f"Erreur lors de l'appel à Ollama : {exc}"
) from exc
data = response.json()
# Diagnostic crucial pour les imports : `done_reason` != "stop" signifie que
# la génération a été INTERROMPUE (fenêtre de contexte pleine, num_predict…)
# et non terminée par le modèle. Sans ce log, on ne voit qu'un JSON coupé
# en aval, sans la cause. `prompt_eval_count` révèle aussi la VRAIE taille
# du prompt en tokens du modèle (les morceaux sont mesurés en tokens
# cl100k, ~20-40% plus compacts que les tokenizers locaux).
done_reason = data.get("done_reason")
if done_reason and done_reason != "stop":
logger.warning(
"Ollama a interrompu la génération (done_reason=%s) : prompt=%s tokens, "
"sortie=%s tokens, num_ctx demandé=%s. Si prompt+sortie ≈ num_ctx, la "
"fenêtre de contexte est pleine : réduisez la taille des morceaux "
"d'import ou augmentez num_ctx (Paramètres).",
done_reason, data.get("prompt_eval_count"),
data.get("eval_count"), self._num_ctx,
)
return data["response"]
return response.json()["response"]
async def stream_chat(
self,

View File

@@ -11,14 +11,6 @@ from app.application.embeddings import EmbeddingError
from app.core.config import Settings
# Préfixes de tâche des modèles nomic-embed : le modèle est ENTRAÎNÉ avec
# (search_document pour le corpus, search_query pour la question). Sans eux,
# la pertinence du retrieval est mesurablement dégradée. Ne s'applique qu'aux
# modèles nomic — les autres (mxbai, bge…) ont leurs propres conventions ou
# aucune ; on reste neutre pour eux.
_NOMIC_PREFIXES = {"document": "search_document: ", "query": "search_query: "}
class OllamaEmbeddingProvider:
"""Implémente EmbeddingProvider via Ollama /api/embed (batch)."""
@@ -27,22 +19,11 @@ class OllamaEmbeddingProvider:
self._model = settings.ollama_embedding_model
self._timeout = settings.llm_timeout_seconds
def _prepare(self, texts: list[str], kind: str) -> list[str]:
"""Applique le préfixe de tâche si le modèle est de la famille nomic-embed.
NB : les sources indexées AVANT l'introduction des préfixes doivent être
ré-uploadées pour que documents et questions vivent dans le même espace.
"""
if "nomic-embed" not in self._model:
return texts
prefix = _NOMIC_PREFIXES.get(kind, _NOMIC_PREFIXES["document"])
return [prefix + t for t in texts]
async def embed(self, texts: list[str], kind: str = "document") -> list[list[float]]:
async def embed(self, texts: list[str]) -> list[list[float]]:
if not texts:
return []
url = f"{self._base_url}/api/embed"
payload = {"model": self._model, "input": self._prepare(texts, kind)}
payload = {"model": self._model, "input": texts}
async with httpx.AsyncClient(timeout=self._timeout) as client:
try:
response = await client.post(url, json=payload)

View File

@@ -1,51 +0,0 @@
"""Auto-installation du modèle d'embeddings Ollama au démarrage du Brain.
Adapter d'infrastructure : parle directement à l'API HTTP d'Ollama. Best-effort
(Ollama peut être absent / la connexion limitée) — n'empêche jamais le démarrage.
"""
from __future__ import annotations
import asyncio
import logging
import httpx
logger = logging.getLogger(__name__)
async def ensure_ollama_embedding_model(base_url: str, model: str) -> None:
"""Télécharge `model` sur le serveur Ollama s'il n'y est pas déjà.
Attend qu'Ollama soit joignable (ordre de démarrage des conteneurs), puis
vérifie la présence du modèle avant de le tirer.
"""
for attempt in range(10):
try:
async with httpx.AsyncClient(timeout=10) as client:
tags = await client.get(f"{base_url}/api/tags")
tags.raise_for_status()
names = [m.get("name", "") for m in tags.json().get("models", [])]
if any(n == model or n.startswith(model + ":") for n in names):
logger.info("Modèle d'embedding '%s' déjà présent.", model)
return
break # Ollama joignable, modèle absent → on tire (ci-dessous)
except httpx.HTTPError:
await asyncio.sleep(min(5 * (attempt + 1), 30))
else:
logger.warning(
"Ollama injoignable au démarrage — modèle d'embedding '%s' non auto-installé "
"(il sera tirable manuellement : ollama pull %s).", model, model)
return
logger.info("Téléchargement automatique du modèle d'embedding '%s'", model)
try:
async with httpx.AsyncClient(timeout=None) as client:
async with client.stream("POST", f"{base_url}/api/pull", json={"name": model}) as resp:
resp.raise_for_status()
async for _line in resp.aiter_lines():
pass # on draine la progression NDJSON jusqu'à la fin
logger.info("Modèle d'embedding '%s' prêt.", model)
except httpx.HTTPError as exc:
logger.warning(
"Auto-installation du modèle d'embedding '%s' échouée : %s "
"(tirage manuel possible : ollama pull %s).", model, exc, model)

View File

@@ -22,7 +22,7 @@ from app.core.config import Settings
from app.domain.models import ChatMessage
logger = logging.getLogger(__name__)
from app.domain.ports import LLMGenerationTimeout, LLMProviderError
from app.domain.ports import LLMProviderError
_API_URL = "https://openrouter.ai/api/v1/chat/completions"
@@ -113,7 +113,7 @@ class OpenRouterLLMProvider:
try:
return await asyncio.wait_for(_collect(), timeout=self._timeout)
except asyncio.TimeoutError as exc:
raise LLMGenerationTimeout(
raise LLMProviderError(
f"Erreur {provider} : génération non terminée en {self._timeout}s. Réduisez la "
"taille des morceaux d'import, augmentez le timeout, ou changez de modèle."
) from exc

View File

@@ -22,7 +22,7 @@ import pymupdf as fitz # PyMuPDF — on importe par le nom canonique `pymupdf`
# (et NON `import fitz`) pour éviter la collision avec le faux paquet PyPI "fitz"
# qui échoue sur `from frontend import *`.
from app.domain.models import ExtractedDocument, ExtractedPage, TocEntry
from app.domain.models import ExtractedDocument, ExtractedPage
from app.domain.ports import PdfExtractionError
logger = logging.getLogger(__name__)
@@ -72,18 +72,7 @@ class PyMuPdfTextExtractor:
raise PdfExtractionError(f"PDF illisible ou corrompu : {exc}") from exc
pages: list[ExtractedPage] = []
toc: list[TocEntry] = []
try:
# Bookmarks/outline du PDF : structure officielle du livre, gratuite
# (pas d'appel LLM). Sert de squelette de référence aux imports.
try:
for level, title, page_no in doc.get_toc(simple=True) or []:
title = str(title or "").strip()
if title:
toc.append(TocEntry(level=int(level), title=title, page=int(page_no)))
except Exception as exc: # noqa: BLE001 — TOC best-effort, jamais bloquante
logger.warning("Lecture de la table des matières impossible : %s", exc)
for index, page in enumerate(doc):
text = (page.get_text() or "").strip()
used_ocr = False
@@ -96,7 +85,7 @@ class PyMuPdfTextExtractor:
finally:
doc.close()
return ExtractedDocument(pages=pages, toc=toc)
return ExtractedDocument(pages=pages)
@staticmethod
def _ocr_page(page: "fitz.Page") -> str:

View File

@@ -5,12 +5,6 @@ Chaque SOURCE est persistée en un fichier JSON sur le volume `data/` du Brain :
À l'échelle d'un livre (quelques centaines d'extraits), une recherche cosinus en
Python pur est instantanée — inutile d'ajouter numpy/pgvector/une base vectorielle.
Les fichiers sont mis en cache mémoire (invalidation par mtime) : le coûteux est
le re-parse JSON des vecteurs, pas le cosinus.
Recherche HYBRIDE : score = cosinus + bonus lexical (mots significatifs de la
question présents dans l'extrait). Sur du JdR, les requêtes sont souvent des noms
propres exacts (« Strahd », « Barovia ») où le lexical bat l'embedding.
"""
from __future__ import annotations
@@ -22,27 +16,6 @@ from pathlib import Path
_STORE_DIR = Path("data/notebooks")
_SAFE_ID = re.compile(r"[^A-Za-z0-9_-]")
# Cache mémoire {source_id: (mtime_ns, chunks)} — évite de relire/re-parser le JSON
# (vecteurs = gros) à chaque question. Invalidé si le fichier change (mtime).
_CACHE: dict[str, tuple[int, list[dict]]] = {}
_CACHE_MAX_SOURCES = 32 # garde-fou mémoire : ~10 Mo par gros livre en cache
# Poids du bonus lexical dans le score hybride. Le cosinus reste dominant ; le
# bonus (0..0.15) sert surtout à départager / repêcher les correspondances exactes.
_LEX_WEIGHT = 0.15
_WORD_RE = re.compile(r"[a-z0-9àâäçéèêëîïôöùûüœæ]{3,}")
# Mots-outils FR/EN fréquents (≥3 lettres) : sans eux, le bonus lexical serait
# dominé par « les », « pour », « the »… au lieu des termes porteurs de sens.
_STOPWORDS = frozenset({
"les", "des", "une", "est", "son", "ses", "aux", "par", "pour", "dans",
"sur", "avec", "qui", "que", "quoi", "dont", "mais", "comme", "plus",
"pas", "tout", "tous", "toute", "toutes", "ils", "elles", "leur", "leurs",
"nous", "vous", "cette", "ces", "cet", "ont", "sont", "fait", "etre",
"être", "avoir", "peut", "quel", "quelle", "quels", "quelles", "ainsi",
"the", "and", "for", "with", "this", "that", "are", "was", "has", "have",
"not", "you", "his", "her", "its", "they", "them", "from", "what", "which",
})
def _path(source_id: str) -> Path:
safe = _SAFE_ID.sub("_", str(source_id))
@@ -69,7 +42,6 @@ def save(
items.append(item)
payload = {"dim": len(vectors[0]) if vectors else 0, "chunks": items}
_path(source_id).write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
_CACHE.pop(source_id, None) # le mtime suffirait, mais soyons explicites
return len(chunks)
@@ -78,65 +50,18 @@ def exists(source_id: str) -> bool:
def delete(source_id: str) -> None:
_CACHE.pop(source_id, None)
_path(source_id).unlink(missing_ok=True)
_summaries_path(source_id).unlink(missing_ok=True)
# --- Index de résumés (analyse approfondie) ----------------------------------
# Cache disque des résumés PAR LOT d'une source : construit paresseusement à la
# première analyse approfondie, réutilisé ensuite pour ne relire que les lots
# pertinents. Invalidé avec la source (delete) et si batch_tokens change.
def _summaries_path(source_id: str) -> Path:
safe = _SAFE_ID.sub("_", str(source_id))
return _STORE_DIR / f"{safe}.summaries.json"
def save_summaries(source_id: str, batch_tokens: int, entries: list[dict]) -> None:
"""Persiste les résumés de lots ({"summary": str, "vector": [...]})."""
_STORE_DIR.mkdir(parents=True, exist_ok=True)
payload = {"batch_tokens": int(batch_tokens), "entries": entries}
_summaries_path(source_id).write_text(
json.dumps(payload, ensure_ascii=False), encoding="utf-8")
def load_summaries(source_id: str, batch_tokens: int) -> list[dict] | None:
"""Résumés de lots d'une source, ou None si absents / construits avec une
autre taille de lot (le découpage ne correspondrait plus)."""
p = _summaries_path(source_id)
if not p.exists():
return None
try:
data = json.loads(p.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return None
if not isinstance(data, dict) or data.get("batch_tokens") != int(batch_tokens):
return None
entries = data.get("entries")
return entries if isinstance(entries, list) else None
def _load(source_id: str) -> list[dict]:
p = _path(source_id)
try:
mtime = p.stat().st_mtime_ns
except OSError:
_CACHE.pop(source_id, None)
if not p.exists():
return []
cached = _CACHE.get(source_id)
if cached is not None and cached[0] == mtime:
return cached[1]
try:
data = json.loads(p.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return []
chunks = data.get("chunks", []) if isinstance(data, dict) else []
if len(_CACHE) >= _CACHE_MAX_SOURCES:
_CACHE.pop(next(iter(_CACHE))) # éviction FIFO simple
_CACHE[source_id] = (mtime, chunks)
return chunks
return data.get("chunks", []) if isinstance(data, dict) else []
def all_chunks(source_id: str) -> list[dict]:
@@ -160,54 +85,20 @@ def _cosine(a: list[float], b: list[float]) -> float:
return dot / (math.sqrt(na) * math.sqrt(nb))
def _significant_words(text: str) -> frozenset[str]:
"""Mots porteurs de sens d'un texte (minuscules, ≥3 lettres, hors mots-outils)."""
return frozenset(w for w in _WORD_RE.findall(text.lower()) if w not in _STOPWORDS)
def _chunk_words(chunk: dict) -> frozenset[str]:
"""Mots significatifs d'un extrait, mémoïsés sur le dict caché (calculés à la
1ère recherche, réutilisés tant que la source reste en cache)."""
words = chunk.get("_words")
if words is None:
words = _significant_words(chunk.get("text", ""))
chunk["_words"] = words
return words
# Alias public du cosinus (réutilisé par l'index de résumés de l'analyse
# approfondie — même métrique que la recherche).
cosine_similarity = _cosine
def search(
source_ids: list[str],
query_vector: list[float],
top_k: int = 6,
query_text: str = "",
min_score: float = 0.0,
) -> list[dict]:
"""Renvoie les `top_k` extraits les plus proches, toutes sources confondues.
Score HYBRIDE : cosinus + `_LEX_WEIGHT` × (part des mots significatifs de
`query_text` présents dans l'extrait). Les extraits dont le cosinus est sous
`min_score` sont écartés (peut donc renvoyer MOINS de `top_k` résultats —
mieux vaut aucun extrait que du bruit injecté dans le prompt).
Chaque résultat : {"text": str, "score": float, "source_id": str, "page": int|None}.
Chaque résultat : {"text": str, "score": float, "source_id": str}.
"""
query_words = _significant_words(query_text) if query_text else frozenset()
scored: list[dict] = []
for sid in source_ids:
for chunk in _load(sid):
vector = chunk.get("vector") or []
cos = _cosine(query_vector, vector)
if cos < min_score:
continue
score = cos
if query_words:
overlap = len(query_words & _chunk_words(chunk)) / len(query_words)
score += _LEX_WEIGHT * overlap
score = _cosine(query_vector, vector)
scored.append({
"text": chunk.get("text", ""),
"score": score,

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,4 @@
fastapi==0.136.*
# Pin EXPLICITE : fastapi n'exige que starlette>=0.46.0 — sans ce pin, un
# environnement existant peut garder une starlette vulnérable.
# >= 0.49.1 : corrige CVE-2025-54121 et CVE-2025-62727.
starlette>=0.49.1,<1.0
fastapi==0.115.*
uvicorn[standard]==0.32.*
httpx==0.27.*
pydantic-settings==2.6.*
@@ -24,6 +20,4 @@ tiktoken==0.8.*
# (le binaire tesseract-ocr est installe dans le Dockerfile, langues fra+eng)
pymupdf==1.24.*
pytesseract==0.3.*
# 12.2+ : corrige 6 CVE de parsing d'images (surface critique : pages de PDF
# uploadés par l'utilisateur rasterisées puis passées à l'OCR).
Pillow==12.2.*
Pillow==11.*

View File

@@ -1,84 +0,0 @@
# -*- coding: utf-8 -*-
"""Sanity check temporaire : overlap du chunking + recherche hybride du vector store."""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from app.application.chunking import chunk_text
# --- 1. Chunking avec overlap ---
paras = [f"Paragraphe {i} : " + ("lorem ipsum dolor sit amet " * 8) for i in range(12)]
text = "\n\n".join(paras)
no_overlap = chunk_text(text, target_tokens=200)
with_overlap = chunk_text(text, target_tokens=200, overlap_tokens=40)
assert len(with_overlap) >= len(no_overlap), "l'overlap ne doit pas réduire le nb de chunks"
# Chaque chunk (sauf le 1er) doit commencer par la fin du précédent
overlapped = 0
for prev, cur in zip(with_overlap, with_overlap[1:]):
first_para = cur.split("\n\n")[0]
if first_para in prev:
overlapped += 1
assert overlapped >= len(with_overlap) - 2, f"overlap absent: {overlapped}/{len(with_overlap)-1}"
# Pas de chunk composé uniquement de l'overlap (dernier chunk dupliqué)
assert with_overlap[-1] != with_overlap[-2], "dernier chunk = pur overlap (dupliqué)"
# overlap_tokens=0 → comportement identique à l'ancien
assert no_overlap == chunk_text(text, target_tokens=200, overlap_tokens=0)
print(f"[OK] chunking : {len(no_overlap)} chunks sans overlap, {len(with_overlap)} avec ({overlapped} recouvrements)")
# --- Paragraphe géant ---
giant = "mot " * 2000
sub = chunk_text(giant, target_tokens=300, overlap_tokens=50)
assert len(sub) > 1
print(f"[OK] paragraphe géant coupé en {len(sub)} sous-blocs")
# --- 2. Vector store : hybride + seuil + cache ---
import tempfile, os
from app.infrastructure import vector_store
with tempfile.TemporaryDirectory() as tmp:
vector_store._STORE_DIR = Path(tmp)
chunks = [
"Strahd von Zarovich règne sur la sombre vallée de Barovia depuis son château.",
"Les règles de combat utilisent un d20 plus le modificateur de caractéristique.",
"La taverne du village sert un ragoût de navets aux voyageurs fatigués.",
]
# Vecteurs factices : chunk 0 et 1 proches de la query, chunk 2 orthogonal
vectors = [[1.0, 0.1, 0.0], [0.9, 0.4, 0.1], [0.0, 0.0, 1.0]]
vector_store.save("src1", chunks, vectors, pages=[10, 20, 30])
q = [1.0, 0.2, 0.0]
# Sans seuil ni texte : 3 résultats, ordre cosinus
r = vector_store.search(["src1"], q, top_k=10)
assert len(r) == 3 and r[0]["page"] == 10
# Avec seuil : le chunk orthogonal (cos~0) est écarté
r = vector_store.search(["src1"], q, top_k=10, min_score=0.30)
assert len(r) == 2, f"seuil non appliqué: {len(r)}"
print(f"[OK] seuil : 2/3 extraits gardés (orthogonal écarté)")
# Bonus lexical : la query mentionne « Strahd Barovia » → chunk 0 doit dominer
r = vector_store.search(["src1"], q, top_k=10, query_text="Parle-moi de Strahd et de Barovia", min_score=0.30)
assert r[0]["text"].startswith("Strahd"), r[0]["text"]
assert r[0]["score"] > vector_store._cosine(q, vectors[0]), "bonus lexical absent"
print(f"[OK] hybride : bonus lexical appliqué (score={r[0]['score']:.3f})")
# Le set "_words" mémoïsé ne doit PAS fuiter dans les résultats
assert all("_words" not in res for res in r)
# Cache : 2e recherche sert depuis la mémoire (même objet liste)
c1 = vector_store._load("src1")
c2 = vector_store._load("src1")
assert c1 is c2, "cache mtime inopérant"
# save() invalide le cache
vector_store.save("src1", chunks[:1], vectors[:1], pages=[10])
c3 = vector_store._load("src1")
assert len(c3) == 1, "cache non invalidé après save"
# delete() purge cache + fichier
vector_store.delete("src1")
assert vector_store._load("src1") == []
print("[OK] cache mémoire : hit, invalidation save, purge delete")
print("\nTous les sanity checks passent.")

View File

@@ -8,22 +8,18 @@
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.5.14</version>
<version>3.2.12</version>
<relativePath/>
</parent>
<groupId>com.loremind</groupId>
<artifactId>loremind-core</artifactId>
<version>0.12.3-beta</version>
<version>0.11.3-beta</version>
<name>LoreMind Core</name>
<description>Backend Core - Architecture Hexagonale</description>
<properties>
<java.version>17</java.version>
<!-- Override de la transitive minio → commons-compress → commons-lang3 3.17.
>= 3.18 : corrige CVE-2025-48924 (recursion infinie ClassUtils.getClass).
Propriete reconnue par le BOM Spring Boot → s'applique partout. -->
<commons-lang3.version>3.20.0</commons-lang3.version>
</properties>
<dependencies>
@@ -81,52 +77,33 @@
<scope>test</scope>
</dependency>
<!-- MinIO — client S3-compatible pour le stockage d'images (Shared Kernel images).
8.6.x = derniere ligne 8.x (la 9.x change l'API) ; transitives a jour. -->
<!-- MinIO — client S3-compatible pour le stockage d'images (Shared Kernel images). -->
<dependency>
<groupId>io.minio</groupId>
<artifactId>minio</artifactId>
<version>8.6.0</version>
<exclusions>
<!-- OkHttp 5 : l'artefact `okhttp` est un alias multiplateforme dont la
resolution vers les classes JVM passe par les metadonnees Gradle —
que Maven ignore. On exclut l'alias et on declare `okhttp-jvm`
(les vraies classes) explicitement ci-dessous. -->
<exclusion>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>okhttp</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>okhttp-jvm</artifactId>
<version>5.1.0</version>
<version>8.5.11</version>
</dependency>
<!-- Nimbus JOSE+JWT — verification des JWT Ed25519 (EdDSA) emis par le relais
Patreon. Supporte nativement les cles Ed25519 via BouncyCastle.
>= 10.0.2 : corrige CVE-2025-53864 (DoS par JSON profondement imbrique
dans le claim set — surface critique : JWT colle par l'utilisateur). -->
Patreon. Supporte nativement les cles Ed25519 via BouncyCastle. -->
<dependency>
<groupId>com.nimbusds</groupId>
<artifactId>nimbus-jose-jwt</artifactId>
<version>10.9.1</version>
<version>9.40</version>
</dependency>
<dependency>
<groupId>org.bouncycastle</groupId>
<artifactId>bcprov-jdk18on</artifactId>
<version>1.84</version>
<version>1.78.1</version>
</dependency>
<!-- Google Tink : runtime requis par com.nimbusds.jose.crypto.Ed25519Verifier
(depuis Nimbus 9.x, la verification EdDSA delegue a Tink.subtle.Ed25519Verify).
Tink n'est PAS une dependance transitive de nimbus-jose-jwt → il faut
l'ajouter explicitement, sinon NoClassDefFoundError au premier verify().
>= 1.15 : embarque un protobuf-java corrige (CVE-2024-7254). -->
l'ajouter explicitement, sinon NoClassDefFoundError au premier verify(). -->
<dependency>
<groupId>com.google.crypto.tink</groupId>
<artifactId>tink</artifactId>
<version>1.21.0</version>
<version>1.14.1</version>
</dependency>
</dependencies>

View File

@@ -6,7 +6,6 @@ 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;
@@ -38,37 +37,31 @@ public class CampaignImportService {
private final ArcService arcService;
private final ChapterService chapterService;
private final SceneService sceneService;
private final NpcService npcService;
public CampaignImportService(
CampaignPdfImporter campaignPdfImporter,
CampaignService campaignService,
ArcService arcService,
ChapterService chapterService,
SceneService sceneService,
NpcService npcService) {
SceneService sceneService) {
this.campaignPdfImporter = campaignPdfImporter;
this.campaignService = campaignService;
this.arcService = arcService;
this.chapterService = chapterService;
this.sceneService = sceneService;
this.npcService = npcService;
}
/** Résumé de ce qui a été créé par {@link #applyStructure}. */
public record ApplyResult(
int arcsCreated, int chaptersCreated, int scenesCreated, int npcsCreated) {}
public record ApplyResult(int arcsCreated, int chaptersCreated, int scenesCreated) {}
/** Génère la proposition d'arbre (streamée). Ne persiste rien. */
public void importStructureStreaming(
byte[] pdfBytes,
String filename,
Consumer<CampaignImportProgress> onProgress,
Runnable onHeartbeat,
Consumer<CampaignImportProposal> onDone,
Consumer<Throwable> onError) {
campaignPdfImporter.importCampaignStreaming(
pdfBytes, filename, onProgress, onHeartbeat, onDone, onError);
campaignPdfImporter.importCampaignStreaming(pdfBytes, filename, onProgress, onDone, onError);
}
/**
@@ -137,36 +130,7 @@ public class CampaignImportService {
}
}
}
int npcsCreated = createNpcs(campaignId, proposal.npcs());
return new ApplyResult(arcsCreated, chaptersCreated, scenesCreated, npcsCreated);
}
/**
* 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
* dans la campagne sont ignorés (ré-import sans doublon).
*/
private int createNpcs(String campaignId, List<NpcProposal> proposals) {
if (proposals == null || proposals.isEmpty()) return 0;
java.util.Set<String> existingNames = new java.util.HashSet<>();
for (var npc : npcService.getNpcsByCampaignId(campaignId)) {
existingNames.add(npc.getName().trim().toLowerCase());
}
int created = 0;
for (NpcProposal p : proposals) {
if (isBlank(p.name()) || !existingNames.add(p.name().trim().toLowerCase())) {
continue;
}
npcService.createNpc(new NpcService.NpcData(
p.name().trim(), null, null,
isBlank(p.description())
? java.util.Map.of()
: java.util.Map.of("Description", p.description().trim()),
null, null, campaignId, null, null, null));
created++;
}
return created;
return new ApplyResult(arcsCreated, chaptersCreated, scenesCreated);
}
/** Compte les nœuds déjà présents (existingId non vide) d'une liste. */

View File

@@ -1,12 +1,9 @@
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.ports.NpcRepository;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@@ -19,11 +16,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(
@@ -34,7 +29,6 @@ public class NpcService {
Map<String, List<String>> imageValues,
Map<String, Map<String, String>> keyValueValues,
String campaignId,
List<String> relatedPageIds,
String folder,
Integer order
) {}
@@ -51,7 +45,6 @@ public class NpcService {
.imageValues(data.imageValues() != null ? new HashMap<>(data.imageValues()) : new HashMap<>())
.keyValueValues(data.keyValueValues() != null ? new HashMap<>(data.keyValueValues()) : new HashMap<>())
.campaignId(data.campaignId())
.relatedPageIds(data.relatedPageIds() != null ? new ArrayList<>(data.relatedPageIds()) : new ArrayList<>())
.folder(normalizeFolder(data.folder()))
.order(order)
.build();
@@ -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));
@@ -90,7 +68,6 @@ public class NpcService {
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.setRelatedPageIds(data.relatedPageIds() != null ? new ArrayList<>(data.relatedPageIds()) : new ArrayList<>());
existing.setFolder(normalizeFolder(data.folder()));
if (data.order() != null) {
existing.setOrder(data.order());

View File

@@ -39,11 +39,9 @@ public class GameSystemService {
byte[] pdfBytes,
String filename,
java.util.function.Consumer<com.loremind.domain.gamesystemcontext.RulesImportProgress> onProgress,
Runnable onHeartbeat,
java.util.function.Consumer<RulesImportResult> onDone,
java.util.function.Consumer<Throwable> onError) {
rulesPdfImporter.importRulesStreaming(
pdfBytes, filename, onProgress, onHeartbeat, onDone, onError);
rulesPdfImporter.importRulesStreaming(pdfBytes, filename, onProgress, onDone, onError);
}
/**

View File

@@ -76,8 +76,6 @@ public class PageService {
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()));

View File

@@ -14,6 +14,5 @@ public record CampaignImportProgress(
int ocrPageCount,
int arcCount,
int chapterCount,
int sceneCount,
int npcCount) {
int sceneCount) {
}

View File

@@ -8,7 +8,7 @@ import java.util.List;
* PROPOSITION non persistée : l'UI laisse l'utilisateur réviser/éditer l'arbre
* avant la création effective des arcs/chapitres/scènes. Records purs (domaine).
*/
public record CampaignImportProposal(List<ArcProposal> arcs, List<NpcProposal> npcs) {
public record CampaignImportProposal(List<ArcProposal> arcs) {
/**
* {@code existingId} (nullable) : si présent, le nœud existe DÉJÀ dans la
@@ -37,14 +37,4 @@ public record CampaignImportProposal(List<ArcProposal> arcs, List<NpcProposal> n
public record RoomProposal(String name, String description, String enemies, String loot) {
}
/**
* PNJ/creature notable detecte dans le PDF (PNJ nommes, boss). Propose a la
* revue (coche par defaut) ; cree comme Npc de la campagne a l''apply avec
* sa description dans values["Description"] (meme convention que les cartes
* d''action des ateliers).
*/
public record NpcProposal(String name, String description) {
}
}

View File

@@ -4,7 +4,6 @@ import lombok.Builder;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@@ -47,13 +46,6 @@ public class Npc {
/** Référence vers la Campaign parente (cross-aggregate via ID). */
private String campaignId;
/**
* IDs de Pages de Lore référencées par ce PNJ (sa ville, sa faction, sa
* région…). Référence faible cross-context, même principe que sur
* Arc/Chapter/Scene — alimente notamment le graphe du Lore.
*/
private List<String> relatedPageIds;
/** Dossier de classement (texte libre, ex. « Bard's Gate »). Nullable = non classé. */
private String folder;
@@ -77,9 +69,4 @@ public class Npc {
if (keyValueValues == null) keyValueValues = new HashMap<>();
return keyValueValues;
}
public List<String> getRelatedPageIds() {
if (relatedPageIds == null) relatedPageIds = new ArrayList<>();
return relatedPageIds;
}
}

View File

@@ -15,18 +15,14 @@ public interface CampaignPdfImporter {
* Variante streamée : l'import peut durer plusieurs minutes, on remonte
* l'avancement au fil de l'eau, puis la proposition finale.
*
* @param onProgress invoqué à chaque étape (extraction, puis par morceau).
* @param onHeartbeat invoqué périodiquement pendant un appel LLM long (aucune
* avancée à afficher, mais le canal SSE vers le navigateur
* doit rester actif — sinon un proxy intermédiaire le coupe).
* @param onDone invoqué une fois avec l'arbre proposé (non persisté).
* @param onError invoqué si l'extraction/structuration échoue.
* @param onProgress invoqué à chaque étape (extraction, puis par morceau).
* @param onDone invoqué une fois avec l'arbre proposé (non persisté).
* @param onError invoqué si l'extraction/structuration échoue.
*/
void importCampaignStreaming(
byte[] pdfBytes,
String filename,
Consumer<CampaignImportProgress> onProgress,
Runnable onHeartbeat,
Consumer<CampaignImportProposal> onDone,
Consumer<Throwable> onError);
}

View File

@@ -17,10 +17,9 @@ public interface NotebookChatStreamer {
/**
* Streame la réponse ancrée sur les sources. Les callbacks sont invoqués au fil
* de l'eau : {@code onSourcesJson} UNE fois avant le premier token (JSON brut des
* passages utilisés — transparence UI), {@code onToken} par fragment,
* {@code onProgress} (mode approfondi uniquement) pendant la lecture du document,
* {@code onDone} à la fin, {@code onError} en cas d'échec.
* de l'eau : {@code onToken} par fragment, {@code onProgress} (mode approfondi
* uniquement) pendant la lecture du document, {@code onDone} à la fin,
* {@code onError} en cas d'échec.
*
* @param deep true = analyse approfondie (map-reduce sur tout le document) ;
* false = chat RAG (top-k).
@@ -30,7 +29,6 @@ public interface NotebookChatStreamer {
List<Msg> messages,
String context,
boolean deep,
Consumer<String> onSourcesJson,
Consumer<String> onToken,
Consumer<Progress> onProgress,
Runnable onDone,

View File

@@ -26,18 +26,14 @@ public interface RulesPdfImporter {
* l'avancement au fil de l'eau. Les callbacks sont invoqués depuis le thread
* d'exécution de l'adapter (synchrone jusqu'à {@code onDone}/{@code onError}).
*
* @param onProgress invoqué à chaque étape (extraction, puis par morceau).
* @param onHeartbeat invoqué périodiquement pendant un appel LLM long (aucune
* avancée à afficher, mais le canal SSE vers le navigateur
* doit rester actif — sinon un proxy intermédiaire le coupe).
* @param onDone invoqué une fois avec le résultat final.
* @param onError invoqué si l'extraction/structuration échoue.
* @param onProgress invoqué à chaque étape (extraction, puis par morceau).
* @param onDone invoqué une fois avec le résultat final.
* @param onError invoqué si l'extraction/structuration échoue.
*/
void importRulesStreaming(
byte[] pdfBytes,
String filename,
Consumer<RulesImportProgress> onProgress,
Runnable onHeartbeat,
Consumer<RulesImportResult> onDone,
Consumer<Throwable> onError);
}

View File

@@ -39,20 +39,6 @@ public class Page {
*/
private Map<String, List<String>> imageValues;
/**
* Valeurs des champs KEY_VALUE_LIST (tableau libelle → valeur, comme sur les
* fiches de personnage) : fieldName → (label → valeur). Les labels sont
* definis par le Template ; seules les valeurs vivent sur la page.
*/
private Map<String, Map<String, String>> keyValueValues;
/**
* Valeurs des champs TABLE (colonnes figees au template, lignes libres) :
* fieldName → liste ordonnee de lignes, chaque ligne = colonne → cellule.
* Usage type : inventaire de boutique, table d'objets.
*/
private Map<String, List<Map<String, String>>> tableValues;
/** Notes privées du MJ (non exportées vers FoundryVTT). */
private String notes;

View File

@@ -9,11 +9,6 @@ package com.loremind.domain.shared.template;
* - KEY_VALUE_LIST : liste de paires {label, value} avec labels figes au template
* (Map<String, Map<String, String>> : fieldName -> label -> value).
* Usage : stat blocks, listes de competences, traits.
* - TABLE : tableau a colonnes figees au template (TemplateField.labels =
* noms de colonnes) et lignes LIBRES ajoutees au remplissage
* (Map<String, List<Map<String, String>>> : fieldName -> lignes,
* chaque ligne = colonne -> cellule).
* Usage : inventaire de boutique, tables d'objets, listes de prix.
* <p>
* Extension future possible : RICH_TEXT, DATE, BOOLEAN, REFERENCE...
*/
@@ -21,6 +16,5 @@ public enum FieldType {
TEXT,
IMAGE,
NUMBER,
KEY_VALUE_LIST,
TABLE
KEY_VALUE_LIST
}

View File

@@ -30,9 +30,8 @@ public class TemplateField {
/** Variante de rendu pour les champs IMAGE. Null = GALLERY. */
private ImageLayout layout;
/**
* Labels predefinis (ordre significatif), selon le type :
* - KEY_VALUE_LIST : libelles des lignes. Ex: ["FOR","DEX","CON","INT","SAG","CHA"].
* - TABLE : noms des COLONNES. Ex: ["Objet","Prix","Description"].
* Labels predefinis pour les champs KEY_VALUE_LIST (ordre significatif).
* Ex: ["FOR","DEX","CON","INT","SAG","CHA"] pour un champ "Caracteristiques".
* Null/vide pour les autres types.
*/
private List<String> labels;
@@ -71,9 +70,4 @@ public class TemplateField {
public static TemplateField keyValueList(String name, List<String> labels) {
return new TemplateField(name, FieldType.KEY_VALUE_LIST, null, labels);
}
/** Raccourci : construit un champ TABLE avec ses noms de colonnes. */
public static TemplateField table(String name, List<String> columns) {
return new TemplateField(name, FieldType.TABLE, null, columns);
}
}

View File

@@ -6,7 +6,6 @@ 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.ports.CampaignImportException;
@@ -60,7 +59,6 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
byte[] pdfBytes,
String filename,
Consumer<CampaignImportProgress> onProgress,
Runnable onHeartbeat,
Consumer<CampaignImportProposal> onDone,
Consumer<Throwable> onError) {
@@ -84,8 +82,7 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
flux
.timeout(Duration.ofSeconds(importTimeoutSeconds))
.doOnNext(sse -> handleEvent(
sse, pageCount, ocrPageCount, terminated,
onProgress, onHeartbeat, onDone, onError))
sse, pageCount, ocrPageCount, terminated, onProgress, onDone, onError))
.blockLast();
if (!terminated[0]) {
onError.accept(new CampaignImportException(
@@ -109,19 +106,12 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
int[] ocrPageCount,
boolean[] terminated,
Consumer<CampaignImportProgress> onProgress,
Runnable onHeartbeat,
Consumer<CampaignImportProposal> onDone,
Consumer<Throwable> onError) {
String event = sse.event();
String data = sse.data() == null ? "" : sse.data();
if ("heartbeat".equals(event)) {
// Keep-alive du Brain pendant un appel LLM long : à PROPAGER jusqu'au
// navigateur, sinon nginx (proxy_read_timeout) coupe le SSE Core→front.
onHeartbeat.run();
return;
}
if ("error".equals(event)) {
terminated[0] = true;
onError.accept(new CampaignImportException(
@@ -129,7 +119,7 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
return;
}
if ("extracting".equals(event)) {
onProgress.accept(new CampaignImportProgress(0, 0, 0, 0, 0, 0, 0, 0));
onProgress.accept(new CampaignImportProgress(0, 0, 0, 0, 0, 0, 0));
return;
}
@@ -140,7 +130,7 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
pageCount[0] = node.path("page_count").asInt();
ocrPageCount[0] = node.path("ocr_page_count").asInt();
onProgress.accept(new CampaignImportProgress(
0, node.path("total").asInt(), pageCount[0], ocrPageCount[0], 0, 0, 0, 0));
0, node.path("total").asInt(), pageCount[0], ocrPageCount[0], 0, 0, 0));
} else if ("progress".equals(event)) {
onProgress.accept(new CampaignImportProgress(
node.path("current").asInt(),
@@ -149,12 +139,10 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
ocrPageCount[0],
node.path("arc_count").asInt(),
node.path("chapter_count").asInt(),
node.path("scene_count").asInt(),
node.path("npc_count").asInt()));
node.path("scene_count").asInt()));
} else if ("done".equals(event)) {
terminated[0] = true;
onDone.accept(new CampaignImportProposal(
toArcs(node.path("arcs")), toNpcs(node.path("npcs"))));
onDone.accept(new CampaignImportProposal(toArcs(node.path("arcs"))));
}
}
@@ -214,16 +202,6 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
return rooms;
}
private List<NpcProposal> toNpcs(JsonNode npcsNode) {
List<NpcProposal> npcs = new ArrayList<>();
if (npcsNode != null && npcsNode.isArray()) {
for (JsonNode n : npcsNode) {
npcs.add(new NpcProposal(text(n, "name"), text(n, "description")));
}
}
return npcs;
}
// --- Helpers -------------------------------------------------------------
private ByteArrayResource filePart(byte[] pdfBytes, String filename) {

View File

@@ -51,7 +51,6 @@ public class BrainNotebookChatClient implements NotebookChatStreamer {
List<Msg> messages,
String context,
boolean deep,
Consumer<String> onSourcesJson,
Consumer<String> onToken,
Consumer<Progress> onProgress,
Runnable onDone,
@@ -76,8 +75,7 @@ public class BrainNotebookChatClient implements NotebookChatStreamer {
try {
flux
.timeout(Duration.ofSeconds(timeoutSeconds))
.doOnNext(sse -> handleEvent(
sse, terminated, onSourcesJson, onToken, onProgress, onDone, onError))
.doOnNext(sse -> handleEvent(sse, terminated, onToken, onProgress, onDone, onError))
.blockLast();
if (!terminated[0]) {
onDone.run(); // flux terminé sans event done explicite
@@ -95,7 +93,6 @@ public class BrainNotebookChatClient implements NotebookChatStreamer {
private void handleEvent(
ServerSentEvent<String> sse,
boolean[] terminated,
Consumer<String> onSourcesJson,
Consumer<String> onToken,
Consumer<Progress> onProgress,
Runnable onDone,
@@ -106,10 +103,6 @@ public class BrainNotebookChatClient implements NotebookChatStreamer {
if ("token".equals(event)) {
String token = readField(data, "token");
if (token != null && !token.isEmpty()) onToken.accept(token);
} else if ("sources".equals(event)) {
// Passages utilisés par le RAG : relayés tels quels (JSON brut) — le
// Core n'a pas besoin de les comprendre, seulement de les transmettre.
onSourcesJson.accept(data);
} else if ("progress".equals(event)) {
onProgress.accept(new Progress(readInt(data, "current"), readInt(data, "total")));
} else if ("done".equals(event)) {

View File

@@ -114,7 +114,6 @@ public class BrainRulesImportClient implements RulesPdfImporter {
byte[] pdfBytes,
String filename,
Consumer<RulesImportProgress> onProgress,
Runnable onHeartbeat,
Consumer<RulesImportResult> onDone,
Consumer<Throwable> onError) {
@@ -140,8 +139,7 @@ public class BrainRulesImportClient implements RulesPdfImporter {
flux
.timeout(Duration.ofSeconds(importTimeoutSeconds))
.doOnNext(sse -> handleEvent(
sse, pageCount, ocrPageCount, terminated,
onProgress, onHeartbeat, onDone, onError))
sse, pageCount, ocrPageCount, terminated, onProgress, onDone, onError))
.blockLast();
// Flux terminé sans event done/error (ex: connexion coupée) → on signale.
if (!terminated[0]) {
@@ -167,20 +165,12 @@ public class BrainRulesImportClient implements RulesPdfImporter {
int[] ocrPageCount,
boolean[] terminated,
Consumer<RulesImportProgress> onProgress,
Runnable onHeartbeat,
Consumer<RulesImportResult> onDone,
Consumer<Throwable> onError) {
String event = sse.event();
String data = sse.data() == null ? "" : sse.data();
if ("heartbeat".equals(event)) {
// Keep-alive du Brain pendant un appel LLM long : à PROPAGER jusqu'au
// navigateur, sinon nginx (proxy_read_timeout) coupe le SSE Core→front
// resté silencieux pendant tout le traitement du morceau.
onHeartbeat.run();
return;
}
if ("error".equals(event)) {
terminated[0] = true;
onError.accept(new RulesImportException(

View File

@@ -1,51 +0,0 @@
package com.loremind.infrastructure.persistence.converter;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.persistence.AttributeConverter;
import jakarta.persistence.Converter;
import java.util.Collections;
import java.util.List;
import java.util.Map;
/**
* Convertit une Map<String, List<Map<String, String>>> en JSON et inversement.
* <p>
* Utilise pour Page.tableValues : pour chaque champ TABLE du template, stocke
* la liste ordonnee des LIGNES du tableau, chaque ligne etant une map
* colonne -> cellule. Exemple :
* {"Inventaire": [{"Objet":"Potion","Prix":"50 po"}, {"Objet":"Corde","Prix":"1 po"}]}
* <p>
* Adaptateur technique pur : le domaine ignore ce converter.
*/
@Converter
public class StringRowListMapJsonConverter
implements AttributeConverter<Map<String, List<Map<String, String>>>, String> {
private static final ObjectMapper MAPPER = new ObjectMapper();
private static final TypeReference<Map<String, List<Map<String, String>>>> TYPE_REF =
new TypeReference<>() {};
@Override
public String convertToDatabaseColumn(Map<String, List<Map<String, String>>> attribute) {
if (attribute == null || attribute.isEmpty()) return "{}";
try {
return MAPPER.writeValueAsString(attribute);
} catch (Exception e) {
throw new IllegalStateException(
"Erreur serialisation Map<String, List<Map<String,String>>> -> JSON", e);
}
}
@Override
public Map<String, List<Map<String, String>>> convertToEntityAttribute(String dbData) {
if (dbData == null || dbData.isBlank()) return Collections.emptyMap();
try {
return MAPPER.readValue(dbData, TYPE_REF);
} catch (Exception e) {
throw new IllegalStateException(
"Erreur deserialisation JSON -> Map<String, List<Map<String,String>>>", e);
}
}
}

View File

@@ -86,7 +86,7 @@ public class TemplateFieldListJsonConverter
}
}
List<String> labels = null;
if (type == FieldType.KEY_VALUE_LIST || type == FieldType.TABLE) {
if (type == FieldType.KEY_VALUE_LIST) {
JsonNode labelsNode = item.path("labels");
if (labelsNode.isArray()) {
labels = new ArrayList<>();

View File

@@ -3,7 +3,6 @@ package com.loremind.infrastructure.persistence.entity;
import com.loremind.domain.campaigncontext.ArcType;
import com.loremind.infrastructure.persistence.converter.StringListJsonConverter;
import jakarta.persistence.*;
import org.hibernate.annotations.ColumnDefault;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
@@ -43,12 +42,8 @@ public class ArcJpaEntity {
* Type structurel de l'arc (LINEAR par défaut).
* Stocké en STRING pour rester lisible en DB et résistant aux refactos d'ordre.
*/
// length + @ColumnDefault (PAS columnDefinition brut) : Hibernate 6.6 recopie
// columnDefinition tel quel dans son ALTER ... SET DATA TYPE de migration, et
// PostgreSQL refuse DEFAULT à cet endroit (erreur à chaque démarrage).
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 16)
@ColumnDefault("'LINEAR'")
@Column(nullable = false, columnDefinition = "VARCHAR(16) DEFAULT 'LINEAR'")
@Builder.Default
private ArcType type = ArcType.LINEAR;

View File

@@ -1,6 +1,5 @@
package com.loremind.infrastructure.persistence.entity;
import com.loremind.infrastructure.persistence.converter.StringListJsonConverter;
import com.loremind.infrastructure.persistence.converter.StringListMapJsonConverter;
import com.loremind.infrastructure.persistence.converter.StringMapJsonConverter;
import com.loremind.infrastructure.persistence.converter.StringMapMapJsonConverter;
@@ -55,11 +54,6 @@ public class NpcJpaEntity {
@Column(name = "campaign_id", nullable = false)
private Long campaignId;
/** IDs de Pages de Lore référencées (référence faible cross-context). JSON TEXT. */
@Convert(converter = StringListJsonConverter.class)
@Column(name = "related_page_ids", columnDefinition = "TEXT")
private List<String> relatedPageIds;
@Column(name = "folder")
private String folder;

View File

@@ -3,8 +3,6 @@ package com.loremind.infrastructure.persistence.entity;
import com.loremind.infrastructure.persistence.converter.StringListJsonConverter;
import com.loremind.infrastructure.persistence.converter.StringListMapJsonConverter;
import com.loremind.infrastructure.persistence.converter.StringMapJsonConverter;
import com.loremind.infrastructure.persistence.converter.StringMapMapJsonConverter;
import com.loremind.infrastructure.persistence.converter.StringRowListMapJsonConverter;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
@@ -54,16 +52,6 @@ public class PageJpaEntity {
@Convert(converter = StringListMapJsonConverter.class)
private Map<String, List<String>> imageValues;
/** Valeurs des champs KEY_VALUE_LIST : fieldName → (label → valeur). JSON TEXT. */
@Column(name = "key_value_values", columnDefinition = "TEXT")
@Convert(converter = StringMapMapJsonConverter.class)
private Map<String, Map<String, String>> keyValueValues;
/** Valeurs des champs TABLE : fieldName → lignes (colonne → cellule). JSON TEXT. */
@Column(name = "table_values", columnDefinition = "TEXT")
@Convert(converter = StringRowListMapJsonConverter.class)
private Map<String, List<Map<String, String>>> tableValues;
@Column(columnDefinition = "TEXT")
private String notes;

View File

@@ -6,7 +6,6 @@ import com.loremind.infrastructure.persistence.entity.NpcJpaEntity;
import com.loremind.infrastructure.persistence.jpa.NpcJpaRepository;
import org.springframework.stereotype.Repository;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Optional;
@@ -60,7 +59,6 @@ public class PostgresNpcRepository implements NpcRepository {
.imageValues(e.getImageValues() != null ? new HashMap<>(e.getImageValues()) : new HashMap<>())
.keyValueValues(e.getKeyValueValues() != null ? new HashMap<>(e.getKeyValueValues()) : new HashMap<>())
.campaignId(e.getCampaignId().toString())
.relatedPageIds(e.getRelatedPageIds() != null ? new ArrayList<>(e.getRelatedPageIds()) : new ArrayList<>())
.folder(e.getFolder())
.order(e.getOrder())
.createdAt(e.getCreatedAt())
@@ -79,7 +77,6 @@ public class PostgresNpcRepository implements NpcRepository {
.imageValues(n.getImageValues() != null ? new HashMap<>(n.getImageValues()) : new HashMap<>())
.keyValueValues(n.getKeyValueValues() != null ? new HashMap<>(n.getKeyValueValues()) : new HashMap<>())
.campaignId(Long.parseLong(n.getCampaignId()))
.relatedPageIds(n.getRelatedPageIds() != null ? new ArrayList<>(n.getRelatedPageIds()) : new ArrayList<>())
.folder(n.getFolder())
.order(n.getOrder())
.createdAt(n.getCreatedAt())

View File

@@ -93,8 +93,6 @@ public class PostgresPageRepository implements PageRepository {
.title(e.getTitle())
.values(e.getValues() != null ? new HashMap<>(e.getValues()) : new HashMap<>())
.imageValues(e.getImageValues() != null ? new HashMap<>(e.getImageValues()) : new HashMap<>())
.keyValueValues(e.getKeyValueValues() != null ? new HashMap<>(e.getKeyValueValues()) : new HashMap<>())
.tableValues(e.getTableValues() != null ? new HashMap<>(e.getTableValues()) : new HashMap<>())
.notes(e.getNotes())
.tags(e.getTags() != null ? new ArrayList<>(e.getTags()) : new ArrayList<>())
.relatedPageIds(e.getRelatedPageIds() != null ? new ArrayList<>(e.getRelatedPageIds()) : new ArrayList<>())
@@ -113,8 +111,6 @@ public class PostgresPageRepository implements PageRepository {
.title(p.getTitle())
.values(p.getValues() != null ? new HashMap<>(p.getValues()) : new HashMap<>())
.imageValues(p.getImageValues() != null ? new HashMap<>(p.getImageValues()) : new HashMap<>())
.keyValueValues(p.getKeyValueValues() != null ? new HashMap<>(p.getKeyValueValues()) : new HashMap<>())
.tableValues(p.getTableValues() != null ? new HashMap<>(p.getTableValues()) : new HashMap<>())
.notes(p.getNotes())
.tags(p.getTags() != null ? new ArrayList<>(p.getTags()) : new ArrayList<>())
.relatedPageIds(p.getRelatedPageIds() != null ? new ArrayList<>(p.getRelatedPageIds()) : new ArrayList<>())

View File

@@ -32,11 +32,6 @@ public class MinioConfig {
@Bean
public MinioClient minioClient() {
return buildClient();
}
/** Fabrique directe (sans proxy Spring) — voir ensureBucketExists. */
private MinioClient buildClient() {
return MinioClient.builder()
.endpoint(endpoint)
.credentials(accessKey, secretKey)
@@ -47,16 +42,11 @@ public class MinioConfig {
* Garantit l'existence du bucket au demarrage. Si MinIO n'est pas joignable,
* on loggue juste l'erreur sans planter l'application : le developpeur
* recevra une erreur claire au premier upload plutot qu'au boot.
* <p>
* NB : on construit un client LOCAL au lieu d'appeler {@code minioClient()} —
* depuis Spring 6.2, appeler une methode @Bean proxifiee pendant le
* @PostConstruct de sa propre @Configuration leve "Requested bean is
* currently in creation" et la verification ne tournait plus jamais.
*/
@PostConstruct
public void ensureBucketExists() {
try {
MinioClient client = buildClient();
MinioClient client = minioClient();
boolean exists = client.bucketExists(BucketExistsArgs.builder().bucket(bucket).build());
if (!exists) {
client.makeBucket(MakeBucketArgs.builder().bucket(bucket).build());

View File

@@ -10,7 +10,6 @@ import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.context.request.async.AsyncRequestNotUsableException;
import java.util.LinkedHashMap;
import java.util.Map;
@@ -71,18 +70,6 @@ public class GlobalExceptionHandler {
));
}
/**
* Client HTTP parti pendant une reponse asynchrone (SSE) : le navigateur a ferme
* la connexion (onglet ferme, proxy coupe...), la reponse n'est plus utilisable.
* Ce n'est PAS une erreur serveur -> pas de log ERROR + stack trace (bruit),
* et aucune reponse a renvoyer (le canal est mort).
*/
@ExceptionHandler(AsyncRequestNotUsableException.class)
public void handleClientDisconnected(HttpServletRequest request, AsyncRequestNotUsableException ex) {
log.debug("Client deconnecte pendant la reponse asynchrone sur {} {} : {}",
request.getMethod(), request.getRequestURI(), ex.getMessage());
}
/**
* Fallback : tout ce qui n'a pas ete catche au-dessus -> 500, mais avec
* un log ERROR explicite (path + stack trace) et un body JSON debuggable

View File

@@ -15,7 +15,6 @@ import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.io.IOException;
import java.util.Map;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* REST Controller pour l'import d'un PDF de campagne → arbre arc/chapitre/scène.
@@ -31,13 +30,8 @@ public class CampaignImportController {
private static final Logger log = LoggerFactory.getLogger(CampaignImportController.class);
/**
* Timeout SSE = durée TOTALE maximale de l'import (pas un timeout d'inactivité :
* les heartbeats ne le réarment pas). Un livre entier sur un modèle local peut
* largement dépasser 15 min → 60 min. La déconnexion du client reste détectée
* immédiatement par ailleurs (échec d'envoi → interruption de l'import).
*/
private static final long IMPORT_SSE_TIMEOUT_MS = 60 * 60 * 1000L;
/** Timeout SSE généreux : un import de livre entier peut durer plusieurs minutes. */
private static final long IMPORT_SSE_TIMEOUT_MS = 15 * 60 * 1000L;
private final CampaignImportService campaignImportService;
private final TaskExecutor taskExecutor;
@@ -60,63 +54,33 @@ public class CampaignImportController {
@RequestParam("file") MultipartFile file) throws IOException {
SseEmitter emitter = new SseEmitter(IMPORT_SSE_TIMEOUT_MS);
if (file == null || file.isEmpty()) {
sendError(emitter, new AtomicBoolean(false), "Fichier PDF vide.");
sendError(emitter, "Fichier PDF vide.");
return emitter;
}
byte[] bytes = file.getBytes();
String filename = file.getOriginalFilename();
// Suivi de la déconnexion du navigateur : dès qu'un envoi échoue (ou que
// l'emitter se termine), on cesse d'envoyer ET on interrompt le streaming
// amont (ClientGoneException remonte dans le doOnNext du WebClient →
// annule la souscription → le Brain voit la coupure et stoppe le LLM).
AtomicBoolean clientGone = new AtomicBoolean(false);
emitter.onTimeout(() -> {
// Timeout = durée totale dépassée, mais la connexion est encore vivante :
// on envoie une vraie erreur au navigateur AVANT de fermer (sinon le flux
// se termine en silence et l'UI reste figée sur la barre de progression).
sendError(emitter, clientGone,
"L'import a dépassé la durée maximale autorisée et a été interrompu. "
+ "Réessayez avec un modèle plus rapide ou un PDF plus petit.");
clientGone.set(true);
});
emitter.onError(e -> clientGone.set(true));
taskExecutor.execute(() -> {
try {
campaignImportService.importStructureStreaming(
bytes, filename,
progress -> sendEvent(emitter, clientGone, "progress", progress),
() -> sendHeartbeat(emitter, clientGone),
progress -> sendEvent(emitter, "progress", progress),
proposal -> {
sendEvent(emitter, clientGone, "done", proposal);
sendEvent(emitter, "done", proposal);
emitter.complete();
},
error -> {
if (clientGone.get()) {
log.info("Import campagne (stream) interrompu : client déconnecté.");
return;
}
log.warn("Import campagne (stream) échoué : {}", error.getMessage());
sendError(emitter, clientGone, error.getMessage());
sendError(emitter, error.getMessage());
});
} catch (ClientGoneException e) {
log.info("Import campagne (stream) interrompu : client déconnecté.");
} catch (Exception e) {
log.warn("Import campagne (stream) échoué : {}", e.getMessage());
sendError(emitter, clientGone, e.getMessage());
sendError(emitter, e.getMessage());
}
});
return emitter;
}
/** Signale que le navigateur a fermé le flux SSE : inutile de continuer l'import. */
private static final class ClientGoneException extends RuntimeException {
ClientGoneException(Throwable cause) {
super("Client SSE déconnecté.", cause);
}
}
@PostMapping(value = "/apply", consumes = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<CampaignImportService.ApplyResult> apply(
@PathVariable String campaignId,
@@ -132,52 +96,23 @@ public class CampaignImportController {
// --- Helpers SSE ---------------------------------------------------------
private void sendEvent(
SseEmitter emitter, AtomicBoolean clientGone, String eventName, Object payload) {
if (clientGone.get()) {
throw new ClientGoneException(null);
}
private void sendEvent(SseEmitter emitter, String eventName, Object payload) {
try {
emitter.send(SseEmitter.event().name(eventName).data(
objectMapper.writeValueAsString(payload), MediaType.APPLICATION_JSON));
} catch (Exception e) {
// IOException OU IllegalStateException (emitter déjà terminé) : le client
// est parti — on interrompt le pipeline amont au lieu de rejouer l'échec.
clientGone.set(true);
} catch (IOException e) {
emitter.completeWithError(e);
throw new ClientGoneException(e);
}
}
/**
* Keep-alive vers le navigateur pendant un appel LLM long : un commentaire SSE
* (ignoré par le front) suffit à réarmer le {@code proxy_read_timeout} de nginx.
*/
private void sendHeartbeat(SseEmitter emitter, AtomicBoolean clientGone) {
if (clientGone.get()) {
throw new ClientGoneException(null);
}
try {
emitter.send(SseEmitter.event().comment("keepalive"));
} catch (Exception e) {
clientGone.set(true);
emitter.completeWithError(e);
throw new ClientGoneException(e);
}
}
private void sendError(SseEmitter emitter, AtomicBoolean clientGone, String message) {
if (clientGone.get()) {
return; // le client n'est plus là pour lire le message d'erreur.
}
private void sendError(SseEmitter emitter, String message) {
try {
emitter.send(SseEmitter.event().name("error").data(
objectMapper.writeValueAsString(Map.of(
"message", message != null ? message : "Erreur inconnue.")),
MediaType.APPLICATION_JSON));
emitter.complete();
} catch (Exception e) {
clientGone.set(true);
} catch (IOException e) {
emitter.completeWithError(e);
}
}

View File

@@ -3,6 +3,7 @@ package com.loremind.infrastructure.web.controller;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.loremind.application.gamesystemcontext.GameSystemService;
import com.loremind.domain.gamesystemcontext.GameSystem;
import com.loremind.domain.gamesystemcontext.RulesImportProgress;
import com.loremind.domain.gamesystemcontext.RulesImportResult;
import com.loremind.domain.gamesystemcontext.ports.RulesImportException;
import com.loremind.domain.shared.template.TemplateField;
@@ -26,7 +27,6 @@ import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.stream.Collectors;
@RestController
@@ -35,13 +35,8 @@ public class GameSystemController {
private static final Logger log = LoggerFactory.getLogger(GameSystemController.class);
/**
* Timeout SSE = durée TOTALE maximale de l'import (pas un timeout d'inactivité :
* les heartbeats ne le réarment pas). Un livre entier sur un modèle local peut
* largement dépasser 15 min → 60 min. La déconnexion du client reste détectée
* immédiatement par ailleurs (échec d'envoi → interruption de l'import).
*/
private static final long IMPORT_SSE_TIMEOUT_MS = 60 * 60 * 1000L;
/** Timeout SSE généreux : un import de livre entier peut durer plusieurs minutes. */
private static final long IMPORT_SSE_TIMEOUT_MS = 15 * 60 * 1000L;
private final GameSystemService gameSystemService;
private final GameSystemMapper gameSystemMapper;
@@ -140,7 +135,7 @@ public class GameSystemController {
public SseEmitter importRulesStream(@RequestParam("file") MultipartFile file) throws IOException {
SseEmitter emitter = new SseEmitter(IMPORT_SSE_TIMEOUT_MS);
if (file == null || file.isEmpty()) {
sendImportError(emitter, new AtomicBoolean(false), "Fichier PDF vide.");
sendImportError(emitter, "Fichier PDF vide.");
return emitter;
}
// Les octets sont lus sur le thread servlet (le MultipartFile n'est plus
@@ -148,108 +143,46 @@ public class GameSystemController {
byte[] bytes = file.getBytes();
String filename = file.getOriginalFilename();
// Suivi de la déconnexion du navigateur : dès qu'un envoi échoue (ou que
// l'emitter se termine), on cesse d'envoyer ET on interrompt le streaming
// amont (l'exception ClientGone remonte dans le doOnNext du WebClient →
// annule la souscription → le Brain voit la coupure et stoppe le LLM).
AtomicBoolean clientGone = new AtomicBoolean(false);
emitter.onTimeout(() -> {
// Timeout = durée totale dépassée, mais la connexion est encore vivante :
// on envoie une vraie erreur au navigateur AVANT de fermer (sinon le flux
// se termine en silence et l'UI reste figée sur la barre de progression).
sendImportError(emitter, clientGone,
"L'import a dépassé la durée maximale autorisée et a été interrompu. "
+ "Réessayez avec un modèle plus rapide ou un PDF plus petit.");
clientGone.set(true);
});
emitter.onError(e -> clientGone.set(true));
taskExecutor.execute(() -> {
try {
gameSystemService.importRulesFromPdfStreaming(
bytes, filename,
progress -> sendImportEvent(emitter, clientGone, "progress", progress),
() -> sendImportHeartbeat(emitter, clientGone),
progress -> sendImportEvent(emitter, "progress", progress),
result -> {
sendImportEvent(emitter, clientGone, "done", result);
sendImportEvent(emitter, "done", result);
emitter.complete();
},
error -> {
if (clientGone.get()) {
// La "panne" amont n'est que l'écho de la déconnexion
// du navigateur : pas un échec d'import.
log.info("Import de règles (stream) interrompu : client déconnecté.");
return;
}
log.warn("Import de règles (stream) échoué : {}", error.getMessage());
sendImportError(emitter, clientGone, error.getMessage());
sendImportError(emitter, error.getMessage());
});
} catch (ClientGoneException e) {
log.info("Import de règles (stream) interrompu : client déconnecté.");
} catch (Exception e) {
log.warn("Import de règles (stream) échoué : {}", e.getMessage());
sendImportError(emitter, clientGone, e.getMessage());
sendImportError(emitter, e.getMessage());
}
});
return emitter;
}
/** Signale que le navigateur a fermé le flux SSE : inutile de continuer l'import. */
private static final class ClientGoneException extends RuntimeException {
ClientGoneException(Throwable cause) {
super("Client SSE déconnecté.", cause);
}
}
/** Sérialise `payload` en JSON et l'envoie comme évènement SSE nommé. */
private void sendImportEvent(
SseEmitter emitter, AtomicBoolean clientGone, String eventName, Object payload) {
if (clientGone.get()) {
throw new ClientGoneException(null);
}
private void sendImportEvent(SseEmitter emitter, String eventName, Object payload) {
try {
emitter.send(SseEmitter.event().name(eventName).data(
objectMapper.writeValueAsString(payload), MediaType.APPLICATION_JSON));
} catch (Exception e) {
// IOException OU IllegalStateException (emitter déjà terminé) : le client
// est parti. On marque l'état et on INTERROMPT le pipeline amont — sinon
// chaque évènement suivant rejouerait l'échec (bruit de logs + LLM gaspillé).
clientGone.set(true);
} catch (IOException e) {
emitter.completeWithError(e);
throw new ClientGoneException(e);
}
}
/**
* Keep-alive vers le navigateur pendant un appel LLM long : un commentaire SSE
* (ignoré par le front) suffit à réarmer le {@code proxy_read_timeout} de nginx.
*/
private void sendImportHeartbeat(SseEmitter emitter, AtomicBoolean clientGone) {
if (clientGone.get()) {
throw new ClientGoneException(null);
}
try {
emitter.send(SseEmitter.event().comment("keepalive"));
} catch (Exception e) {
clientGone.set(true);
emitter.completeWithError(e);
throw new ClientGoneException(e);
}
}
/** Envoie un évènement `error` {message} puis termine le flux. */
private void sendImportError(SseEmitter emitter, AtomicBoolean clientGone, String message) {
if (clientGone.get()) {
return; // le client n'est plus là pour lire le message d'erreur.
}
private void sendImportError(SseEmitter emitter, String message) {
try {
emitter.send(SseEmitter.event().name("error").data(
objectMapper.writeValueAsString(Map.of(
"message", message != null ? message : "Erreur inconnue.")),
MediaType.APPLICATION_JSON));
emitter.complete();
} catch (Exception e) {
clientGone.set(true);
} catch (IOException e) {
emitter.completeWithError(e);
}
}

View File

@@ -132,7 +132,6 @@ public class NotebookController {
StringBuilder assistant = new StringBuilder();
chatStreamer.stream(
sourceIds, history, context, deep,
sourcesJson -> sendSources(emitter, sourcesJson),
token -> { assistant.append(token); sendToken(emitter, token); },
progress -> sendProgress(emitter, progress),
() -> {
@@ -162,15 +161,6 @@ public class NotebookController {
}
}
private void sendSources(SseEmitter emitter, String sourcesJson) {
try {
// JSON brut du Brain ({"sources":[{source_id,page,score},…]}), relayé tel quel.
emitter.send(SseEmitter.event().name("sources").data(sourcesJson));
} catch (IOException | IllegalStateException e) {
// flux fermé/expiré : on cesse d'écrire
}
}
private void sendProgress(SseEmitter emitter, NotebookChatStreamer.Progress p) {
try {
emitter.send(SseEmitter.event().name("progress")

View File

@@ -43,15 +43,6 @@ public class NpcController {
return ResponseEntity.ok(dtos);
}
/** PNJ de toutes les campagnes liées au Lore donné — alimente le graphe du Lore. */
@GetMapping("/lore/{loreId}")
public ResponseEntity<List<NpcDTO>> getNpcsByLore(@PathVariable String loreId) {
List<NpcDTO> dtos = npcService.getNpcsByLoreId(loreId).stream()
.map(npcMapper::toDTO)
.collect(Collectors.toList());
return ResponseEntity.ok(dtos);
}
@PutMapping("/{id}")
public ResponseEntity<NpcDTO> updateNpc(@PathVariable String id, @RequestBody NpcDTO dto) {
Npc updated = npcService.updateNpc(id, toData(dto, dto.getOrder()));
@@ -73,7 +64,6 @@ public class NpcController {
dto.getImageValues(),
dto.getKeyValueValues(),
dto.getCampaignId(),
dto.getRelatedPageIds(),
dto.getFolder(),
order
);

View File

@@ -2,7 +2,6 @@ package com.loremind.infrastructure.web.dto.campaigncontext;
import lombok.Data;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@@ -21,8 +20,6 @@ public class NpcDTO {
private Map<String, List<String>> imageValues = new HashMap<>();
private Map<String, Map<String, String>> keyValueValues = new HashMap<>();
private String campaignId;
/** IDs de Pages de Lore référencées par ce PNJ (référence faible cross-context). */
private List<String> relatedPageIds = new ArrayList<>();
private String folder;
private int order;
}

View File

@@ -20,10 +20,6 @@ public class PageDTO {
private Map<String, String> values;
/** Pour chaque champ IMAGE du template, la liste ordonnee des IDs d'images. */
private Map<String, List<String>> imageValues;
/** Pour chaque champ KEY_VALUE_LIST du template : label → valeur. */
private Map<String, Map<String, String>> keyValueValues;
/** Pour chaque champ TABLE du template : lignes (colonne → cellule). */
private Map<String, List<Map<String, String>>> tableValues;
private String notes;
private List<String> tags;
private List<String> relatedPageIds;

View File

@@ -4,7 +4,6 @@ import com.loremind.domain.campaigncontext.Npc;
import com.loremind.infrastructure.web.dto.campaigncontext.NpcDTO;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.HashMap;
@Component
@@ -21,7 +20,6 @@ public class NpcMapper {
dto.setImageValues(n.getImageValues() != null ? new HashMap<>(n.getImageValues()) : new HashMap<>());
dto.setKeyValueValues(n.getKeyValueValues() != null ? new HashMap<>(n.getKeyValueValues()) : new HashMap<>());
dto.setCampaignId(n.getCampaignId());
dto.setRelatedPageIds(n.getRelatedPageIds() != null ? new ArrayList<>(n.getRelatedPageIds()) : new ArrayList<>());
dto.setFolder(n.getFolder());
dto.setOrder(n.getOrder());
return dto;
@@ -38,7 +36,6 @@ public class NpcMapper {
.imageValues(dto.getImageValues() != null ? new HashMap<>(dto.getImageValues()) : new HashMap<>())
.keyValueValues(dto.getKeyValueValues() != null ? new HashMap<>(dto.getKeyValueValues()) : new HashMap<>())
.campaignId(dto.getCampaignId())
.relatedPageIds(dto.getRelatedPageIds() != null ? new ArrayList<>(dto.getRelatedPageIds()) : new ArrayList<>())
.folder(dto.getFolder())
.order(dto.getOrder())
.build();

View File

@@ -23,8 +23,6 @@ public class PageMapper {
dto.setTitle(page.getTitle());
dto.setValues(CollectionUtils.copyMap(page.getValues()));
dto.setImageValues(CollectionUtils.copyMap(page.getImageValues()));
dto.setKeyValueValues(CollectionUtils.copyMap(page.getKeyValueValues()));
dto.setTableValues(CollectionUtils.copyMap(page.getTableValues()));
dto.setNotes(page.getNotes());
dto.setTags(CollectionUtils.copyList(page.getTags()));
dto.setRelatedPageIds(CollectionUtils.copyList(page.getRelatedPageIds()));
@@ -43,8 +41,6 @@ public class PageMapper {
.title(dto.getTitle())
.values(CollectionUtils.copyMap(dto.getValues()))
.imageValues(CollectionUtils.copyMap(dto.getImageValues()))
.keyValueValues(CollectionUtils.copyMap(dto.getKeyValueValues()))
.tableValues(CollectionUtils.copyMap(dto.getTableValues()))
.notes(dto.getNotes())
.tags(CollectionUtils.copyList(dto.getTags()))
.relatedPageIds(CollectionUtils.copyList(dto.getRelatedPageIds()))

View File

@@ -29,8 +29,7 @@ public class TemplateFieldMapper {
layoutStr = layout.name();
}
List<String> labels = null;
if ((field.getType() == FieldType.KEY_VALUE_LIST || field.getType() == FieldType.TABLE)
&& field.getLabels() != null) {
if (field.getType() == FieldType.KEY_VALUE_LIST && field.getLabels() != null) {
labels = new ArrayList<>(field.getLabels());
}
return new TemplateFieldDTO(field.getName(), typeStr, layoutStr, labels);
@@ -55,7 +54,7 @@ public class TemplateFieldMapper {
}
}
List<String> labels = null;
if ((type == FieldType.KEY_VALUE_LIST || type == FieldType.TABLE) && dto.getLabels() != null) {
if (type == FieldType.KEY_VALUE_LIST && dto.getLabels() != null) {
labels = new ArrayList<>(dto.getLabels());
}
return new TemplateField(dto.getName(), type, layout, labels);

View File

@@ -1,7 +1,6 @@
package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Npc;
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.campaigncontext.ports.NpcRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -30,9 +29,6 @@ public class NpcServiceTest {
@Mock
private NpcRepository npcRepository;
@Mock
private CampaignRepository campaignRepository;
@InjectMocks
private NpcService npcService;
@@ -55,7 +51,7 @@ public class NpcServiceTest {
Npc result = npcService.createNpc(
new NpcService.NpcData("Borin le forgeron", null, null,
Map.of("Notes", "Borin"), null, null, "camp-1", null, null, 5));
Map.of("Notes", "Borin"), null, null, "camp-1", null,5));
assertNotNull(result);
ArgumentCaptor<Npc> captor = ArgumentCaptor.forClass(Npc.class);
@@ -71,7 +67,7 @@ public class NpcServiceTest {
when(npcRepository.findByCampaignId("camp-1")).thenReturn(List.of(a, b));
when(npcRepository.save(any(Npc.class))).thenReturn(testNpc);
npcService.createNpc(new NpcService.NpcData("Nouveau", null, null, null, null, null, "camp-1", null, null, null));
npcService.createNpc(new NpcService.NpcData("Nouveau", null, null, null, null, null, "camp-1", null,null));
ArgumentCaptor<Npc> captor = ArgumentCaptor.forClass(Npc.class);
verify(npcRepository).save(captor.capture());
@@ -83,7 +79,7 @@ public class NpcServiceTest {
when(npcRepository.findByCampaignId("camp-1")).thenReturn(List.of());
when(npcRepository.save(any(Npc.class))).thenReturn(testNpc);
npcService.createNpc(new NpcService.NpcData("Premier", null, null, null, null, null, "camp-1", null, null, null));
npcService.createNpc(new NpcService.NpcData("Premier", null, null, null, null, null, "camp-1", null,null));
ArgumentCaptor<Npc> captor = ArgumentCaptor.forClass(Npc.class);
verify(npcRepository).save(captor.capture());
@@ -128,7 +124,7 @@ public class NpcServiceTest {
Npc result = npcService.updateNpc("npc-1",
new NpcService.NpcData("Borin renommé", null, null,
Map.of("Notes", "v2"), null, null, "camp-1", null, null, 7));
Map.of("Notes", "v2"), null, null, "camp-1", null,7));
assertEquals("Borin renommé", result.getName());
assertEquals("v2", result.getValues().get("Notes"));
@@ -142,7 +138,7 @@ public class NpcServiceTest {
Npc result = npcService.updateNpc("npc-1",
new NpcService.NpcData("Borin", null, null,
Map.of("Notes", "txt"), null, null, "camp-1", null, null, null));
Map.of("Notes", "txt"), null, null, "camp-1", null,null));
// testNpc avait order=1 → préservé
assertEquals(1, result.getOrder());
@@ -154,7 +150,7 @@ public class NpcServiceTest {
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
() -> npcService.updateNpc("missing",
new NpcService.NpcData("x", null, null, null, null, null, "camp-1", null, null, null)));
new NpcService.NpcData("x", null, null, null, null, null, "camp-1", null,null)));
assertTrue(ex.getMessage().contains("missing"));
verify(npcRepository, never()).save(any());
}

View File

@@ -1,12 +1,14 @@
# Configuration de test : vraie base PostgreSQL loremind_test.
#
# La base `loremind_test` doit exister sur l'instance locale (port 5432).
# AUCUN credential reel ici (fichier versionne !) : definissez les variables
# d'environnement en local (IDE ou shell) comme en CI :
# Les credentials sont ceux de `.env` a la racine du projet ; on les duplique
# ici car les tests Maven ne chargent pas le .env.
#
# Surchargables via variables d'environnement (CI) :
# SPRING_DATASOURCE_URL, SPRING_DATASOURCE_USERNAME, SPRING_DATASOURCE_PASSWORD
spring.datasource.url=${SPRING_DATASOURCE_URL:jdbc:postgresql://localhost:5432/loremind_test}
spring.datasource.username=${SPRING_DATASOURCE_USERNAME:loremind_test}
spring.datasource.password=${SPRING_DATASOURCE_PASSWORD:loremind_test}
spring.datasource.username=${SPRING_DATASOURCE_USERNAME:ietm64}
spring.datasource.password=${SPRING_DATASOURCE_PASSWORD:M&Ipourlavie64}
spring.datasource.driver-class-name=org.postgresql.Driver
# Configuration JPA pour les tests : schema recree a chaque run, pas de logs SQL.

View File

@@ -66,31 +66,5 @@
}
}
}
},
"schematics": {
"@schematics/angular:component": {
"type": "component"
},
"@schematics/angular:directive": {
"type": "directive"
},
"@schematics/angular:service": {
"type": "service"
},
"@schematics/angular:guard": {
"typeSeparator": "."
},
"@schematics/angular:interceptor": {
"typeSeparator": "."
},
"@schematics/angular:module": {
"typeSeparator": "."
},
"@schematics/angular:pipe": {
"typeSeparator": "."
},
"@schematics/angular:resolver": {
"typeSeparator": "."
}
}
}

11134
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "loremind-web",
"version": "0.12.3-beta",
"version": "0.11.3-beta",
"description": "LoreMind Frontend - Angular",
"scripts": {
"ng": "ng",
@@ -15,28 +15,26 @@
},
"private": true,
"dependencies": {
"@angular/animations": "^21.2.16",
"@angular/common": "^21.2.16",
"@angular/compiler": "^21.2.16",
"@angular/core": "^21.2.16",
"@angular/forms": "^21.2.16",
"@angular/platform-browser": "^21.2.16",
"@angular/platform-browser-dynamic": "^21.2.16",
"@angular/router": "^21.2.16",
"@angular/animations": "^17.0.0",
"@angular/common": "^17.0.0",
"@angular/compiler": "^17.0.0",
"@angular/core": "^17.0.0",
"@angular/forms": "^17.0.0",
"@angular/platform-browser": "^17.0.0",
"@angular/platform-browser-dynamic": "^17.0.0",
"@angular/router": "^17.0.0",
"dompurify": "^3.4.1",
"lucide-angular": "^1.0.0",
"marked": "^18.0.2",
"rxjs": "~7.8.0",
"tslib": "^2.3.0",
"zone.js": "~0.15.1"
"zone.js": "~0.14.2"
},
"devDependencies": {
"@angular-devkit/build-angular": "^21.2.14",
"@angular/cli": "^21.2.14",
"@angular/compiler-cli": "^21.2.16",
"@emnapi/core": "^1.11.0",
"@emnapi/runtime": "^1.11.0",
"@angular-devkit/build-angular": "^17.0.0",
"@angular/cli": "^17.0.0",
"@angular/compiler-cli": "^17.0.0",
"@playwright/test": "^1.59.1",
"typescript": "~5.9.3"
"typescript": "~5.2.2"
}
}

View File

@@ -3,7 +3,7 @@
<div class="app-container">
<app-sidebar></app-sidebar>
@if (sidebarConfig$ | async; as config) {
<ng-container *ngIf="sidebarConfig$ | async as config">
<app-secondary-sidebar
[title]="config.title"
[titleRoute]="config.titleRoute || null"
@@ -11,7 +11,7 @@
[createActions]="config.createActions"
[bottomPanel]="config.bottomPanel || null">
</app-secondary-sidebar>
}
</ng-container>
<main class="main-content">
<router-outlet></router-outlet>

View File

@@ -1,5 +1,5 @@
import { Component, HostListener } from '@angular/core';
import { AsyncPipe } from '@angular/common';
import { AsyncPipe, NgIf } from '@angular/common';
import { RouterOutlet } from '@angular/router';
import { SidebarComponent } from './sidebar/sidebar.component';
import { SecondarySidebarComponent } from './shared/secondary-sidebar/secondary-sidebar.component';
@@ -11,18 +11,20 @@ import { GlobalSearchService } from './services/global-search.service';
import { VersionCheckerService } from './services/version-checker.service';
@Component({
selector: 'app-root',
imports: [
selector: 'app-root',
standalone: true,
imports: [
RouterOutlet,
SidebarComponent,
SecondarySidebarComponent,
GlobalSearchComponent,
UpdateBannerComponent,
ConfirmDialogHostComponent,
AsyncPipe
],
templateUrl: './app.component.html',
styleUrls: ['./app.component.scss']
AsyncPipe,
NgIf,
],
templateUrl: './app.component.html',
styleUrls: ['./app.component.scss']
})
export class AppComponent {
readonly sidebarConfig$ = this.layoutService.secondarySidebar$;

View File

@@ -4,7 +4,6 @@ import { hiddenInDemoGuard } from './guards/demo-mode.guard';
export const routes: Routes = [
{ path: 'lore', loadComponent: () => import('./lore/lore.component').then(m => m.LoreComponent) },
{ path: 'lore/:id', loadComponent: () => import('./lore/lore-detail/lore-detail.component').then(m => m.LoreDetailComponent) },
{ path: 'lore/:loreId/graph', loadComponent: () => import('./lore/lore-graph/lore-graph.component').then(m => m.LoreGraphComponent) },
{ path: 'lore/:loreId/nodes/create', loadComponent: () => import('./lore/lore-node-create/lore-node-create.component').then(m => m.LoreNodeCreateComponent) },
{ path: 'lore/:loreId/folders/:parentId/create', loadComponent: () => import('./lore/lore-node-create/lore-node-create.component').then(m => m.LoreNodeCreateComponent) },
{ path: 'lore/:loreId/folders/:folderId', loadComponent: () => import('./lore/folder-view/folder-view.component').then(m => m.FolderViewComponent) },

View File

@@ -1,5 +1,5 @@
import { Component, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { forkJoin } from 'rxjs';
@@ -19,10 +19,11 @@ import { CAMPAIGN_ICON_OPTIONS } from '../../campaign-icons';
* le nombre d'arcs existants dans la campagne courante.
*/
@Component({
selector: 'app-arc-create',
imports: [ReactiveFormsModule, LucideAngularModule, IconPickerComponent],
templateUrl: './arc-create.component.html',
styleUrls: ['./arc-create.component.scss']
selector: 'app-arc-create',
standalone: true,
imports: [CommonModule, ReactiveFormsModule, LucideAngularModule, IconPickerComponent],
templateUrl: './arc-create.component.html',
styleUrls: ['./arc-create.component.scss']
})
export class ArcCreateComponent implements OnInit, OnDestroy {
readonly BookOpen = BookOpen;

View File

@@ -7,9 +7,9 @@
</div>
<div class="header-actions">
<button type="button" class="btn-ai"
(click)="toggleChat()"
[class.active]="chatOpen"
title="Ouvrir l'Assistant IA pour dialoguer autour de cet arc">
(click)="toggleChat()"
[class.active]="chatOpen"
title="Ouvrir l'Assistant IA pour dialoguer autour de cet arc">
<lucide-icon [img]="Sparkles" [size]="14"></lucide-icon>
Assistant IA
</button>
@@ -58,7 +58,7 @@
formControlName="name"
placeholder="Ex: L'Ombre du Nord"
[class.invalid]="form.get('name')?.invalid && form.get('name')?.touched"
/>
/>
</div>
<div class="field">
@@ -145,29 +145,25 @@
</div>
<!-- ===== Pages Lore associées (phase B2 cross-context) ===== -->
@if (loreId) {
<div class="field">
<label>Pages Lore associées</label>
<app-lore-link-picker
[value]="relatedPageIds"
[availablePages]="availablePages"
[loreId]="loreId"
(valueChange)="relatedPageIds = $event">
</app-lore-link-picker>
<small class="field-hint">
Liez cet arc à des PNJ, lieux ou éléments du Lore. Cliquez sur un chip pour ouvrir la page associée.
</small>
</div>
}
<div class="field" *ngIf="loreId">
<label>Pages Lore associées</label>
<app-lore-link-picker
[value]="relatedPageIds"
[availablePages]="availablePages"
[loreId]="loreId"
(valueChange)="relatedPageIds = $event">
</app-lore-link-picker>
<small class="field-hint">
Liez cet arc à des PNJ, lieux ou éléments du Lore. Cliquez sur un chip pour ouvrir la page associée.
</small>
</div>
@if (!loreId) {
<div class="field lore-hint">
<small class="field-hint">
💡 Cette campagne n'est associée à aucun univers. Associez-la à un Lore dans l'écran de la campagne
pour pouvoir lier cet arc à des pages du Lore (PNJ, lieux, etc.).
</small>
</div>
}
<div class="field lore-hint" *ngIf="!loreId">
<small class="field-hint">
💡 Cette campagne n'est associée à aucun univers. Associez-la à un Lore dans l'écran de la campagne
pour pouvoir lier cet arc à des pages du Lore (PNJ, lieux, etc.).
</small>
</div>
</form>

View File

@@ -1,5 +1,5 @@
import { Component, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { forkJoin, of } from 'rxjs';
@@ -32,10 +32,11 @@ import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dia
* personnages / lieux / objets du Lore.
*/
@Component({
selector: 'app-arc-edit',
imports: [ReactiveFormsModule, LucideAngularModule, LoreLinkPickerComponent, AiChatDrawerComponent, ImageGalleryComponent, IconPickerComponent],
templateUrl: './arc-edit.component.html',
styleUrls: ['./arc-edit.component.scss']
selector: 'app-arc-edit',
standalone: true,
imports: [CommonModule, ReactiveFormsModule, LucideAngularModule, LoreLinkPickerComponent, AiChatDrawerComponent, ImageGalleryComponent, IconPickerComponent],
templateUrl: './arc-edit.component.html',
styleUrls: ['./arc-edit.component.scss']
})
export class ArcEditComponent implements OnInit, OnDestroy {
readonly Trash2 = Trash2;

View File

@@ -1,150 +1,123 @@
@if (arc) {
<div class="view-page">
<header class="view-header">
<div>
<h1>
@if (arc.icon) {
<lucide-icon [img]="resolveCampaignIcon(arc.icon)" [size]="22" class="title-icon"></lucide-icon>
}
{{ arc.name }}
</h1>
<p class="view-subtitle">
{{ arc.type === 'HUB' ? 'Arc en hub (quêtes non linéaires)' : 'Arc narratif' }}
</p>
</div>
<div class="view-actions">
<button type="button" class="btn-primary" (click)="editMode()">
<lucide-icon [img]="Pencil" [size]="14"></lucide-icon>
Modifier
</button>
<button type="button" class="btn-danger" (click)="deleteArc()" title="Supprimer l'arc et tout son contenu">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
Supprimer
</button>
</div>
</header>
<!-- Illustrations (rendu editorial magazine) -->
@if ((arc.illustrationImageIds?.length ?? 0) > 0) {
<section class="view-section">
<app-image-gallery [imageIds]="arc.illustrationImageIds ?? []" [layout]="'EDITORIAL'"></app-image-gallery>
</section>
}
<!-- Cartes & plans -->
@if ((arc.mapImageIds?.length ?? 0) > 0) {
<section class="view-section">
<h2 class="view-section-title"><span class="view-section-icon">🗺️</span> Cartes & plans</h2>
<app-image-gallery [imageIds]="arc.mapImageIds ?? []" [layout]="'MAPS'"></app-image-gallery>
</section>
}
<!-- Vue Hub (scénario) : liste les quêtes et leurs conditions, sans statut.
Le statut effectif et les actions de jeu vivent dans l'écran d'une Partie. -->
@if (arc.type === 'HUB') {
<section class="view-section">
<h2 class="view-section-title"><span class="view-section-icon">🗺️</span> Quêtes du hub (scénario)</h2>
@if (hubQuests.length === 0) {
<p class="view-section-empty">
Aucune quête pour ce hub. Créez-en une pour démarrer.
</p>
}
@if (hubQuests.length > 0) {
<div class="hub-quest-grid">
@for (q of hubQuests; track q) {
<button type="button"
class="hub-quest-card"
(click)="openQuest(q)">
<div class="hub-quest-card-head">
@if (q.icon) {
<span class="hub-quest-card-icon">
<lucide-icon [img]="resolveCampaignIcon(q.icon)" [size]="18"></lucide-icon>
</span>
}
<span class="hub-quest-card-name">{{ q.name }}</span>
</div>
@if (q.description?.trim()) {
<p class="hub-quest-card-desc">{{ q.description }}</p>
}
@if ((q.prerequisites?.length ?? 0) > 0) {
<div class="hub-quest-card-locked-hint">
<lucide-icon [img]="AlertCircle" [size]="13"></lucide-icon>
<span>{{ q.prerequisites!.length }} condition(s) de déblocage</span>
<ul class="hub-quest-card-prereq-list">
@for (p of q.prerequisites; track p) {
<li>{{ describePrerequisite(p) }}</li>
}
</ul>
</div>
}
</button>
}
</div>
}
</section>
}
<section class="view-section">
<h2 class="view-section-title"><span class="view-section-icon">📜</span> Synopsis</h2>
@if (arc.description?.trim()) {
<p class="view-section-body">{{ arc.description }}</p>
} @else {
<p class="view-section-empty">Non renseigné</p>
}
</section>
<div class="view-row">
<section class="view-section">
<h2 class="view-section-title"><span class="view-section-icon"></span> Thèmes principaux</h2>
@if (arc.themes?.trim()) {
<p class="view-section-body">{{ arc.themes }}</p>
} @else {
<p class="view-section-empty">Non renseigné</p>
}
</section>
<section class="view-section">
<h2 class="view-section-title"><span class="view-section-icon">⚖️</span> Enjeux globaux</h2>
@if (arc.stakes?.trim()) {
<p class="view-section-body">{{ arc.stakes }}</p>
} @else {
<p class="view-section-empty">Non renseigné</p>
}
</section>
<div class="view-page" *ngIf="arc">
<header class="view-header">
<div>
<h1>
<lucide-icon *ngIf="arc.icon" [img]="resolveCampaignIcon(arc.icon)" [size]="22" class="title-icon"></lucide-icon>
{{ arc.name }}
</h1>
<p class="view-subtitle">
{{ arc.type === 'HUB' ? 'Arc en hub (quêtes non linéaires)' : 'Arc narratif' }}
</p>
</div>
<section class="view-section">
<h2 class="view-section-title"><span class="view-section-icon">🎁</span> Récompenses et progression</h2>
@if (arc.rewards?.trim()) {
<p class="view-section-body">{{ arc.rewards }}</p>
} @else {
<p class="view-section-empty">Non renseigné</p>
}
</section>
<section class="view-section">
<h2 class="view-section-title"><span class="view-section-icon">🎬</span> Dénouement prévu</h2>
@if (arc.resolution?.trim()) {
<p class="view-section-body">{{ arc.resolution }}</p>
} @else {
<p class="view-section-empty">Non renseigné</p>
}
</section>
<!-- Notes MJ (bloc privé rouge discret) -->
@if (arc.gmNotes?.trim()) {
<section class="view-section view-section--private">
<h2 class="view-section-title">
<span class="view-section-icon">🔒</span>
Notes et planification du MJ
</h2>
<p class="view-section-body">{{ arc.gmNotes }}</p>
</section>
}
<!-- Pages Lore liées (chips cliquables) -->
@if (loreId && (arc.relatedPageIds?.length ?? 0) > 0) {
<section class="view-section">
<h2 class="view-section-title"><span class="view-section-icon">🔗</span> Pages Lore associées</h2>
<div class="view-chips">
@for (relId of arc.relatedPageIds; track relId) {
<a class="view-chip"
[routerLink]="['/lore', loreId, 'pages', relId]">
{{ titleOfRelated(relId) }}
</a>
}
<div class="view-actions">
<button type="button" class="btn-primary" (click)="editMode()">
<lucide-icon [img]="Pencil" [size]="14"></lucide-icon>
Modifier
</button>
<button type="button" class="btn-danger" (click)="deleteArc()" title="Supprimer l'arc et tout son contenu">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
Supprimer
</button>
</div>
</header>
<!-- Illustrations (rendu editorial magazine) -->
<section class="view-section" *ngIf="(arc.illustrationImageIds?.length ?? 0) > 0">
<app-image-gallery [imageIds]="arc.illustrationImageIds ?? []" [layout]="'EDITORIAL'"></app-image-gallery>
</section>
<!-- Cartes & plans -->
<section class="view-section" *ngIf="(arc.mapImageIds?.length ?? 0) > 0">
<h2 class="view-section-title"><span class="view-section-icon">🗺️</span> Cartes & plans</h2>
<app-image-gallery [imageIds]="arc.mapImageIds ?? []" [layout]="'MAPS'"></app-image-gallery>
</section>
<!-- Vue Hub (scénario) : liste les quêtes et leurs conditions, sans statut.
Le statut effectif et les actions de jeu vivent dans l'écran d'une Partie. -->
<section class="view-section" *ngIf="arc.type === 'HUB'">
<h2 class="view-section-title"><span class="view-section-icon">🗺️</span> Quêtes du hub (scénario)</h2>
<p class="view-section-empty" *ngIf="hubQuests.length === 0">
Aucune quête pour ce hub. Créez-en une pour démarrer.
</p>
<div class="hub-quest-grid" *ngIf="hubQuests.length > 0">
<button type="button"
class="hub-quest-card"
*ngFor="let q of hubQuests"
(click)="openQuest(q)">
<div class="hub-quest-card-head">
<span class="hub-quest-card-icon" *ngIf="q.icon">
<lucide-icon [img]="resolveCampaignIcon(q.icon)" [size]="18"></lucide-icon>
</span>
<span class="hub-quest-card-name">{{ q.name }}</span>
</div>
</section>
}
<p class="hub-quest-card-desc" *ngIf="q.description?.trim()">{{ q.description }}</p>
<div class="hub-quest-card-locked-hint" *ngIf="(q.prerequisites?.length ?? 0) > 0">
<lucide-icon [img]="AlertCircle" [size]="13"></lucide-icon>
<span>{{ q.prerequisites!.length }} condition(s) de déblocage</span>
<ul class="hub-quest-card-prereq-list">
<li *ngFor="let p of q.prerequisites">{{ describePrerequisite(p) }}</li>
</ul>
</div>
</button>
</div>
</section>
<section class="view-section">
<h2 class="view-section-title"><span class="view-section-icon">📜</span> Synopsis</h2>
<p class="view-section-body" *ngIf="arc.description?.trim(); else emptyDesc">{{ arc.description }}</p>
<ng-template #emptyDesc><p class="view-section-empty">Non renseigné</p></ng-template>
</section>
<div class="view-row">
<section class="view-section">
<h2 class="view-section-title"><span class="view-section-icon"></span> Thèmes principaux</h2>
<p class="view-section-body" *ngIf="arc.themes?.trim(); else emptyThemes">{{ arc.themes }}</p>
<ng-template #emptyThemes><p class="view-section-empty">Non renseigné</p></ng-template>
</section>
<section class="view-section">
<h2 class="view-section-title"><span class="view-section-icon">⚖️</span> Enjeux globaux</h2>
<p class="view-section-body" *ngIf="arc.stakes?.trim(); else emptyStakes">{{ arc.stakes }}</p>
<ng-template #emptyStakes><p class="view-section-empty">Non renseigné</p></ng-template>
</section>
</div>
}
<section class="view-section">
<h2 class="view-section-title"><span class="view-section-icon">🎁</span> Récompenses et progression</h2>
<p class="view-section-body" *ngIf="arc.rewards?.trim(); else emptyRewards">{{ arc.rewards }}</p>
<ng-template #emptyRewards><p class="view-section-empty">Non renseigné</p></ng-template>
</section>
<section class="view-section">
<h2 class="view-section-title"><span class="view-section-icon">🎬</span> Dénouement prévu</h2>
<p class="view-section-body" *ngIf="arc.resolution?.trim(); else emptyResolution">{{ arc.resolution }}</p>
<ng-template #emptyResolution><p class="view-section-empty">Non renseigné</p></ng-template>
</section>
<!-- Notes MJ (bloc privé rouge discret) -->
<section class="view-section view-section--private" *ngIf="arc.gmNotes?.trim()">
<h2 class="view-section-title">
<span class="view-section-icon">🔒</span>
Notes et planification du MJ
</h2>
<p class="view-section-body">{{ arc.gmNotes }}</p>
</section>
<!-- Pages Lore liées (chips cliquables) -->
<section class="view-section" *ngIf="loreId && (arc.relatedPageIds?.length ?? 0) > 0">
<h2 class="view-section-title"><span class="view-section-icon">🔗</span> Pages Lore associées</h2>
<div class="view-chips">
<a class="view-chip"
*ngFor="let relId of arc.relatedPageIds"
[routerLink]="['/lore', loreId, 'pages', relId]">
{{ titleOfRelated(relId) }}
</a>
</div>
</section>
</div>

View File

@@ -1,5 +1,5 @@
import { Component, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { forkJoin, of } from 'rxjs';
import { switchMap } from 'rxjs/operators';
@@ -24,10 +24,11 @@ import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dia
* Bouton "Modifier" → /campaigns/:campaignId/arcs/:arcId/edit
*/
@Component({
selector: 'app-arc-view',
imports: [RouterModule, LucideAngularModule, ImageGalleryComponent],
templateUrl: './arc-view.component.html',
styleUrls: ['./arc-view.component.scss']
selector: 'app-arc-view',
standalone: true,
imports: [CommonModule, RouterModule, LucideAngularModule, ImageGalleryComponent],
templateUrl: './arc-view.component.html',
styleUrls: ['./arc-view.component.scss']
})
export class ArcViewComponent implements OnInit, OnDestroy {
readonly Pencil = Pencil;

View File

@@ -18,114 +18,102 @@
formControlName="name"
placeholder="Ex: L'Ombre du Nord, Les Héritiers Oubliés..."
[class.invalid]="form.get('name')?.invalid && form.get('name')?.touched"
/>
</div>
<div class="field">
<label for="campaign-description">Description / Pitch</label>
<textarea
id="campaign-description"
formControlName="description"
placeholder="Résumez l'intrigue principale de votre campagne..."
rows="5"
></textarea>
</div>
<div class="field">
<label for="campaign-player-count">Nombre de joueurs</label>
<input id="campaign-player-count" type="number" formControlName="playerCount" min="1" />
</div>
<div class="field">
<label for="campaign-lore">Univers associé</label>
<select id="campaign-lore" formControlName="loreId">
<option value="">— Aucun univers (campagne libre) —</option>
<option *ngFor="let lore of availableLores" [value]="lore.id">{{ lore.name }}</option>
</select>
<p class="hint">
Optionnel. Si associée, vous pourrez lier arcs, chapitres et scènes aux pages du Lore.
Laissez vide pour un one-shot ou si vous créerez le Lore plus tard.
</p>
</div>
<div class="field">
<label for="campaign-game-system">Système de JDR</label>
<select *ngIf="!creatingGameSystem" id="campaign-game-system" formControlName="gameSystemId">
<option value="">— Aucun (campagne générique) —</option>
<option *ngFor="let gs of availableGameSystems" [value]="gs.id">{{ gs.name }}</option>
<option [value]="CREATE_GAMESYSTEM_SENTINEL">+ Créer un nouveau système…</option>
</select>
<!-- Mode creation inline : remplace temporairement le select. -->
<div *ngIf="creatingGameSystem" class="inline-create">
<input
type="text"
[(ngModel)]="newGameSystemName"
[ngModelOptions]="{ standalone: true }"
placeholder="Nom du nouveau système (ex: D&D 5e, Nimble, Maison)"
(keydown.enter)="$event.preventDefault(); submitCreateGameSystem()"
(keydown.escape)="cancelCreateGameSystem()"
autofocus
/>
</div>
<div class="field">
<label for="campaign-description">Description / Pitch</label>
<textarea
id="campaign-description"
formControlName="description"
placeholder="Résumez l'intrigue principale de votre campagne..."
rows="5"
></textarea>
</div>
<div class="field">
<label for="campaign-player-count">Nombre de joueurs</label>
<input id="campaign-player-count" type="number" formControlName="playerCount" min="1" />
</div>
<div class="field">
<label for="campaign-lore">Univers associé</label>
<select id="campaign-lore" formControlName="loreId">
<option value="">— Aucun univers (campagne libre) —</option>
@for (lore of availableLores; track lore) {
<option [value]="lore.id">{{ lore.name }}</option>
}
</select>
<div class="inline-create-actions">
<button type="button" class="btn-inline-primary"
[disabled]="!newGameSystemName.trim() || creatingGameSystemInFlight"
(click)="submitCreateGameSystem()">
<lucide-icon [img]="Check" [size]="14"></lucide-icon>
Créer
</button>
<button type="button" class="btn-inline-secondary" (click)="cancelCreateGameSystem()">
Annuler
</button>
</div>
<p class="hint">
Optionnel. Si associée, vous pourrez lier arcs, chapitres et scènes aux pages du Lore.
Laissez vide pour un one-shot ou si vous créerez le Lore plus tard.
Création rapide — vous pourrez ajouter les règles, les templates de fiches PJ/PNJ
et le reste depuis la section "Systèmes" plus tard.
</p>
</div>
<div class="field">
<label for="campaign-game-system">Système de JDR</label>
@if (!creatingGameSystem) {
<select id="campaign-game-system" formControlName="gameSystemId">
<option value="">— Aucun (campagne générique) —</option>
@for (gs of availableGameSystems; track gs) {
<option [value]="gs.id">{{ gs.name }}</option>
}
<option [value]="CREATE_GAMESYSTEM_SENTINEL">+ Créer un nouveau système…</option>
</select>
}
<!-- Mode creation inline : remplace temporairement le select. -->
@if (creatingGameSystem) {
<div class="inline-create">
<input
type="text"
[(ngModel)]="newGameSystemName"
[ngModelOptions]="{ standalone: true }"
placeholder="Nom du nouveau système (ex: D&D 5e, Nimble, Maison)"
(keydown.enter)="$event.preventDefault(); submitCreateGameSystem()"
(keydown.escape)="cancelCreateGameSystem()"
autofocus
/>
<div class="inline-create-actions">
<button type="button" class="btn-inline-primary"
[disabled]="!newGameSystemName.trim() || creatingGameSystemInFlight"
(click)="submitCreateGameSystem()">
<lucide-icon [img]="Check" [size]="14"></lucide-icon>
Créer
</button>
<button type="button" class="btn-inline-secondary" (click)="cancelCreateGameSystem()">
Annuler
</button>
</div>
<p class="hint">
Création rapide — vous pourrez ajouter les règles, les templates de fiches PJ/PNJ
et le reste depuis la section "Systèmes" plus tard.
</p>
</div>
}
@if (!creatingGameSystem) {
<p class="hint">
Optionnel. Si défini, l'IA injectera les règles du système (classes, combat, lore...)
dans ses suggestions pour respecter les mécaniques du JDR.
</p>
}
@if (!creatingGameSystem) {
<p class="hint hint-warning">
⚠️ Le système de jeu choisi détermine aussi le <strong>template des fiches de PJ et PNJ</strong>.
Le changer plus tard rendra les champs des fiches existantes invisibles
(les données restent stockées mais ne s'afficheront qu'en revenant à l'ancien système).
Choisissez bien dès le départ si possible.
</p>
}
</div>
<div class="info-box">
<p><strong>💡 Organisation :</strong> Votre campagne sera structurée en :</p>
<ul>
<li><strong>Arcs</strong> - Les grandes phases narratives</li>
<li><strong>Chapitres</strong> - Les segments d'un arc</li>
<li><strong>Scènes</strong> - Les moments de jeu individuels</li>
</ul>
</div>
<div class="modal-actions">
<button type="submit" class="btn-primary" [disabled]="form.invalid">
<lucide-icon [img]="BookCopy" [size]="16"></lucide-icon>
Créer la campagne
</button>
<button type="button" class="btn-secondary" (click)="onCancel()">Annuler</button>
</div>
</form>
<p *ngIf="!creatingGameSystem" class="hint">
Optionnel. Si défini, l'IA injectera les règles du système (classes, combat, lore...)
dans ses suggestions pour respecter les mécaniques du JDR.
</p>
<p *ngIf="!creatingGameSystem" class="hint hint-warning">
⚠️ Le système de jeu choisi détermine aussi le <strong>template des fiches de PJ et PNJ</strong>.
Le changer plus tard rendra les champs des fiches existantes invisibles
(les données restent stockées mais ne s'afficheront qu'en revenant à l'ancien système).
Choisissez bien dès le départ si possible.
</p>
</div>
</div>
<div class="info-box">
<p><strong>💡 Organisation :</strong> Votre campagne sera structurée en :</p>
<ul>
<li><strong>Arcs</strong> - Les grandes phases narratives</li>
<li><strong>Chapitres</strong> - Les segments d'un arc</li>
<li><strong>Scènes</strong> - Les moments de jeu individuels</li>
</ul>
</div>
<div class="modal-actions">
<button type="submit" class="btn-primary" [disabled]="form.invalid">
<lucide-icon [img]="BookCopy" [size]="16"></lucide-icon>
Créer la campagne
</button>
<button type="button" class="btn-secondary" (click)="onCancel()">Annuler</button>
</div>
</form>
</div>
</div>

View File

@@ -1,5 +1,5 @@
import { Component, EventEmitter, OnInit, Output } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { FormsModule } from '@angular/forms';
import { LucideAngularModule, BookCopy, X, Plus, Check } from 'lucide-angular';
@@ -21,10 +21,11 @@ export interface CampaignCreatePayload {
}
@Component({
selector: 'app-campaign-create',
imports: [ReactiveFormsModule, FormsModule, LucideAngularModule],
templateUrl: './campaign-create.component.html',
styleUrls: ['./campaign-create.component.scss']
selector: 'app-campaign-create',
standalone: true,
imports: [CommonModule, ReactiveFormsModule, FormsModule, LucideAngularModule],
templateUrl: './campaign-create.component.html',
styleUrls: ['./campaign-create.component.scss']
})
export class CampaignCreateComponent implements OnInit {
@Output() close = new EventEmitter<void>();

View File

@@ -1,233 +1,206 @@
@if (campaign) {
<div class="campaign-detail">
<!-- ============ Header : mode lecture ============ -->
@if (!editing) {
<div class="detail-header">
<div class="header-texts">
<h1>{{ campaign.name }}</h1>
<p class="description">{{ campaign.description }}</p>
<div class="meta">
<span class="badge">{{ campaign.playerCount || 0 }} joueurs</span>
<!-- Badge "Univers" : lien vers le Lore associé si présent -->
@if (linkedLore) {
<a
class="badge badge-lore"
[routerLink]="['/lore', linkedLore.id]"
title="Ouvrir l'univers associé">
<lucide-icon [img]="Globe" [size]="12"></lucide-icon>
{{ linkedLore.name }}
</a>
}
<!-- Campagne liée à un Lore qui n'existe plus (supprimé ailleurs) -->
@if (campaign.loreId && !linkedLore) {
<span class="badge badge-lore-missing" title="L'univers associé est introuvable">
<lucide-icon [img]="Globe" [size]="12"></lucide-icon>
Univers introuvable
</span>
}
</div>
</div>
<div class="header-actions">
<button type="button" class="btn-secondary" (click)="startEdit()">
<lucide-icon [img]="Pencil" [size]="14"></lucide-icon>
Modifier
</button>
<button type="button" class="btn-danger" (click)="deleteCampaign()">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
Supprimer
</button>
</div>
<div class="campaign-detail" *ngIf="campaign">
<!-- ============ Header : mode lecture ============ -->
<div class="detail-header" *ngIf="!editing">
<div class="header-texts">
<h1>{{ campaign.name }}</h1>
<p class="description">{{ campaign.description }}</p>
<div class="meta">
<span class="badge">{{ campaign.playerCount || 0 }} joueurs</span>
<!-- Badge "Univers" : lien vers le Lore associé si présent -->
<a *ngIf="linkedLore"
class="badge badge-lore"
[routerLink]="['/lore', linkedLore.id]"
title="Ouvrir l'univers associé">
<lucide-icon [img]="Globe" [size]="12"></lucide-icon>
{{ linkedLore.name }}
</a>
<!-- Campagne liée à un Lore qui n'existe plus (supprimé ailleurs) -->
<span *ngIf="campaign.loreId && !linkedLore" class="badge badge-lore-missing" title="L'univers associé est introuvable">
<lucide-icon [img]="Globe" [size]="12"></lucide-icon>
Univers introuvable
</span>
</div>
}
<!-- ============ Header : mode édition inline ============ -->
@if (editing) {
<div class="detail-header edit-mode">
<div class="field">
<label>Nom</label>
<input type="text" [(ngModel)]="editName" name="editName" required />
</div>
<div class="field">
<label>Description</label>
<textarea [(ngModel)]="editDescription" name="editDescription" rows="3"></textarea>
</div>
<div class="field">
<label>Univers associé</label>
<select [(ngModel)]="editLoreId" name="editLoreId">
<option value="">— Aucun univers (campagne libre) —</option>
@for (lore of availableLores; track lore) {
<option [value]="lore.id">{{ lore.name }}</option>
}
</select>
</div>
<div class="field">
<label>Système de JDR</label>
@if (!creatingGameSystem) {
<select
</div>
<div class="header-actions">
<button type="button" class="btn-secondary" (click)="startEdit()">
<lucide-icon [img]="Pencil" [size]="14"></lucide-icon>
Modifier
</button>
<button type="button" class="btn-danger" (click)="deleteCampaign()">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
Supprimer
</button>
</div>
</div>
<!-- ============ Header : mode édition inline ============ -->
<div class="detail-header edit-mode" *ngIf="editing">
<div class="field">
<label>Nom</label>
<input type="text" [(ngModel)]="editName" name="editName" required />
</div>
<div class="field">
<label>Description</label>
<textarea [(ngModel)]="editDescription" name="editDescription" rows="3"></textarea>
</div>
<div class="field">
<label>Univers associé</label>
<select [(ngModel)]="editLoreId" name="editLoreId">
<option value="">— Aucun univers (campagne libre) —</option>
<option *ngFor="let lore of availableLores" [value]="lore.id">{{ lore.name }}</option>
</select>
</div>
<div class="field">
<label>Système de JDR</label>
<select *ngIf="!creatingGameSystem"
[(ngModel)]="editGameSystemId"
name="editGameSystemId"
(ngModelChange)="onEditGameSystemChange($event)">
<option value="">— Aucun (générique) —</option>
@for (gs of availableGameSystems; track gs) {
<option [value]="gs.id">{{ gs.name }}</option>
}
<option [value]="CREATE_GAMESYSTEM_SENTINEL">+ Créer un nouveau système…</option>
</select>
}
@if (creatingGameSystem) {
<div class="inline-create">
<input
type="text"
[(ngModel)]="newGameSystemName"
name="newGameSystemName"
placeholder="Nom du nouveau système (ex: D&D 5e, Nimble, Maison)"
(keydown.enter)="$event.preventDefault(); submitCreateGameSystem()"
(keydown.escape)="cancelCreateGameSystem()"
autofocus
/>
<div class="inline-create-actions">
<button type="button" class="btn-inline-primary"
[disabled]="!newGameSystemName.trim() || creatingGameSystemInFlight"
(click)="submitCreateGameSystem()">
<lucide-icon [img]="Check" [size]="14"></lucide-icon>
Créer
</button>
<button type="button" class="btn-inline-secondary" (click)="cancelCreateGameSystem()">
Annuler
</button>
</div>
</div>
}
</div>
<div class="header-actions">
<button type="button" class="btn-primary" (click)="saveEdit()" [disabled]="!editName.trim()">
Sauvegarder
</button>
<button type="button" class="btn-secondary" (click)="cancelEdit()">
Annuler
</button>
<option value="">— Aucun (générique) —</option>
<option *ngFor="let gs of availableGameSystems" [value]="gs.id">{{ gs.name }}</option>
<option [value]="CREATE_GAMESYSTEM_SENTINEL">+ Créer un nouveau système…</option>
</select>
<div *ngIf="creatingGameSystem" class="inline-create">
<input
type="text"
[(ngModel)]="newGameSystemName"
name="newGameSystemName"
placeholder="Nom du nouveau système (ex: D&D 5e, Nimble, Maison)"
(keydown.enter)="$event.preventDefault(); submitCreateGameSystem()"
(keydown.escape)="cancelCreateGameSystem()"
autofocus
/>
<div class="inline-create-actions">
<button type="button" class="btn-inline-primary"
[disabled]="!newGameSystemName.trim() || creatingGameSystemInFlight"
(click)="submitCreateGameSystem()">
<lucide-icon [img]="Check" [size]="14"></lucide-icon>
Créer
</button>
<button type="button" class="btn-inline-secondary" (click)="cancelCreateGameSystem()">
Annuler
</button>
</div>
</div>
</div>
<div class="header-actions">
<button type="button" class="btn-primary" (click)="saveEdit()" [disabled]="!editName.trim()">
Sauvegarder
</button>
<button type="button" class="btn-secondary" (click)="cancelEdit()">
Annuler
</button>
</div>
</div>
<section class="detail-section personas-section" *ngIf="!editing">
<div class="section-header">
<h2>Personnages</h2>
</div>
<!-- Les PJ ne sont plus rattachés à la campagne mais à une Partie (Playthrough) :
ils se gèrent depuis la page d'une Partie. Ici on ne liste que les PNJ
(donnée de scénario, partagée par toutes les Parties). -->
<!-- Sous-section : Personnages non-joueurs (PNJ) -->
<div class="persona-subsection">
<div class="subsection-header">
<h3>
<lucide-icon [img]="Drama" [size]="16"></lucide-icon>
Personnages non-joueurs
<span class="count-badge" *ngIf="npcs.length > 0">{{ npcs.length }}</span>
</h3>
<button class="btn-add" (click)="createNpc()">
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
Nouveau PNJ
</button>
</div>
<div class="characters-grid" *ngIf="npcs.length > 0">
<div class="character-card" *ngFor="let npc of npcs" (click)="viewNpc(npc)">
<lucide-icon [img]="Drama" [size]="20" class="character-icon character-icon--npc"></lucide-icon>
<div class="character-info">
<span class="character-name">{{ npc.name }}</span>
<span class="character-snippet">{{ personaSnippet(npc) }}</span>
</div>
</div>
}
@if (!editing) {
<section class="detail-section personas-section">
<div class="section-header">
<h2>Personnages</h2>
</div>
<!-- Les PJ ne sont plus rattachés à la campagne mais à une Partie (Playthrough) :
ils se gèrent depuis la page d'une Partie. Ici on ne liste que les PNJ
(donnée de scénario, partagée par toutes les Parties). -->
<!-- Sous-section : Personnages non-joueurs (PNJ) -->
<div class="persona-subsection">
<div class="subsection-header">
<h3>
<lucide-icon [img]="Drama" [size]="16"></lucide-icon>
Personnages non-joueurs
@if (npcs.length > 0) {
<span class="count-badge">{{ npcs.length }}</span>
}
</h3>
<button class="btn-add" (click)="createNpc()">
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
Nouveau PNJ
</button>
</div>
@if (npcs.length > 0) {
<div class="characters-grid">
@for (npc of npcs; track npc) {
<div class="character-card" (click)="viewNpc(npc)">
<lucide-icon [img]="Drama" [size]="20" class="character-icon character-icon--npc"></lucide-icon>
<div class="character-info">
<span class="character-name">{{ npc.name }}</span>
<span class="character-snippet">{{ personaSnippet(npc) }}</span>
</div>
</div>
}
</div>
}
@if (npcs.length === 0) {
<div class="empty-state empty-state--compact">
<p>Aucun PNJ pour le moment.</p>
<button class="btn-add-first" (click)="createNpc()">
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
Créer votre premier PNJ
</button>
</div>
}
</div>
</section>
}
@if (!editing) {
<section class="detail-section arcs-section">
<div class="section-header">
<h2>Arcs narratifs</h2>
<div class="section-header-actions">
<button class="btn-add" (click)="createArc()">
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
Nouvel arc
</button>
</div>
</div>
@if (arcs.length > 0) {
<div class="arcs-grid">
@for (arc of arcs; track arc) {
<div class="arc-card" (click)="openArc(arc)">
<lucide-icon [img]="Swords" [size]="24" class="arc-icon"></lucide-icon>
<span class="arc-name">{{ arc.name }}</span>
<span class="arc-meta">{{ chapterCountByArc[arc.id!] || 0 }} chapitres</span>
</div>
}
</div>
}
@if (arcs.length === 0) {
<div class="empty-state">
<lucide-icon [img]="Swords" [size]="40" class="empty-icon"></lucide-icon>
<p>Aucun arc narratif pour le moment.</p>
<button class="btn-add-first" (click)="createArc()">
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
Créer votre premier arc
</button>
</div>
}
</section>
}
<!-- ============ Parties (Playthroughs) ============ -->
@if (!editing) {
<section class="detail-section playthroughs-section">
<div class="section-header">
<h2>
<lucide-icon [img]="Dices" [size]="18"></lucide-icon>
Mes parties
</h2>
<button class="btn-add" [disabled]="newPlaythroughInFlight" (click)="createPlaythrough()">
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
Nouvelle partie
</button>
</div>
@if (playthroughs.length > 0) {
<div class="playthroughs-grid">
@for (p of playthroughs; track p) {
<div class="playthrough-card"
(click)="openPlaythrough(p)">
<lucide-icon [img]="Dices" [size]="22" class="playthrough-icon"></lucide-icon>
<div class="playthrough-info">
<span class="playthrough-name">{{ p.name }}</span>
@if (p.description) {
<span class="playthrough-meta">{{ p.description }}</span>
}
</div>
</div>
}
</div>
}
@if (playthroughs.length === 0) {
<div class="empty-state empty-state--compact">
<p>Aucune partie. Créez-en une pour démarrer une session avec une table.</p>
</div>
}
</section>
}
<!-- Sessions retirées : elles vivent désormais dans une Partie.
Les faits narratifs sont définis dans les conditions de chaque quête
(chapter-edit > « Conditions de déblocage »). -->
</div>
<div class="empty-state empty-state--compact" *ngIf="npcs.length === 0">
<p>Aucun PNJ pour le moment.</p>
<button class="btn-add-first" (click)="createNpc()">
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
Créer votre premier PNJ
</button>
</div>
</div>
}
</section>
<section class="detail-section arcs-section" *ngIf="!editing">
<div class="section-header">
<h2>Arcs narratifs</h2>
<div class="section-header-actions">
<button class="btn-add" (click)="createArc()">
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
Nouvel arc
</button>
</div>
</div>
<div class="arcs-grid" *ngIf="arcs.length > 0">
<div class="arc-card" *ngFor="let arc of arcs" (click)="openArc(arc)">
<lucide-icon [img]="Swords" [size]="24" class="arc-icon"></lucide-icon>
<span class="arc-name">{{ arc.name }}</span>
<span class="arc-meta">{{ chapterCountByArc[arc.id!] || 0 }} chapitres</span>
</div>
</div>
<div class="empty-state" *ngIf="arcs.length === 0">
<lucide-icon [img]="Swords" [size]="40" class="empty-icon"></lucide-icon>
<p>Aucun arc narratif pour le moment.</p>
<button class="btn-add-first" (click)="createArc()">
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
Créer votre premier arc
</button>
</div>
</section>
<!-- ============ Parties (Playthroughs) ============ -->
<section class="detail-section playthroughs-section" *ngIf="!editing">
<div class="section-header">
<h2>
<lucide-icon [img]="Dices" [size]="18"></lucide-icon>
Mes parties
</h2>
<button class="btn-add" [disabled]="newPlaythroughInFlight" (click)="createPlaythrough()">
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
Nouvelle partie
</button>
</div>
<div class="playthroughs-grid" *ngIf="playthroughs.length > 0">
<div class="playthrough-card"
*ngFor="let p of playthroughs"
(click)="openPlaythrough(p)">
<lucide-icon [img]="Dices" [size]="22" class="playthrough-icon"></lucide-icon>
<div class="playthrough-info">
<span class="playthrough-name">{{ p.name }}</span>
<span class="playthrough-meta" *ngIf="p.description">{{ p.description }}</span>
</div>
</div>
</div>
<div class="empty-state empty-state--compact" *ngIf="playthroughs.length === 0">
<p>Aucune partie. Créez-en une pour démarrer une session avec une table.</p>
</div>
</section>
<!-- Sessions retirées : elles vivent désormais dans une Partie.
Les faits narratifs sont définis dans les conditions de chaque quête
(chapter-edit > « Conditions de déblocage »). -->
</div>

View File

@@ -1,5 +1,5 @@
import { Component, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { LucideAngularModule, Swords, Plus, Globe, Pencil, Trash2, Dices, Drama, Check, Play, Upload, Sparkles } from 'lucide-angular';
@@ -26,10 +26,11 @@ import { loadCampaignTreeData, buildCampaignSidebarConfig, CampaignTreeData } fr
import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service';
@Component({
selector: 'app-campaign-detail',
imports: [FormsModule, LucideAngularModule, RouterLink],
templateUrl: './campaign-detail.component.html',
styleUrls: ['./campaign-detail.component.scss']
selector: 'app-campaign-detail',
standalone: true,
imports: [CommonModule, FormsModule, LucideAngularModule, RouterLink],
templateUrl: './campaign-detail.component.html',
styleUrls: ['./campaign-detail.component.scss']
})
export class CampaignDetailComponent implements OnInit, OnDestroy {
readonly Swords = Swords;

View File

@@ -13,228 +13,164 @@
</div>
<!-- Étape 1 : upload (masqué une fois en revue) -->
@if (!reviewing) {
<section class="upload-area">
<input #pdfInput type="file" accept="application/pdf,.pdf" hidden (change)="onPdfSelected($event)" />
<button type="button" class="btn-primary big" [disabled]="importing" (click)="pdfInput.click()">
<lucide-icon [img]="Upload" [size]="16"></lucide-icon>
{{ importing ? 'Import en cours…' : 'Choisir un PDF de campagne' }}
</button>
<!-- Progression live -->
@if (importing) {
<div class="import-progress">
<p class="import-phase">{{ importPhase }}</p>
@if (importProgress) {
<div class="progress-bar">
<div class="progress-fill"
[style.width.%]="importProgress.total ? (importProgress.current / importProgress.total) * 100 : 0">
</div>
</div>
}
@if (importCounts) {
<p class="import-counts">
Trouvé jusqu'ici : {{ importCounts.arcs }} arc(s) · {{ importCounts.chapters }} chapitre(s) · {{ importCounts.scenes }} scène(s) · {{ importCounts.npcs }} PNJ
</p>
}
<section class="upload-area" *ngIf="!reviewing">
<input #pdfInput type="file" accept="application/pdf,.pdf" hidden (change)="onPdfSelected($event)" />
<button type="button" class="btn-primary big" [disabled]="importing" (click)="pdfInput.click()">
<lucide-icon [img]="Upload" [size]="16"></lucide-icon>
{{ importing ? 'Import en cours…' : 'Choisir un PDF de campagne' }}
</button>
<!-- Progression live -->
<div class="import-progress" *ngIf="importing">
<p class="import-phase">{{ importPhase }}</p>
<div class="progress-bar" *ngIf="importProgress">
<div class="progress-fill"
[style.width.%]="importProgress.total ? (importProgress.current / importProgress.total) * 100 : 0">
</div>
}
@if (importError) {
<p class="import-error">{{ importError }}</p>
}
</section>
}
</div>
<p class="import-counts" *ngIf="importCounts">
Trouvé jusqu'ici : {{ importCounts.arcs }} arc(s) · {{ importCounts.chapters }} chapitre(s) · {{ importCounts.scenes }} scène(s)
</p>
</div>
<p class="import-error" *ngIf="importError">{{ importError }}</p>
</section>
<!-- Étape 2 : revue de l'arbre éditable -->
@if (reviewing) {
<section class="review-area">
<div class="review-header">
<p class="review-summary">
À créer : <strong>{{ arcCount }}</strong> nouvel(s) arc(s),
<strong>{{ chapterCount }}</strong> chapitre(s),
<strong>{{ sceneCount }}</strong> scène(s)@if (npcs.length > 0) {<span>,
<strong>{{ npcCount }}</strong> PNJ</span>}.
Les éléments <em>« déjà présent »</em> (grisés) sont affichés pour situer les ajouts ;
ils ne seront pas recréés. Modifiez les nouveaux, puis créez.
</p>
</div>
<div class="tree">
<!-- Arc -->
@for (arc of tree; track arc; let ai = $index) {
<div class="arc-card">
<div class="node-head arc-head" [class.existing-node]="arc.existing">
<button type="button" class="btn-collapse" (click)="toggleArc(arc)">
<lucide-icon [img]="arc.collapsed ? ChevronRight : ChevronDown" [size]="16"></lucide-icon>
</button>
<lucide-icon [img]="Swords" [size]="16" class="node-icon"></lucide-icon>
<input type="text" class="node-name" [(ngModel)]="arc.name" [name]="'arc-' + ai"
[readonly]="arc.existing" placeholder="Nom de l'arc" />
@if (arc.existing) {
<span class="badge-existing">déjà présent</span>
}
@if (!arc.existing) {
<button type="button" class="btn-remove" (click)="removeArc(ai)" title="Supprimer l'arc">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
</button>
}
</div>
@if (!arc.collapsed) {
<div class="node-body">
@if (!arc.existing) {
<div class="arc-type-toggle">
<span class="toggle-label">Type :</span>
<button type="button" class="type-btn" [class.active]="arc.type === 'LINEAR'"
(click)="setArcType(arc, 'LINEAR')">Linéaire</button>
<button type="button" class="type-btn" [class.active]="arc.type === 'HUB'"
(click)="setArcType(arc, 'HUB')">Hub (quêtes)</button>
</div>
}
@if (!arc.existing) {
<textarea class="node-desc" [(ngModel)]="arc.description" [name]="'arc-desc-' + ai"
rows="2" placeholder="Synopsis de l'arc (optionnel)"></textarea>
}
<!-- Chapitres -->
@for (chapter of arc.chapters; track chapter; let ci = $index) {
<div class="chapter-card">
<div class="node-head chapter-head" [class.existing-node]="chapter.existing">
<button type="button" class="btn-collapse" (click)="toggleChapter(chapter)">
<lucide-icon [img]="chapter.collapsed ? ChevronRight : ChevronDown" [size]="14"></lucide-icon>
</button>
<lucide-icon [img]="BookOpen" [size]="14" class="node-icon"></lucide-icon>
<input type="text" class="node-name" [(ngModel)]="chapter.name" [name]="'chap-' + ai + '-' + ci"
[readonly]="chapter.existing" placeholder="Nom du chapitre" />
@if (chapter.existing) {
<span class="badge-existing">déjà présent</span>
}
@if (!chapter.existing) {
<button type="button" class="btn-remove" (click)="removeChapter(arc, ci)" title="Supprimer le chapitre">
<lucide-icon [img]="Trash2" [size]="13"></lucide-icon>
</button>
}
</div>
@if (!chapter.collapsed) {
<div class="node-body">
@if (!chapter.existing) {
<textarea class="node-desc" [(ngModel)]="chapter.description" [name]="'chap-desc-' + ai + '-' + ci"
rows="2" placeholder="Synopsis du chapitre (optionnel)"></textarea>
}
<!-- Scènes -->
@for (scene of chapter.scenes; track scene; let si = $index) {
<div class="scene-card"
[class.existing-node]="scene.existing">
<div class="scene-row">
<lucide-icon [img]="MapPin" [size]="13" class="node-icon scene-icon"></lucide-icon>
<div class="scene-fields">
<input type="text" class="node-name" [(ngModel)]="scene.name"
[name]="'scene-' + ai + '-' + ci + '-' + si" [readonly]="scene.existing"
placeholder="Nom de la scène" />
@if (!scene.existing) {
<input type="text" class="node-desc-inline" [(ngModel)]="scene.description"
[name]="'scene-desc-' + ai + '-' + ci + '-' + si" placeholder="Synopsis (optionnel)" />
}
</div>
@if (scene.existing) {
<span class="badge-existing">déjà présent</span>
}
@if (!scene.existing) {
<button type="button" class="btn-details" (click)="toggleDetails(scene)"
title="Narration joueurs, notes MJ, pièces">
<lucide-icon [img]="scene.detailsOpen ? ChevronDown : ChevronRight" [size]="12"></lucide-icon>
Détails@if (scene.rooms.length) {
<span> · {{ scene.rooms.length }} pièce(s)</span>
}
</button>
}
@if (!scene.existing) {
<button type="button" class="btn-remove" (click)="removeScene(chapter, si)" title="Supprimer la scène">
<lucide-icon [img]="Trash2" [size]="13"></lucide-icon>
</button>
}
</div>
@if (scene.detailsOpen && !scene.existing) {
<div class="scene-details">
<label class="field-label">À lire aux joueurs</label>
<textarea class="node-desc" [(ngModel)]="scene.playerNarration"
[name]="'scene-pn-' + ai + '-' + ci + '-' + si" rows="3"
placeholder="Texte d'encadré lu aux joueurs (optionnel)"></textarea>
<label class="field-label">Notes MJ</label>
<textarea class="node-desc" [(ngModel)]="scene.gmNotes"
[name]="'scene-gm-' + ai + '-' + ci + '-' + si" rows="3"
placeholder="Secrets, développement, conséquences (optionnel)"></textarea>
<!-- Pièces (donjon) : vide = scène narrative classique -->
<span class="field-label">Pièces (lieu explorable)</span>
<div class="rooms">
@for (room of scene.rooms; track room; let ri = $index) {
<div class="room-row">
<input type="text" class="room-name" [(ngModel)]="room.name"
[name]="'room-' + ai + '-' + ci + '-' + si + '-' + ri" placeholder="Pièce" />
<input type="text" class="room-field" [(ngModel)]="room.description"
[name]="'room-desc-' + ai + '-' + ci + '-' + si + '-' + ri" placeholder="Description" />
<input type="text" class="room-field" [(ngModel)]="room.enemies"
[name]="'room-en-' + ai + '-' + ci + '-' + si + '-' + ri" placeholder="Ennemis" />
<input type="text" class="room-field" [(ngModel)]="room.loot"
[name]="'room-loot-' + ai + '-' + ci + '-' + si + '-' + ri" placeholder="Trésor" />
<button type="button" class="btn-remove" (click)="removeRoom(scene, ri)" title="Supprimer la pièce">
<lucide-icon [img]="Trash2" [size]="12"></lucide-icon>
</button>
</div>
}
<button type="button" class="btn-add-inline" (click)="addRoom(scene)">
<lucide-icon [img]="Plus" [size]="12"></lucide-icon> Pièce
</button>
</div>
</div>
}
</div>
}
<button type="button" class="btn-add-inline" (click)="addScene(chapter)">
<lucide-icon [img]="Plus" [size]="12"></lucide-icon> Scène
</button>
</div>
}
</div>
}
<button type="button" class="btn-add-inline" (click)="addChapter(arc)">
<lucide-icon [img]="Plus" [size]="12"></lucide-icon> Chapitre
</button>
</div>
}
<section class="review-area" *ngIf="reviewing">
<div class="review-header">
<p class="review-summary">
À créer : <strong>{{ arcCount }}</strong> nouvel(s) arc(s),
<strong>{{ chapterCount }}</strong> chapitre(s),
<strong>{{ sceneCount }}</strong> scène(s).
Les éléments <em>« déjà présent »</em> (grisés) sont affichés pour situer les ajouts ;
ils ne seront pas recréés. Modifiez les nouveaux, puis créez.
</p>
</div>
<div class="tree">
<!-- Arc -->
<div class="arc-card" *ngFor="let arc of tree; let ai = index">
<div class="node-head arc-head" [class.existing-node]="arc.existing">
<button type="button" class="btn-collapse" (click)="toggleArc(arc)">
<lucide-icon [img]="arc.collapsed ? ChevronRight : ChevronDown" [size]="16"></lucide-icon>
</button>
<lucide-icon [img]="Swords" [size]="16" class="node-icon"></lucide-icon>
<input type="text" class="node-name" [(ngModel)]="arc.name" [name]="'arc-' + ai"
[readonly]="arc.existing" placeholder="Nom de l'arc" />
<span class="badge-existing" *ngIf="arc.existing">déjà présent</span>
<button type="button" class="btn-remove" *ngIf="!arc.existing" (click)="removeArc(ai)" title="Supprimer l'arc">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
</button>
</div>
}
<div class="node-body" *ngIf="!arc.collapsed">
<div class="arc-type-toggle" *ngIf="!arc.existing">
<span class="toggle-label">Type :</span>
<button type="button" class="type-btn" [class.active]="arc.type === 'LINEAR'"
(click)="setArcType(arc, 'LINEAR')">Linéaire</button>
<button type="button" class="type-btn" [class.active]="arc.type === 'HUB'"
(click)="setArcType(arc, 'HUB')">Hub (quêtes)</button>
</div>
<textarea class="node-desc" *ngIf="!arc.existing" [(ngModel)]="arc.description" [name]="'arc-desc-' + ai"
rows="2" placeholder="Synopsis de l'arc (optionnel)"></textarea>
<!-- Chapitres -->
<div class="chapter-card" *ngFor="let chapter of arc.chapters; let ci = index">
<div class="node-head chapter-head" [class.existing-node]="chapter.existing">
<button type="button" class="btn-collapse" (click)="toggleChapter(chapter)">
<lucide-icon [img]="chapter.collapsed ? ChevronRight : ChevronDown" [size]="14"></lucide-icon>
</button>
<lucide-icon [img]="BookOpen" [size]="14" class="node-icon"></lucide-icon>
<input type="text" class="node-name" [(ngModel)]="chapter.name" [name]="'chap-' + ai + '-' + ci"
[readonly]="chapter.existing" placeholder="Nom du chapitre" />
<span class="badge-existing" *ngIf="chapter.existing">déjà présent</span>
<button type="button" class="btn-remove" *ngIf="!chapter.existing" (click)="removeChapter(arc, ci)" title="Supprimer le chapitre">
<lucide-icon [img]="Trash2" [size]="13"></lucide-icon>
</button>
</div>
<div class="node-body" *ngIf="!chapter.collapsed">
<textarea class="node-desc" *ngIf="!chapter.existing" [(ngModel)]="chapter.description" [name]="'chap-desc-' + ai + '-' + ci"
rows="2" placeholder="Synopsis du chapitre (optionnel)"></textarea>
<!-- Scènes -->
<div class="scene-card" *ngFor="let scene of chapter.scenes; let si = index"
[class.existing-node]="scene.existing">
<div class="scene-row">
<lucide-icon [img]="MapPin" [size]="13" class="node-icon scene-icon"></lucide-icon>
<div class="scene-fields">
<input type="text" class="node-name" [(ngModel)]="scene.name"
[name]="'scene-' + ai + '-' + ci + '-' + si" [readonly]="scene.existing"
placeholder="Nom de la scène" />
<input type="text" class="node-desc-inline" *ngIf="!scene.existing" [(ngModel)]="scene.description"
[name]="'scene-desc-' + ai + '-' + ci + '-' + si" placeholder="Synopsis (optionnel)" />
</div>
<span class="badge-existing" *ngIf="scene.existing">déjà présent</span>
<button type="button" class="btn-details" *ngIf="!scene.existing" (click)="toggleDetails(scene)"
title="Narration joueurs, notes MJ, pièces">
<lucide-icon [img]="scene.detailsOpen ? ChevronDown : ChevronRight" [size]="12"></lucide-icon>
Détails<span *ngIf="scene.rooms.length"> · {{ scene.rooms.length }} pièce(s)</span>
</button>
<button type="button" class="btn-remove" *ngIf="!scene.existing" (click)="removeScene(chapter, si)" title="Supprimer la scène">
<lucide-icon [img]="Trash2" [size]="13"></lucide-icon>
</button>
</div>
<div class="scene-details" *ngIf="scene.detailsOpen && !scene.existing">
<label class="field-label">À lire aux joueurs</label>
<textarea class="node-desc" [(ngModel)]="scene.playerNarration"
[name]="'scene-pn-' + ai + '-' + ci + '-' + si" rows="3"
placeholder="Texte d'encadré lu aux joueurs (optionnel)"></textarea>
<label class="field-label">Notes MJ</label>
<textarea class="node-desc" [(ngModel)]="scene.gmNotes"
[name]="'scene-gm-' + ai + '-' + ci + '-' + si" rows="3"
placeholder="Secrets, développement, conséquences (optionnel)"></textarea>
<!-- Pièces (donjon) : vide = scène narrative classique -->
<span class="field-label">Pièces (lieu explorable)</span>
<div class="rooms">
<div class="room-row" *ngFor="let room of scene.rooms; let ri = index">
<input type="text" class="room-name" [(ngModel)]="room.name"
[name]="'room-' + ai + '-' + ci + '-' + si + '-' + ri" placeholder="Pièce" />
<input type="text" class="room-field" [(ngModel)]="room.description"
[name]="'room-desc-' + ai + '-' + ci + '-' + si + '-' + ri" placeholder="Description" />
<input type="text" class="room-field" [(ngModel)]="room.enemies"
[name]="'room-en-' + ai + '-' + ci + '-' + si + '-' + ri" placeholder="Ennemis" />
<input type="text" class="room-field" [(ngModel)]="room.loot"
[name]="'room-loot-' + ai + '-' + ci + '-' + si + '-' + ri" placeholder="Trésor" />
<button type="button" class="btn-remove" (click)="removeRoom(scene, ri)" title="Supprimer la pièce">
<lucide-icon [img]="Trash2" [size]="12"></lucide-icon>
</button>
</div>
<button type="button" class="btn-add-inline" (click)="addRoom(scene)">
<lucide-icon [img]="Plus" [size]="12"></lucide-icon> Pièce
</button>
</div>
</div>
</div>
<button type="button" class="btn-add-inline" (click)="addScene(chapter)">
<lucide-icon [img]="Plus" [size]="12"></lucide-icon> Scène
</button>
</div>
</div>
<button type="button" class="btn-add-inline" (click)="addChapter(arc)">
<lucide-icon [img]="Plus" [size]="12"></lucide-icon> Chapitre
</button>
</div>
</div>
<button type="button" class="btn-add" (click)="addArc()">
<lucide-icon [img]="Plus" [size]="14"></lucide-icon> Ajouter un arc
</button>
</div>
<!-- PNJ détectés dans le PDF : revue par cases à cocher -->
@if (npcs.length > 0) {
<div class="npc-review">
<h3>PNJ et créatures détectés ({{ npcs.length }})</h3>
<p class="hint">
Cochez ceux à créer dans la campagne (description issue du livre).
Ceux déjà présents dans la campagne sont grisés et ne seront pas recréés.
</p>
<ul class="npc-list">
@for (npc of npcs; track npc.name) {
<li class="npc-row" [class.npc-existing]="npc.existing">
<label class="npc-check">
<input type="checkbox" [(ngModel)]="npc.selected" [disabled]="npc.existing">
<strong>{{ npc.name }}</strong>
@if (npc.existing) {
<em class="npc-tag">déjà présent</em>
}
</label>
@if (npc.description) {
<p class="npc-desc">{{ npc.description }}</p>
}
</li>
}
</ul>
</div>
}
<p class="apply-error" *ngIf="applyError">{{ applyError }}</p>
@if (applyError) {
<p class="apply-error">{{ applyError }}</p>
}
<div class="review-actions">
<button type="button" class="btn-primary" [disabled]="applying || !hasNewContent" (click)="apply()">
<lucide-icon [img]="Check" [size]="16"></lucide-icon>
@@ -243,6 +179,5 @@
<button type="button" class="btn-secondary" (click)="cancel()">Annuler</button>
</div>
</section>
}
</div>

View File

@@ -321,49 +321,3 @@
gap: 0.6rem;
margin-top: 1.5rem;
}
// --- Revue des PNJ détectés dans le PDF ------------------------------------
.npc-review {
margin-top: 1.25rem;
padding: 1rem;
border: 1px solid var(--border-color, #2e2e3a);
border-radius: 8px;
h3 { margin: 0 0 0.25rem; font-size: 1rem; }
.hint { margin: 0 0 0.75rem; font-size: 0.85rem; opacity: 0.75; }
}
.npc-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.npc-row {
padding: 0.5rem 0.65rem;
border-radius: 6px;
background: rgba(255, 255, 255, 0.03);
&.npc-existing { opacity: 0.55; }
.npc-check {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
}
.npc-tag {
font-size: 0.75rem;
opacity: 0.7;
}
.npc-desc {
margin: 0.3rem 0 0 1.6rem;
font-size: 0.85rem;
opacity: 0.85;
}
}

View File

@@ -1,5 +1,5 @@
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router } from '@angular/router';
import { FormsModule } from '@angular/forms';
import {
@@ -13,7 +13,7 @@ import { NpcService } from '../../../services/npc.service';
import { RandomTableService } from '../../../services/random-table.service';
import { CampaignSidebarService } from '../../../services/campaign-sidebar.service';
import { PageTitleService } from '../../../services/page-title.service';
import { ArcKind, ArcProposal, ChapterProposal, NpcProposal, SceneProposal } from '../../../services/campaign-import.model';
import { ArcKind, ArcProposal, ChapterProposal, SceneProposal } from '../../../services/campaign-import.model';
import { CampaignImportProposal } from '../../../services/campaign-import.model';
import { loadCampaignTreeData, CampaignTreeData } from '../../campaign-tree.helper';
import { of } from 'rxjs';
@@ -37,11 +37,6 @@ interface ArcNode {
name: string; description: string; type: ArcKind; chapters: ChapterNode[]; collapsed: boolean;
existing: boolean; existingId?: string;
}
/**
* PNJ détecté dans le PDF, à cocher à la revue. `existing` = un PNJ du même nom
* existe déjà dans la campagne (décoché et non créable, anti-doublon).
*/
interface NpcNode { name: string; description: string; selected: boolean; existing: boolean; }
/**
* Page d'import d'un PDF de campagne → arbre arc/chapitre/scène.
@@ -51,10 +46,11 @@ interface NpcNode { name: string; description: string; selected: boolean; existi
* Rien n'est créé tant que l'utilisateur n'a pas validé « Créer dans la campagne ».
*/
@Component({
selector: 'app-campaign-import',
imports: [FormsModule, LucideAngularModule],
templateUrl: './campaign-import.component.html',
styleUrls: ['./campaign-import.component.scss']
selector: 'app-campaign-import',
standalone: true,
imports: [CommonModule, FormsModule, LucideAngularModule],
templateUrl: './campaign-import.component.html',
styleUrls: ['./campaign-import.component.scss']
})
export class CampaignImportComponent implements OnInit {
readonly ArrowLeft = ArrowLeft;
@@ -74,7 +70,7 @@ export class CampaignImportComponent implements OnInit {
importing = false;
importPhase = '';
importProgress: { current: number; total: number } | null = null;
importCounts: { arcs: number; chapters: number; scenes: number; npcs: number } | null = null;
importCounts: { arcs: number; chapters: number; scenes: number } | null = null;
importError: string | null = null;
/** Vrai une fois la proposition reçue (on affiche l'arbre éditable). */
reviewing = false;
@@ -82,9 +78,6 @@ export class CampaignImportComponent implements OnInit {
// --- Arbre éditable ---
tree: ArcNode[] = [];
/** PNJ détectés dans le PDF (revue par cases à cocher). */
npcs: NpcNode[] = [];
/** Structure actuelle de la campagne (chargée pour la fusion à la revue). */
private existingData: CampaignTreeData | null = null;
@@ -144,21 +137,17 @@ export class CampaignImportComponent implements OnInit {
} else {
this.importPhase = `Analyse de la campagne… (${ev.current}/${ev.total})`;
this.importProgress = { current: ev.current, total: ev.total };
this.importCounts = {
arcs: ev.arcCount, chapters: ev.chapterCount,
scenes: ev.sceneCount, npcs: ev.npcCount ?? 0
};
this.importCounts = { arcs: ev.arcCount, chapters: ev.chapterCount, scenes: ev.sceneCount };
}
} else if (ev.type === 'done') {
this.importing = false;
this.importPhase = '';
this.importProgress = null;
if ((ev.arcs ?? []).length === 0 && (ev.npcs ?? []).length === 0) {
if ((ev.arcs ?? []).length === 0) {
this.importError = "Aucune structure narrative détectée dans ce PDF.";
this.reviewing = false;
} else {
this.tree = this.buildMergedTree(ev.arcs ?? []);
this.npcs = this.buildNpcNodes(ev.npcs ?? []);
this.tree = this.buildMergedTree(ev.arcs);
this.reviewing = true;
}
}
@@ -244,21 +233,6 @@ export class CampaignImportComponent implements OnInit {
return (a ?? '').trim().toLowerCase() === (b ?? '').trim().toLowerCase();
}
/**
* Construit la liste de revue des PNJ : cochés par défaut, sauf ceux dont le
* nom existe déjà dans la campagne (anti-doublon, affichés grisés).
*/
private buildNpcNodes(proposals: NpcProposal[]): NpcNode[] {
const existingNames = new Set(
(this.existingData?.npcs ?? []).map(n => (n.name ?? '').trim().toLowerCase()));
return proposals
.filter(p => (p.name ?? '').trim())
.map(p => {
const existing = existingNames.has(p.name.trim().toLowerCase());
return { name: p.name.trim(), description: p.description ?? '', selected: !existing, existing };
});
}
// --- Mappers proposition → nœud NEUF -------------------------------------
private newArcNode(a: ArcProposal): ArcNode {
@@ -337,14 +311,9 @@ export class CampaignImportComponent implements OnInit {
n + a.chapters.reduce((m, c) => m + c.scenes.filter(s => !s.existing && s.name.trim()).length, 0), 0);
}
/** Nombre de PNJ cochés (= qui seront créés). */
get npcCount(): number {
return this.npcs.filter(n => n.selected && !n.existing).length;
}
/** Vrai s'il y a au moins un nœud nouveau à créer (sinon « Créer » désactivé). */
get hasNewContent(): boolean {
return this.arcCount > 0 || this.chapterCount > 0 || this.sceneCount > 0 || this.npcCount > 0;
return this.arcCount > 0 || this.chapterCount > 0 || this.sceneCount > 0;
}
// --- Application (création) ----------------------------------------------
@@ -389,10 +358,7 @@ export class CampaignImportComponent implements OnInit {
}))
}))
}))
})),
npcs: this.npcs
.filter(n => n.selected && !n.existing && n.name.trim())
.map(n => ({ name: n.name.trim(), description: n.description.trim() }))
}))
};
this.service.applyStructure(this.campaignId, proposal).subscribe({

View File

@@ -8,20 +8,18 @@
<div class="campaigns-grid">
@for (campaign of campaigns; track campaign) {
<div class="campaign-card" (click)="navigateToDetail(campaign.id!)">
<div class="card-header">
<span class="status-badge en-cours">En cours</span>
<span class="card-date">{{ campaign.playerCount }} joueurs</span>
</div>
<h2>{{ campaign.name }}</h2>
<p class="card-description">{{ campaign.description }}</p>
<div class="card-stats">
<span>⚔️ {{ campaign.arcCount || 0 }} arcs</span>
<span>📖 {{ campaign.chapterCount || 0 }} chapitres</span>
</div>
<div class="campaign-card" *ngFor="let campaign of campaigns" (click)="navigateToDetail(campaign.id!)">
<div class="card-header">
<span class="status-badge en-cours">En cours</span>
<span class="card-date">{{ campaign.playerCount }} joueurs</span>
</div>
}
<h2>{{ campaign.name }}</h2>
<p class="card-description">{{ campaign.description }}</p>
<div class="card-stats">
<span>⚔️ {{ campaign.arcCount || 0 }} arcs</span>
<span>📖 {{ campaign.chapterCount || 0 }} chapitres</span>
</div>
</div>
<div class="campaign-card card-new" (click)="openCreateModal()">
<div class="new-icon">
@@ -37,9 +35,8 @@
</div>
@if (showCreateModal) {
<app-campaign-create
(close)="onModalClose()"
(created)="onCampaignCreated($event)">
</app-campaign-create>
}
<app-campaign-create
*ngIf="showCreateModal"
(close)="onModalClose()"
(created)="onCampaignCreated($event)">
</app-campaign-create>

View File

@@ -1,5 +1,5 @@
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Router } from '@angular/router';
import { LucideAngularModule, Map, Plus } from 'lucide-angular';
import { CampaignService } from '../services/campaign.service';
@@ -8,10 +8,11 @@ import { Campaign } from '../services/campaign.model';
import { CampaignCreateComponent, CampaignCreatePayload } from './campaign/campaign-create/campaign-create.component';
@Component({
selector: 'app-campaigns',
imports: [LucideAngularModule, CampaignCreateComponent],
templateUrl: './campaigns.component.html',
styleUrls: ['./campaigns.component.scss']
selector: 'app-campaigns',
standalone: true,
imports: [CommonModule, LucideAngularModule, CampaignCreateComponent],
templateUrl: './campaigns.component.html',
styleUrls: ['./campaigns.component.scss']
})
export class CampaignsComponent implements OnInit {
readonly Map = Map;

View File

@@ -2,9 +2,7 @@
<div class="page-header">
<h1>{{ isHub ? 'Créer une nouvelle quête' : 'Créer un nouveau chapitre' }}</h1>
@if (arcName) {
<p class="arc-ref">{{ isHub ? 'Hub' : 'Arc' }} : {{ arcName }}</p>
}
<p class="arc-ref" *ngIf="arcName">{{ isHub ? 'Hub' : 'Arc' }} : {{ arcName }}</p>
</div>
<form [formGroup]="form" (ngSubmit)="submit()" class="chapter-form">
@@ -17,7 +15,7 @@
formControlName="name"
[placeholder]="isHub ? 'Ex: Sauver le marchand disparu' : 'Ex: Chapitre 1: Les Disparitions'"
[class.invalid]="form.get('name')?.invalid && form.get('name')?.touched"
/>
/>
</div>
<div class="field">

View File

@@ -1,5 +1,5 @@
import { Component, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { forkJoin } from 'rxjs';
@@ -18,10 +18,11 @@ import { CAMPAIGN_ICON_OPTIONS } from '../../campaign-icons';
* Route : /campaigns/:campaignId/arcs/:arcId/chapters/create
*/
@Component({
selector: 'app-chapter-create',
imports: [ReactiveFormsModule, LucideAngularModule, IconPickerComponent],
templateUrl: './chapter-create.component.html',
styleUrls: ['./chapter-create.component.scss']
selector: 'app-chapter-create',
standalone: true,
imports: [CommonModule, ReactiveFormsModule, LucideAngularModule, IconPickerComponent],
templateUrl: './chapter-create.component.html',
styleUrls: ['./chapter-create.component.scss']
})
export class ChapterCreateComponent implements OnInit, OnDestroy {
readonly campaignIconOptions = CAMPAIGN_ICON_OPTIONS;

View File

@@ -7,9 +7,9 @@
</div>
<div class="header-actions">
<button type="button" class="btn-ai"
(click)="toggleChat()"
[class.active]="chatOpen"
title="Ouvrir l'Assistant IA pour dialoguer autour de ce chapitre">
(click)="toggleChat()"
[class.active]="chatOpen"
title="Ouvrir l'Assistant IA pour dialoguer autour de ce chapitre">
<lucide-icon [img]="Sparkles" [size]="14"></lucide-icon>
Assistant IA
</button>
@@ -27,7 +27,7 @@
<form [formGroup]="form" (ngSubmit)="submit()" class="edit-form">
<!-- Conditions de déblocage (scénario) — pour TOUT chapitre : arc HUB (= quête)
comme arc linéaire (= chapitre conditionnel). Vide = disponible d'emblée. -->
comme arc linéaire (= chapitre conditionnel). Vide = disponible d'emblée. -->
<div class="hub-section">
<h2 class="hub-section-title">
{{ parentArc?.type === 'HUB' ? '🗺️ Conditions de déblocage de la quête' : '🔒 Conditions de déblocage du chapitre' }}
@@ -78,7 +78,7 @@
formControlName="name"
placeholder="Ex: Chapitre 1: Les Disparitions"
[class.invalid]="form.get('name')?.invalid && form.get('name')?.touched"
/>
/>
</div>
<div class="field">
@@ -129,29 +129,25 @@
</div>
<!-- ===== Pages Lore associées (B2 cross-context) ===== -->
@if (loreId) {
<div class="field">
<label>Pages Lore associées</label>
<app-lore-link-picker
[value]="relatedPageIds"
[availablePages]="availablePages"
[loreId]="loreId"
(valueChange)="relatedPageIds = $event">
</app-lore-link-picker>
<small class="field-hint">
Liez ce chapitre à des PNJ, lieux ou éléments du Lore qui y apparaissent.
</small>
</div>
}
<div class="field" *ngIf="loreId">
<label>Pages Lore associées</label>
<app-lore-link-picker
[value]="relatedPageIds"
[availablePages]="availablePages"
[loreId]="loreId"
(valueChange)="relatedPageIds = $event">
</app-lore-link-picker>
<small class="field-hint">
Liez ce chapitre à des PNJ, lieux ou éléments du Lore qui y apparaissent.
</small>
</div>
@if (!loreId) {
<div class="field">
<small class="field-hint">
💡 Cette campagne n'est associée à aucun univers. Associez-la à un Lore dans l'écran de la campagne
pour pouvoir lier ce chapitre à des pages du Lore.
</small>
</div>
}
<div class="field" *ngIf="!loreId">
<small class="field-hint">
💡 Cette campagne n'est associée à aucun univers. Associez-la à un Lore dans l'écran de la campagne
pour pouvoir lier ce chapitre à des pages du Lore.
</small>
</div>
</form>

View File

@@ -1,5 +1,5 @@
import { Component, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { forkJoin, of } from 'rxjs';
@@ -32,8 +32,10 @@ import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dia
* prérequis (conditions de déblocage), qui font partie du scénario.
*/
@Component({
selector: 'app-chapter-edit',
imports: [
selector: 'app-chapter-edit',
standalone: true,
imports: [
CommonModule,
ReactiveFormsModule,
LucideAngularModule,
LoreLinkPickerComponent,
@@ -41,9 +43,9 @@ import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dia
ImageGalleryComponent,
IconPickerComponent,
PrerequisiteEditorComponent
],
templateUrl: './chapter-edit.component.html',
styleUrls: ['./chapter-edit.component.scss']
],
templateUrl: './chapter-edit.component.html',
styleUrls: ['./chapter-edit.component.scss']
})
export class ChapterEditComponent implements OnInit, OnDestroy {
readonly Trash2 = Trash2;

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