Compare commits

..

66 Commits

Author SHA1 Message Date
504c4b7b6c Plusieurs ajouts :
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Build & Push Images / build-switcher (push) Has been cancelled
- Possibilité de configurer des lieux dans une scène : permet de configurer un donjon par exemple avec les pièces, les trésors par pièce, la narration..... Mise en place également de conditions permettant de conditionner le déblocage des quêtes.
- Possibilité de transformé un arc en instance non linéaire afin de faire un hub. Permet de jouer de préparer des campagnes type Dragon of Icespire peak plus facilement.
- Configuration de partie : chaque partie va contenir les séances, ce qui permettra de suivre le déblocage des conditions pour les quêtes.

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

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

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

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

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

Passage version 0.6.5
2026-04-25 11:41:14 +02:00
854110683b Correction d'un test unitaire
Ajout d'un champs image dans les templates par défaut en + du champs nom, description pour avoir un exemple
Correction du visuel du champs d'ajout lors de la modification d'un template (apparition ligne pleine au lieu de texte en pointillé)
Ajout d'un intercepteur pour la partie démo de l'application afin de bien rafraichir le cache angular lorsque le temps de démo est expiré
2026-04-25 09:23:56 +02:00
3ad8145572 Fix : problème d'ascenseur en bas de la page au niveau des templates
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Sélection du template par défaut lors de la création d'une page en fonction du dossier
Passage v0.6.2
2026-04-25 01:39:05 +02:00
0d27ea71b0 Ajout de test, correctif d'un problème d'horloge pour le workflow gitea actions pour le e2e 2026-04-25 00:51:32 +02:00
f6b4e3ff82 Fix workflow gitea action pour e2e (tests automatisés via playwright) + correction d'une incohérence dans l'API coté java. Ajout d'autres tests utilisateur 2026-04-25 00:45:04 +02:00
2a438b79ce Mise en place de tests utilisateurs avec playwright pour la partie angular + corrections au niveau des labels avec for et id pour cliquer dessus 2026-04-25 00:25:53 +02:00
6ab7f64909 Fix rate limit derriere Cloudflare + CORS sur POST demo 2026-04-24 08:55:40 +02:00
0ad1d09802 Fix CORS 403 sur POST : passer APP_CORS_ALLOWED_ORIGINS au core démo 2026-04-24 08:46:26 +02:00
c6a228a48f Remplace docker SDK par appels HTTP directs (zero deps)
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
2026-04-24 07:39:49 +02:00
9eabd2abf8 Docker SDK v28 pour resoudre les conflits transitifs 2026-04-24 07:33:48 +02:00
a23f82d635 Correction d'une dépendance go 2026-04-24 07:30:20 +02:00
280dced405 Mise à jour du dockerfile suite à une dépendance trop ancienne sur go 2026-04-24 07:26:42 +02:00
987187943e Orchestrateur go pour lancer la démo et mettre en place plusieurs instances docker 2026-04-23 17:49:26 +02:00
2e7d46cc63 Changement dans la config pour éviter les url en dur + mise en place d'un mode démo 2026-04-23 17:15:08 +02:00
93deb73ade Version 0.6.1
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
2026-04-23 14:36:09 +02:00
4f1b8fdd55 Ajout des personnage dans la sidebar de la campagne 2026-04-23 14:34:07 +02:00
035a2553d1 Mutualisation de la version pour ne pas l'oublier dans le footer du front ;
Some checks failed
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Build & Push Images / build (brain) (push) Has been cancelled
Passage en version v 0.6.0
2026-04-23 14:12:24 +02:00
5bbae9e130 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
5121e2d2e3 Mise en place de la possibilité de supprimer des lores / campagnes d'un seul coup 2026-04-23 11:51:03 +02:00
5f5b2ecb87 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
ceabf53c0a Correction d'un test unitaire
Some checks failed
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Build & Push Images / build (brain) (push) Has been cancelled
2026-04-22 13:38:48 +02:00
43e375e169 Passage en v 0.5.0
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
2026-04-22 13:33:47 +02:00
68fd1698b3 Mise en place d'un bouton + au hover plutot qu'un affichage constant 2026-04-22 13:31:06 +02:00
735dd2eee0 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
5ea3a5097f 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
63a3c21bd2 Ecriture de tests unitaires coté java pour améliorer la stabilité de l'application 2026-04-22 07:46:24 +02:00
213 changed files with 306 additions and 13286 deletions

1
.gitignore vendored
View File

@@ -108,4 +108,3 @@ docker-compose.override.yml
# ============================================================================
relay/
scripts/bump-version.mjs
brain/data/notebooks/5.json

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,154 +0,0 @@
"""Use case « Analyse approfondie » d'un notebook : map-reduce sur TOUT le document.
Contrairement au chat RAG (qui ne ramène que les top-k extraits), ce mode lit
l'INTÉGRALITÉ des sources par lots :
- MAP : pour chaque lot, le modèle extrait ce qui est pertinent pour la question
(ou « RAS » si rien) ;
- REDUCE : il synthétise toutes les notes en une réponse finale (streamée).
→ Répond aux questions globales/exhaustives (« liste tous les… ») quel que soit le
modèle, au prix de plusieurs appels (comme l'import). Le lot est dimensionné par
`batch_tokens` (= taille de morceau d'import) : avec un modèle gros-contexte, peu de
lots ; avec un petit modèle local, plus de lots (mais ça reste exhaustif).
"""
from __future__ import annotations
import logging
from typing import AsyncIterator
import tiktoken
from app.application.llm_retry import generate_with_retry
from app.domain.models import ChatMessage
from app.domain.ports import LLMChatProvider, LLMProvider, LLMProviderError
from app.infrastructure import vector_store
logger = logging.getLogger(__name__)
_NO_MATCH = "RAS"
_MAP_TEMPERATURE = 0.2
_MAP_PROMPT = """Voici un EXTRAIT d'un document. Extrais UNIQUEMENT les informations
pertinentes pour répondre à la question ci-dessous. Conserve les détails utiles et
indique les numéros de page (format « p. X »). Si l'extrait ne contient RIEN de
pertinent, réponds EXACTEMENT « {no_match} » et rien d'autre.
QUESTION : {question}
--- EXTRAIT ---
{excerpt}
--- FIN EXTRAIT ---
Informations pertinentes (ou « {no_match} ») :"""
_REDUCE_SYSTEM = """Tu es l'assistant-MJ d'un jeu de rôle. Tu réponds à la demande du MJ en
t'appuyant sur TROIS sources : (1) des NOTES extraites de l'ENSEMBLE du document source (vue
complète — mais POSSIBLEMENT VIDE si rien d'utile n'y figure), (2) le contexte de sa CAMPAGNE,
(3) la conversation ci-dessous.
- Si les notes contiennent des éléments utiles : exploite-les et CITE les pages (« p. X »).
- Si les notes sont VIDES ou pauvres (cas fréquent d'une demande CRÉATIVE portant sur des
éléments INVENTÉS par le MJ) : ne te bloque surtout PAS. Aide-le quand même en t'appuyant
sur sa CAMPAGNE, la CONVERSATION et ta connaissance du genre — propose des adaptations
concrètes (arcs, chapitres, scènes, PNJ), structurées et jouables.
- Sois concret et utile. N'affirme rien de FAUX sur le contenu du document.
{context_block}
--- NOTES EXTRAITES DE TOUT LE DOCUMENT ---
{notes_block}
--- FIN DES NOTES ---
Réponds en français."""
class NotebookDeepUseCase:
def __init__(self, llm: LLMProvider, batch_tokens: int = 10000) -> None:
self._llm = llm
self._batch_tokens = max(2000, batch_tokens)
async def stream(
self,
source_ids: list[str],
messages: list[ChatMessage],
context: str = "",
history_limit: int = 8,
) -> AsyncIterator[dict]:
"""Yield des évènements : {type:'progress',current,total}, {type:'token',token},
{type:'done'}. (Les erreurs LLM des lots sont tolérées : lot ignoré.)
La dernière question utilisateur sert à la LECTURE du document (map) ; la
SYNTHÈSE (reduce) reçoit les `history_limit` derniers messages → les relances
conversationnelles (« et pour les autres ? ») fonctionnent aussi en approfondi.
"""
question = next((m.content for m in reversed(messages) if m.role == "user"), "")
chunks: list[dict] = []
for sid in source_ids:
chunks.extend(vector_store.all_chunks(sid))
if not chunks:
yield {"type": "token", "token": "Aucune source indexée à analyser."}
yield {"type": "done"}
return
batches = self._group(chunks)
total = len(batches)
notes: list[str] = []
for i, batch in enumerate(batches):
yield {"type": "progress", "current": i, "total": total}
excerpt = "\n\n".join(
f"(p. {c['page']}) {c['text'].strip()}" if c.get("page") else c["text"].strip()
for c in batch
)
prompt = _MAP_PROMPT.format(no_match=_NO_MATCH, question=question, excerpt=excerpt)
try:
raw = await generate_with_retry(self._llm, prompt, temperature=_MAP_TEMPERATURE)
except LLMProviderError as exc:
logger.warning("Analyse approfondie : lot %s/%s ignoré : %s", i + 1, total, exc)
continue
answer = raw.strip()
if answer and answer.upper().rstrip(".") != _NO_MATCH:
notes.append(answer)
yield {"type": "progress", "current": total, "total": total}
notes_block = "\n\n".join(notes) if notes else "(aucune information pertinente trouvée dans le document)"
context_block = (
f"--- TA CAMPAGNE (structure, PNJ, univers) ---\n{context.strip()}\n--- FIN CAMPAGNE ---\n\n"
if context.strip() else ""
)
system_prompt = _REDUCE_SYSTEM.format(context_block=context_block, notes_block=notes_block)
# Historique récent pour la cohérence des relances ; on garantit que le
# dernier message est bien la question courante.
reduce_messages = messages[-history_limit:] if messages else [ChatMessage(role="user", content=question)]
llm_chat: LLMChatProvider = self._llm # type: ignore[assignment]
produced = False
async for token in llm_chat.stream_chat(reduce_messages, system_prompt=system_prompt):
if token:
produced = True
yield {"type": "token", "token": token}
if not produced:
# Jamais de bulle vide : message de repli + orientation vers le mode rapide,
# mieux adapté aux demandes créatives (et qui propose des cartes d'action).
yield {"type": "token", "token": (
"Je n'ai pas trouvé d'éléments pertinents dans le document pour cette demande "
"(elle porte sans doute sur des éléments que tu as inventés). Pour une "
"**adaptation créative** — proposer des arcs, chapitres, scènes ou PNJ — "
"utilise plutôt le bouton **« Envoyer »** (mode rapide) : il est conversationnel, "
"voit ta campagne, et te propose des cartes « Créer dans la campagne »."
)}
yield {"type": "done"}
def _group(self, chunks: list[dict]) -> list[list[dict]]:
"""Regroupe les extraits en lots ~`batch_tokens` (compte tiktoken)."""
enc = tiktoken.get_encoding("cl100k_base")
batches: list[list[dict]] = []
current: list[dict] = []
current_tokens = 0
for c in chunks:
t = len(enc.encode(c.get("text", "")))
if current and current_tokens + t > self._batch_tokens:
batches.append(current)
current, current_tokens = [], 0
current.append(c)
current_tokens += t
if current:
batches.append(current)
return batches

View File

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

View File

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

View File

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

View File

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

View File

@@ -301,128 +301,3 @@ class SessionContext:
in_progress_quests: list[QuestSummary] = field(default_factory=list)
locked_quest_titles: list[str] = field(default_factory=list)
active_flags: list[str] = field(default_factory=list)
# ─────────────────────── Import de PDF (règles → GameSystem) ───────────────────────
@dataclass(frozen=True)
class ExtractedPage:
"""Texte extrait d'UNE page de PDF, avec la trace de la méthode utilisée.
`used_ocr=True` signale que la page n'avait pas de couche texte exploitable
(born-digital absent) et a donc été rasterisée puis passée à l'OCR. Permet
au CLI/diagnostic de dire à l'utilisateur si son PDF est "texte" ou "scan".
"""
index: int # 0-based
text: str
used_ocr: bool
@dataclass(frozen=True)
class ExtractedDocument:
"""Résultat brut de l'extraction d'un PDF : une entrée par page."""
pages: list[ExtractedPage]
@property
def page_count(self) -> int:
return len(self.pages)
@property
def ocr_page_count(self) -> int:
return sum(1 for p in self.pages if p.used_ocr)
@property
def full_text(self) -> str:
"""Concatène le texte de toutes les pages, séparées par un saut double."""
return "\n\n".join(p.text for p in self.pages if p.text.strip())
@dataclass(frozen=True)
class RulesImportResult:
"""Proposition structurée de règles : sections markdown indexées par titre.
`sections` = {titre H2 → contenu markdown}. C'est une PROPOSITION : rien
n'est persisté côté Core tant que l'utilisateur n'a pas validé/édité.
`page_count` / `ocr_page_count` remontent au diagnostic d'extraction.
"""
sections: dict[str, str]
page_count: int
ocr_page_count: int
def to_markdown(self) -> str:
"""Assemble les sections en un markdown monolithique (## titre + contenu).
Format aligné sur `GameSystem.rulesMarkdown` côté Core (découpé par H2).
"""
blocks = [f"## {title}\n\n{content.strip()}" for title, content in self.sections.items()]
return "\n\n".join(blocks).strip() + "\n"
# ─────────────────────── Import de PDF de campagne (arbre arc→chapitre→scène) ──────────────
@dataclass(frozen=True)
class RoomProposal:
"""Pièce d'un lieu explorable (donjon) proposée pour une scène."""
name: str
description: str
enemies: str = ""
loot: str = ""
@dataclass(frozen=True)
class SceneProposal:
"""Scène proposée. `rooms` non vide => donjon/lieu explorable.
On capture aussi, quand le livre les fournit, le texte d'encadré « à lire aux
joueurs » (`player_narration`) et les secrets/développement MJ (`gm_notes`).
"""
name: str
description: str
player_narration: str = ""
gm_notes: str = ""
rooms: list[RoomProposal] = field(default_factory=list)
@dataclass(frozen=True)
class ChapterProposal:
"""Chapitre proposé : nom + synopsis + ses scènes."""
name: str
description: str
scenes: list[SceneProposal] = field(default_factory=list)
@dataclass(frozen=True)
class ArcProposal:
"""Arc proposé : nom + synopsis + type (LINEAR/HUB) + ses chapitres."""
name: str
description: str
arc_type: str = "LINEAR"
chapters: list[ChapterProposal] = field(default_factory=list)
@dataclass(frozen=True)
class CampaignImportResult:
"""Proposition d'arborescence narrative extraite d'un PDF de campagne.
PROPOSITION non persistée : l'UI laisse l'utilisateur réviser/éditer l'arbre
avant la création effective des arcs/chapitres/scènes côté Core.
"""
arcs: list[ArcProposal]
page_count: int
ocr_page_count: int
def counts(self) -> tuple[int, int, int]:
"""(nb arcs, nb chapitres, nb scènes) — pour le diagnostic / la progression."""
chapters = sum(len(a.chapters) for a in self.arcs)
scenes = sum(len(c.scenes) for a in self.arcs for c in a.chapters)
return len(self.arcs), chapters, scenes

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,34 +4,18 @@ Controller volontairement FIN : il valide l'entrée (DTOs Pydantic), délègue
au domaine via injection de dépendance (ports + use cases), et transforme les
erreurs du domaine en réponses HTTP. Aucune connaissance d'Ollama ici.
"""
import asyncio
import json
import logging
from typing import Annotated, AsyncIterator, Literal
import hmac
import httpx
import tiktoken
from fastapi import Depends, FastAPI, File, Form, HTTPException, Request, UploadFile
from fastapi import Depends, FastAPI, HTTPException, Request
from fastapi.responses import JSONResponse, StreamingResponse
from pydantic import BaseModel, Field
import re
from app.application.adapt_campaign import AdaptCampaignUseCase
from app.application.chat import ChatUseCase
from app.application.generate_page import GeneratePageUseCase
from app.application.import_campaign import ImportCampaignUseCase
from app.application.import_rules import ImportRulesUseCase
from app.application.llm_json import load_json_object
from app.application.llm_retry import generate_with_retry
from app.application.notebook_rag import NotebookRagUseCase
from app.application.notebook_chat import NotebookChatUseCase
from app.application.notebook_deep import NotebookDeepUseCase
from app.application.embeddings import EmbeddingError
from app.infrastructure import vector_store
from app.infrastructure.ollama_embedding_adapter import OllamaEmbeddingProvider
from app.infrastructure.mistral_embedding_adapter import MistralEmbeddingProvider
from app.core.config import Settings, get_settings
from app.core.settings_store import save_overrides
from app.domain.models import (
@@ -55,22 +39,16 @@ from app.domain.models import (
SceneSummary,
SessionContext,
)
from app.domain.ports import LLMProvider, LLMProviderError, PdfExtractionError
from app.domain.ports import LLMProvider, LLMProviderError
from app.infrastructure.ollama_adapter import OllamaLLMProvider
from app.infrastructure.onemin_adapter import OneMinAiLLMProvider
from app.infrastructure.openrouter_adapter import OpenRouterLLMProvider
from app.infrastructure.mistral_adapter import MistralLLMProvider
from app.infrastructure.gemini_adapter import GeminiLLMProvider
from app.infrastructure.pdf_extractor import PyMuPdfTextExtractor
app = FastAPI(
title="LoreMind Brain",
description="Backend IA pour la génération de contenu narratif.",
version="0.11.3-beta",
version="0.9.1-beta",
)
logger = logging.getLogger(__name__)
# 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
@@ -372,12 +350,6 @@ def get_llm_provider(
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
@@ -403,72 +375,6 @@ def get_chat_use_case(
return ChatUseCase(llm=llm) # type: ignore[arg-type]
# 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 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)."""
return ImportRulesUseCase(
llm=llm, extractor=_PDF_EXTRACTOR, chunk_target_tokens=settings.import_chunk_tokens)
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=settings.import_chunk_tokens)
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)],
) -> NotebookRagUseCase:
return NotebookRagUseCase(extractor=_PDF_EXTRACTOR, embedder=embedder) # type: ignore[arg-type]
def get_notebook_chat_use_case(
llm: Annotated[LLMProvider, Depends(get_llm_provider)],
rag: Annotated[NotebookRagUseCase, Depends(get_notebook_rag_use_case)],
) -> NotebookChatUseCase:
return NotebookChatUseCase(rag=rag, llm=llm) # type: ignore[arg-type]
def get_notebook_deep_use_case(
llm: Annotated[LLMProvider, Depends(get_llm_provider)],
settings: Annotated[Settings, Depends(get_settings)],
) -> NotebookDeepUseCase:
return NotebookDeepUseCase(llm=llm, batch_tokens=settings.import_chunk_tokens)
# --- Endpoints ---
@@ -478,54 +384,6 @@ def health() -> dict[str, str]:
return {"status": "ok", "service": "brain"}
@app.on_event("startup")
async def _auto_install_embedding_model() -> None:
"""Au démarrage : si le provider d'embeddings est Ollama et que le modèle n'est
pas installé, on le télécharge EN ARRIÈRE-PLAN → le RAG marche d'emblée pour un
nouvel utilisateur, sans bloquer le démarrage du Brain. Best-effort (Ollama peut
être absent / la connexion limitée) ; désactivable via `auto_pull_embedding_model`.
"""
settings = get_settings()
if not settings.auto_pull_embedding_model or settings.embedding_provider != "ollama":
return
asyncio.create_task(_ensure_ollama_embedding_model(settings.ollama_base_url, settings.ollama_embedding_model))
async def _ensure_ollama_embedding_model(base_url: str, model: str) -> None:
# 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)
@app.post("/generate", response_model=GenerateResponse)
async def generate(
body: GenerateRequest,
@@ -570,186 +428,6 @@ async def generate_page(
return GeneratePageResponseDTO(values=result.values)
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
# 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
@app.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,
)
@app.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()
def _sse(event: str, data: dict) -> str:
return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
async def event_stream() -> AsyncIterator[str]:
if not content:
yield _sse("error", {"message": "Fichier PDF vide."})
return
if len(content) > _MAX_PDF_BYTES:
yield _sse("error", {"message": f"PDF trop volumineux (> {_MAX_PDF_BYTES // (1024 * 1024)} Mo)."})
return
try:
async for ev in use_case.stream(content):
event_type = ev.pop("type")
yield _sse(event_type, ev)
except PdfExtractionError as exc:
yield _sse("error", {"message": str(exc)})
except LLMProviderError as exc:
yield _sse("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("error", {"message": f"Erreur inattendue du Brain : {type(exc).__name__} : {exc}"})
return StreamingResponse(event_stream(), media_type="text/event-stream")
@app.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()
def _sse(event: str, data: dict) -> str:
return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
async def event_stream() -> AsyncIterator[str]:
if not content:
yield _sse("error", {"message": "Fichier PDF vide."})
return
if len(content) > _MAX_PDF_BYTES:
yield _sse("error", {"message": f"PDF trop volumineux (> {_MAX_PDF_BYTES // (1024 * 1024)} Mo)."})
return
try:
async for ev in use_case.stream(content):
event_type = ev.pop("type")
yield _sse(event_type, ev)
except PdfExtractionError as exc:
yield _sse("error", {"message": str(exc)})
except LLMProviderError as exc:
yield _sse("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("error", {"message": f"Erreur inattendue du Brain : {type(exc).__name__} : {exc}"})
return StreamingResponse(event_stream(), media_type="text/event-stream")
@app.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()
]
def _sse(event: str, data: dict) -> str:
return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
async def event_stream() -> AsyncIterator[str]:
if not content:
yield _sse("error", {"message": "Fichier PDF vide."})
return
if len(content) > _MAX_PDF_BYTES:
yield _sse("error", {"message": f"PDF trop volumineux (> {_MAX_PDF_BYTES // (1024 * 1024)} Mo)."})
return
try:
async for token in use_case.stream(content, brief, convo):
yield _sse("token", {"token": token})
yield _sse("done", {})
except PdfExtractionError as exc:
yield _sse("error", {"message": str(exc)})
except LLMProviderError as exc:
yield _sse("error", {"message": str(exc)})
return StreamingResponse(event_stream(), media_type="text/event-stream")
@app.post("/chat/stream")
async def chat_stream(
body: ChatStreamRequestDTO,
@@ -801,9 +479,7 @@ async def chat_stream(
"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,
"max": settings.llm_num_ctx,
}
async def event_stream() -> AsyncIterator[str]:
@@ -883,320 +559,6 @@ async def summarize_conversation_title(
return SummarizeTitleResponseDTO(title=title)
# --- Tables aléatoires : génération IA + improvisation -----------------------
_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]
@app.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
@app.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]
@app.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,
)
# --- Notebooks (atelier RAG) : indexation des sources + chat ancré ----------
class IndexSourceResponseDTO(BaseModel):
chunks: int
page_count: int
ocr_page_count: int
@app.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)
@app.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="")
@app.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))
def _sse(event: str, data: dict) -> str:
return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
async def event_stream() -> AsyncIterator[str]:
try:
async for token in use_case.stream(body.source_ids, messages, context=body.context, top_k=top_k):
if token:
yield _sse("token", {"token": token})
yield _sse("done", {})
except (LLMProviderError, EmbeddingError) as exc:
yield _sse("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("error", {"message": f"Erreur inattendue du Brain : {type(exc).__name__} : {exc}"})
return StreamingResponse(event_stream(), media_type="text/event-stream")
@app.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"), "")
def _sse(event: str, data: dict) -> str:
return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
async def event_stream() -> AsyncIterator[str]:
if not question.strip():
yield _sse("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(ev_type, ev)
except (LLMProviderError, EmbeddingError) as exc:
yield _sse("error", {"message": str(exc)})
except Exception as exc: # noqa: BLE001 — filet : pas de coupure brutale.
logger.exception("Analyse approfondie : erreur inattendue.")
yield _sse("error", {"message": f"Erreur inattendue du Brain : {type(exc).__name__} : {exc}"})
return StreamingResponse(event_stream(), media_type="text/event-stream")
# --- Mapping DTO → domaine (frontière HTTP) ---------------------------------
@@ -1314,59 +676,27 @@ class SettingsDTO(BaseModel):
Les secrets (onemin_api_key) sont masques en lecture.
"""
llm_provider: Literal["ollama", "onemin", "openrouter", "mistral", "gemini"]
llm_provider: Literal["ollama", "onemin"]
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
llm_provider: Literal["ollama", "onemin"] | 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:
@@ -1376,20 +706,7 @@ def _to_settings_dto(s: Settings) -> SettingsDTO:
llm_model=s.llm_model,
onemin_model=s.onemin_model,
onemin_api_key_set=bool(s.onemin_api_key),
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,
)
@@ -1547,144 +864,6 @@ async def delete_ollama_model(
return {"status": "deleted", "name": name}
@app.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",
]
@app.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",
]
@app.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]}
@app.get("/models/onemin")
def list_onemin_models() -> dict[str, list[dict[str, object]]]:
"""Catalogue statique des modeles 1min.ai, groupes par fournisseur.

View File

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

View File

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

View File

@@ -14,7 +14,7 @@
<groupId>com.loremind</groupId>
<artifactId>loremind-core</artifactId>
<version>0.11.3-beta</version>
<version>0.9.1-beta</version>
<name>LoreMind Core</name>
<description>Backend Core - Architecture Hexagonale</description>

View File

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

View File

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

View File

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

View File

@@ -1,122 +0,0 @@
package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Campaign;
import com.loremind.domain.campaigncontext.CatalogItem;
import com.loremind.domain.campaigncontext.ItemCatalog;
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.campaigncontext.ports.ItemCatalogGenerator;
import com.loremind.domain.campaigncontext.ports.ItemCatalogRepository;
import com.loremind.domain.gamesystemcontext.ports.GameSystemRepository;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
/**
* Service d'application des catalogues d'objets (campagne) : CRUD + génération IA.
*/
@Service
public class ItemCatalogService {
private final ItemCatalogRepository repository;
private final ItemCatalogGenerator generator;
private final CampaignRepository campaignRepository;
private final GameSystemRepository gameSystemRepository;
public ItemCatalogService(
ItemCatalogRepository repository,
ItemCatalogGenerator generator,
CampaignRepository campaignRepository,
GameSystemRepository gameSystemRepository) {
this.repository = repository;
this.generator = generator;
this.campaignRepository = campaignRepository;
this.gameSystemRepository = gameSystemRepository;
}
public record CatalogData(
String name,
String description,
String icon,
List<CatalogItem> items,
String campaignId,
Integer order
) {}
public ItemCatalog createCatalog(CatalogData data) {
int order = data.order() != null ? data.order() : nextOrderFor(data.campaignId());
ItemCatalog catalog = ItemCatalog.builder()
.name(data.name())
.description(data.description())
.icon(data.icon())
.items(copyItems(data.items()))
.campaignId(data.campaignId())
.order(order)
.build();
return repository.save(catalog);
}
public Optional<ItemCatalog> getCatalogById(String id) {
return repository.findById(id);
}
public List<ItemCatalog> getCatalogsByCampaignId(String campaignId) {
return repository.findByCampaignId(campaignId);
}
public ItemCatalog updateCatalog(String id, CatalogData data) {
ItemCatalog existing = repository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Catalogue d'objets introuvable: " + id));
existing.setName(data.name());
existing.setDescription(data.description());
existing.setIcon(data.icon());
existing.setItems(copyItems(data.items()));
if (data.order() != null) {
existing.setOrder(data.order());
}
return repository.save(existing);
}
public void deleteCatalog(String id) {
repository.deleteById(id);
}
/** Génère une PROPOSITION de catalogue (non persistée) via l'IA, contextualisée campagne. */
public ItemCatalog generateProposal(String campaignId, String description) {
ItemCatalogGenerator.GeneratedCatalog g = generator.generate(description, buildContext(campaignId));
return ItemCatalog.builder()
.name(g.name())
.description(g.description())
.campaignId(campaignId)
.items(g.items() != null ? new ArrayList<>(g.items()) : new ArrayList<>())
.build();
}
private static List<CatalogItem> copyItems(List<CatalogItem> items) {
return items != null ? new ArrayList<>(items) : new ArrayList<>();
}
private String buildContext(String campaignId) {
if (campaignId == null) return "";
Campaign campaign = campaignRepository.findById(campaignId).orElse(null);
if (campaign == null) return "";
StringBuilder sb = new StringBuilder();
sb.append("Campagne : ").append(campaign.getName());
if (campaign.getDescription() != null && !campaign.getDescription().isBlank()) {
sb.append("").append(campaign.getDescription().trim());
}
if (campaign.getGameSystemId() != null && !campaign.getGameSystemId().isBlank()) {
gameSystemRepository.findById(campaign.getGameSystemId())
.ifPresent(gs -> sb.append("\nSystème de jeu : ").append(gs.getName()));
}
return sb.toString();
}
private int nextOrderFor(String campaignId) {
return repository.findByCampaignId(campaignId).stream()
.mapToInt(ItemCatalog::getOrder)
.max()
.orElse(-1) + 1;
}
}

View File

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

View File

@@ -29,7 +29,6 @@ public class NpcService {
Map<String, List<String>> imageValues,
Map<String, Map<String, String>> keyValueValues,
String campaignId,
String folder,
Integer order
) {}
@@ -45,7 +44,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())
.folder(normalizeFolder(data.folder()))
.order(order)
.build();
return npcRepository.save(npc);
@@ -68,7 +66,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.setFolder(normalizeFolder(data.folder()));
if (data.order() != null) {
existing.setOrder(data.order());
}
@@ -79,13 +76,6 @@ public class NpcService {
npcRepository.deleteById(id);
}
/** Trim le dossier ; chaîne vide → null (= non classé). */
private static String normalizeFolder(String folder) {
if (folder == null) return null;
String trimmed = folder.trim();
return trimmed.isEmpty() ? null : trimmed;
}
private int nextOrderFor(String campaignId) {
return npcRepository.findByCampaignId(campaignId).stream()
.mapToInt(Npc::getOrder)

View File

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

View File

@@ -6,7 +6,6 @@ import com.loremind.domain.campaigncontext.ports.SceneRepository;
import org.springframework.beans.BeanUtils;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.Set;
@@ -41,19 +40,6 @@ public class SceneService {
return sceneRepository.save(scene);
}
/**
* Crée une scène à partir d'un objet Scene complet (tous les champs : narration
* joueurs, notes MJ, pièces…). Utilisé par l'import de campagne. L'ID est forcé
* à null pour laisser le repo en générer un nouveau.
*/
public Scene createScene(Scene input) {
input.setId(null);
if (input.getRooms() == null) {
input.setRooms(new ArrayList<>());
}
return sceneRepository.save(input);
}
public Optional<Scene> getSceneById(String id) {
return sceneRepository.findById(id);
}

View File

@@ -1,9 +1,7 @@
package com.loremind.application.gamesystemcontext;
import com.loremind.domain.gamesystemcontext.GameSystem;
import com.loremind.domain.gamesystemcontext.RulesImportResult;
import com.loremind.domain.gamesystemcontext.ports.GameSystemRepository;
import com.loremind.domain.gamesystemcontext.ports.RulesPdfImporter;
import com.loremind.domain.shared.template.TemplateField;
import org.springframework.stereotype.Service;
@@ -14,34 +12,9 @@ import java.util.Optional;
public class GameSystemService {
private final GameSystemRepository gameSystemRepository;
private final RulesPdfImporter rulesPdfImporter;
public GameSystemService(GameSystemRepository gameSystemRepository,
RulesPdfImporter rulesPdfImporter) {
public GameSystemService(GameSystemRepository gameSystemRepository) {
this.gameSystemRepository = gameSystemRepository;
this.rulesPdfImporter = rulesPdfImporter;
}
/**
* Importe un PDF de règles et renvoie une PROPOSITION de sections (titre →
* markdown). Ne persiste rien : l'UI laisse l'utilisateur réviser/éditer
* puis enregistrer le GameSystem via {@link #updateGameSystem}/{@link #createGameSystem}.
*/
public RulesImportResult importRulesFromPdf(byte[] pdfBytes, String filename) {
return rulesPdfImporter.importRules(pdfBytes, filename);
}
/**
* Variante streamée de {@link #importRulesFromPdf} : remonte l'avancement via
* callbacks (import long → l'UI affiche une progression). Ne persiste rien.
*/
public void importRulesFromPdfStreaming(
byte[] pdfBytes,
String filename,
java.util.function.Consumer<com.loremind.domain.gamesystemcontext.RulesImportProgress> onProgress,
java.util.function.Consumer<RulesImportResult> onDone,
java.util.function.Consumer<Throwable> onError) {
rulesPdfImporter.importRulesStreaming(pdfBytes, filename, onProgress, onDone, onError);
}
/**

View File

@@ -174,9 +174,10 @@ public class SessionStructuralContextBuilder {
.filter(a -> a.getId() != null)
.collect(Collectors.toMap(Arc::getId, a -> a));
// On suit comme "quêtes" les chapitres CONDITIONNELS : ceux d'un arc HUB, ET
// ceux d'un arc linéaire qui portent des prérequis. Un chapitre linéaire sans
// condition reste hors du tableau (sinon tous les chapitres deviendraient des quêtes).
boolean anyHub = arcs.stream().anyMatch(a -> a.getType() == ArcType.HUB);
if (!anyHub) {
return new HubStatus(List.of(), List.of(), List.of(), activeFlags);
}
// Map chapterId -> ProgressionStatus pour ce Playthrough
Map<String, ProgressionStatus> progressionByChapter = new HashMap<>();
@@ -196,10 +197,8 @@ public class SessionStructuralContextBuilder {
List<String> lockedTitles = new ArrayList<>();
for (Arc arc : arcs) {
boolean isHub = arc.getType() == ArcType.HUB;
if (arc.getType() != ArcType.HUB) continue;
for (Chapter c : chapterRepository.findByArcId(arc.getId())) {
boolean hasPrereqs = c.getPrerequisites() != null && !c.getPrerequisites().isEmpty();
if (!isHub && !hasPrereqs) continue; // chapitre linéaire sans condition : ignoré
ProgressionStatus prog = progressionByChapter.getOrDefault(c.getId(), ProgressionStatus.NOT_STARTED);
QuestStatus status = prerequisiteEvaluator.computeStatus(prog, c.getPrerequisites(), ctx);
Arc parent = arcsById.get(c.getArcId());

View File

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

View File

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

View File

@@ -1,24 +0,0 @@
package com.loremind.domain.campaigncontext;
import lombok.Builder;
import lombok.Data;
/**
* Un objet d'un {@link ItemCatalog} (boutique, butin, trésor…).
* Value object possédé par le catalogue : remplacé en bloc à chaque mise à jour.
*/
@Data
@Builder
public class CatalogItem {
private String name;
/** Prix libre (ex. « 50 po », « 2 pa »). Nullable. */
private String price;
/** Catégorie de regroupement (ex. « Armes », « Potions »). Nullable. */
private String category;
/** Description / effet (markdown). Nullable. */
private String description;
}

View File

@@ -1,44 +0,0 @@
package com.loremind.domain.campaigncontext;
import lombok.Builder;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
/**
* Catalogue d'objets prédéfinis d'une campagne : une boutique, un butin, un trésor…
* Le MJ le remplit à la main ou via l'IA, et le consulte (notamment en session) quand
* les joueurs visitent une échoppe. Scope campagne (cross-aggregate via ID).
*/
@Data
@Builder
public class ItemCatalog {
private String id;
private String name;
/** Description libre (à quoi sert ce catalogue / cette boutique). Nullable. */
private String description;
/** Clé d'icône (lucide) pour la sidebar/fiche. Nullable. */
private String icon;
/** Référence vers la Campaign parente (cross-aggregate via ID). */
private String campaignId;
/** Ordre d'affichage dans la liste des catalogues de la campagne. */
private int order;
/** Objets ordonnés du catalogue. Jamais null après construction. */
private List<CatalogItem> items;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
public List<CatalogItem> getItems() {
if (items == null) items = new ArrayList<>();
return items;
}
}

View File

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

View File

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

View File

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

View File

@@ -46,9 +46,6 @@ public class Npc {
/** Référence vers la Campaign parente (cross-aggregate via ID). */
private String campaignId;
/** Dossier de classement (texte libre, ex. « Bard's Gate »). Nullable = non classé. */
private String folder;
/** Ordre d'affichage dans la liste des PNJ de la campagne. */
private int order;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,21 +0,0 @@
package com.loremind.domain.campaigncontext.ports;
import com.loremind.domain.campaigncontext.CatalogItem;
import java.util.List;
/**
* Port de sortie : génération IA d'un catalogue d'objets. Implémenté par un client
* du Brain (service IA Python).
*/
public interface ItemCatalogGenerator {
/** Catalogue proposé (non persisté) à partir d'une description. */
record GeneratedCatalog(String name, String description, List<CatalogItem> items) {}
/**
* Génère une proposition de catalogue (objets) sur le sujet donné, en s'appuyant
* sur le contexte (campagne, système…) s'il est fourni.
*/
GeneratedCatalog generate(String description, String context);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,20 +0,0 @@
package com.loremind.domain.gamesystemcontext;
import java.util.Map;
/**
* Proposition de règles extraites d'un PDF, prête à être révisée par l'utilisateur.
* <p>
* {@code sections} associe un titre de section à son contenu markdown — aligné
* sur le format {@link GameSystem#getRulesMarkdown()} (découpé par titres H2).
* C'est une PROPOSITION : rien n'est persisté ; l'UI laisse l'utilisateur
* réviser/éditer avant d'enregistrer le GameSystem.
* <p>
* {@code ocrPageCount} indique combien de pages ont nécessité l'OCR (scan) —
* 0 = PDF born-digital (couche texte présente).
*/
public record RulesImportResult(
Map<String, String> sections,
int pageCount,
int ocrPageCount) {
}

View File

@@ -1,17 +0,0 @@
package com.loremind.domain.gamesystemcontext.ports;
/**
* Erreur de domaine : l'import d'un PDF de règles a échoué (PDF illisible,
* Brain injoignable, LLM en erreur...). Les couches supérieures la traduisent
* en réponse HTTP sans connaître l'adapter concret.
*/
public class RulesImportException extends RuntimeException {
public RulesImportException(String message) {
super(message);
}
public RulesImportException(String message, Throwable cause) {
super(message, cause);
}
}

View File

@@ -1,39 +0,0 @@
package com.loremind.domain.gamesystemcontext.ports;
import com.loremind.domain.gamesystemcontext.RulesImportProgress;
import com.loremind.domain.gamesystemcontext.RulesImportResult;
import java.util.function.Consumer;
/**
* Port de sortie : extrait et structure les règles d'un PDF en sections.
* <p>
* L'implémentation (adapter) délègue au Brain Python (extraction texte + OCR +
* structuration LLM). Le domaine ne connaît ni HTTP, ni le Brain, ni le LLM.
*/
public interface RulesPdfImporter {
/**
* @param pdfBytes contenu binaire du PDF de règles.
* @param filename nom d'origine (diagnostic/logs ; peut être null).
* @return la proposition de sections (non persistée).
* @throws RulesImportException si l'extraction ou la structuration échoue.
*/
RulesImportResult importRules(byte[] pdfBytes, String filename);
/**
* Variante streamée : l'import peut durer plusieurs minutes, on remonte
* 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 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,
Consumer<RulesImportResult> onDone,
Consumer<Throwable> onError);
}

View File

@@ -1,120 +0,0 @@
package com.loremind.infrastructure.ai;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.loremind.domain.campaigncontext.ports.CampaignPdfAdvisor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.http.MediaType;
import org.springframework.http.client.MultipartBodyBuilder;
import org.springframework.http.codec.ServerSentEvent;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.BodyInserters;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Flux;
import java.time.Duration;
import java.util.function.Consumer;
/**
* Adapter de sortie : implémente {@link CampaignPdfAdvisor} via WebClient + SSE
* (POST /adapt/campaign/stream). Envoie le PDF (multipart) + le brief de campagne,
* relaie les tokens de conseil au fil de l'eau.
*/
@Component
public class BrainCampaignAdaptClient implements CampaignPdfAdvisor {
private static final String ADAPT_PATH = "/adapt/campaign/stream";
private static final ParameterizedTypeReference<ServerSentEvent<String>> SSE_STRING_TYPE =
new ParameterizedTypeReference<>() {};
private final WebClient webClient;
private final ObjectMapper objectMapper;
private final long timeoutSeconds;
public BrainCampaignAdaptClient(
WebClient.Builder webClientBuilder,
ObjectMapper objectMapper,
@Value("${brain.base-url}") String baseUrl,
@Value("${brain.import-timeout-seconds:600}") long timeoutSeconds) {
this.webClient = webClientBuilder.baseUrl(baseUrl).build();
this.objectMapper = objectMapper;
this.timeoutSeconds = timeoutSeconds;
}
@Override
public void adviseStreaming(
byte[] pdfBytes,
String filename,
String brief,
String messagesJson,
Consumer<String> onToken,
Runnable onComplete,
Consumer<Throwable> onError) {
MultipartBodyBuilder parts = new MultipartBodyBuilder();
parts.part("file", new ByteArrayResource(pdfBytes) {
@Override
public String getFilename() {
return (filename == null || filename.isBlank()) ? "campaign.pdf" : filename;
}
});
parts.part("brief", brief == null ? "" : brief);
parts.part("messages", (messagesJson == null || messagesJson.isBlank()) ? "[]" : messagesJson);
Flux<ServerSentEvent<String>> flux = webClient.post()
.uri(ADAPT_PATH)
.contentType(MediaType.MULTIPART_FORM_DATA)
.accept(MediaType.TEXT_EVENT_STREAM)
.body(BodyInserters.fromMultipartData(parts.build()))
.retrieve()
.bodyToFlux(SSE_STRING_TYPE);
boolean[] terminated = {false};
try {
flux
.timeout(Duration.ofSeconds(timeoutSeconds))
.doOnNext(sse -> handleEvent(sse, terminated, onToken, onComplete, onError))
.blockLast();
// Flux clos sans 'done' explicite (ex: coupure) → on complète quand même.
if (!terminated[0]) onComplete.run();
} catch (Exception e) {
if (!terminated[0]) {
onError.accept(new RuntimeException(
"Erreur lors du streaming d'adaptation depuis le Brain.", e));
}
}
}
private void handleEvent(
ServerSentEvent<String> sse,
boolean[] terminated,
Consumer<String> onToken,
Runnable onComplete,
Consumer<Throwable> onError) {
String event = sse.event();
String data = sse.data() == null ? "" : sse.data();
if ("error".equals(event)) {
terminated[0] = true;
onError.accept(new RuntimeException("Le Brain a signalé une erreur : " + readField(data, "message")));
} else if ("done".equals(event)) {
terminated[0] = true;
onComplete.run();
} else if ("token".equals(event)) {
String token = readField(data, "token");
if (token != null && !token.isEmpty()) onToken.accept(token);
}
}
private String readField(String data, String field) {
try {
JsonNode node = objectMapper.readTree(data);
return node.hasNonNull(field) ? node.get(field).asText() : data;
} catch (Exception e) {
return data;
}
}
}

View File

@@ -1,236 +0,0 @@
package com.loremind.infrastructure.ai;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.loremind.domain.campaigncontext.CampaignImportProgress;
import com.loremind.domain.campaigncontext.CampaignImportProposal;
import com.loremind.domain.campaigncontext.CampaignImportProposal.ArcProposal;
import com.loremind.domain.campaigncontext.CampaignImportProposal.ChapterProposal;
import com.loremind.domain.campaigncontext.CampaignImportProposal.RoomProposal;
import com.loremind.domain.campaigncontext.CampaignImportProposal.SceneProposal;
import com.loremind.domain.campaigncontext.ports.CampaignImportException;
import com.loremind.domain.campaigncontext.ports.CampaignPdfImporter;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.http.MediaType;
import org.springframework.http.client.MultipartBodyBuilder;
import org.springframework.http.codec.ServerSentEvent;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.BodyInserters;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Flux;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Consumer;
/**
* Adapter de sortie : implémente {@link CampaignPdfImporter} en appelant le
* Brain Python via WebClient + SSE (POST /import/campaign/stream).
* <p>
* Le secret inter-service est ajouté par le WebClientCustomizer. Le timeout est
* long (import d'un livre entier = nombreux appels LLM en série).
*/
@Component
public class BrainCampaignImportClient implements CampaignPdfImporter {
private static final String IMPORT_CAMPAIGN_STREAM_PATH = "/import/campaign/stream";
private static final ParameterizedTypeReference<ServerSentEvent<String>> SSE_STRING_TYPE =
new ParameterizedTypeReference<>() {};
private final WebClient webClient;
private final ObjectMapper objectMapper;
private final long importTimeoutSeconds;
public BrainCampaignImportClient(
WebClient.Builder webClientBuilder,
ObjectMapper objectMapper,
@Value("${brain.base-url}") String baseUrl,
@Value("${brain.import-timeout-seconds:600}") long importTimeoutSeconds) {
this.webClient = webClientBuilder.baseUrl(baseUrl).build();
this.objectMapper = objectMapper;
this.importTimeoutSeconds = importTimeoutSeconds;
}
@Override
public void importCampaignStreaming(
byte[] pdfBytes,
String filename,
Consumer<CampaignImportProgress> onProgress,
Consumer<CampaignImportProposal> onDone,
Consumer<Throwable> onError) {
MultipartBodyBuilder parts = new MultipartBodyBuilder();
parts.part("file", filePart(pdfBytes, filename))
.filename(filename == null || filename.isBlank() ? "campaign.pdf" : filename);
Flux<ServerSentEvent<String>> flux = webClient.post()
.uri(IMPORT_CAMPAIGN_STREAM_PATH)
.contentType(MediaType.MULTIPART_FORM_DATA)
.accept(MediaType.TEXT_EVENT_STREAM)
.body(BodyInserters.fromMultipartData(parts.build()))
.retrieve()
.bodyToFlux(SSE_STRING_TYPE);
int[] pageCount = {0};
int[] ocrPageCount = {0};
boolean[] terminated = {false};
try {
flux
.timeout(Duration.ofSeconds(importTimeoutSeconds))
.doOnNext(sse -> handleEvent(
sse, pageCount, ocrPageCount, terminated, onProgress, onDone, onError))
.blockLast();
if (!terminated[0]) {
onError.accept(new CampaignImportException(
"Le flux d'import s'est interrompu avant la fin."));
}
} catch (Exception e) {
if (!terminated[0]) {
// On EXPOSE la cause réelle (type + message) : sans ça, le diagnostic
// est impossible (timeout WebClient, connexion coupée, réponse non-2xx…).
String cause = e.getClass().getSimpleName()
+ (e.getMessage() != null ? "" + e.getMessage() : "");
onError.accept(new CampaignImportException(
"Erreur lors du streaming d'import depuis le Brain : " + cause, e));
}
}
}
private void handleEvent(
ServerSentEvent<String> sse,
int[] pageCount,
int[] ocrPageCount,
boolean[] terminated,
Consumer<CampaignImportProgress> onProgress,
Consumer<CampaignImportProposal> onDone,
Consumer<Throwable> onError) {
String event = sse.event();
String data = sse.data() == null ? "" : sse.data();
if ("error".equals(event)) {
terminated[0] = true;
onError.accept(new CampaignImportException(
"Le Brain a signalé une erreur : " + readMessage(data)));
return;
}
if ("extracting".equals(event)) {
onProgress.accept(new CampaignImportProgress(0, 0, 0, 0, 0, 0, 0));
return;
}
JsonNode node = readJson(data);
if (node == null) return;
if ("start".equals(event)) {
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));
} else if ("progress".equals(event)) {
onProgress.accept(new CampaignImportProgress(
node.path("current").asInt(),
node.path("total").asInt(),
pageCount[0],
ocrPageCount[0],
node.path("arc_count").asInt(),
node.path("chapter_count").asInt(),
node.path("scene_count").asInt()));
} else if ("done".equals(event)) {
terminated[0] = true;
onDone.accept(new CampaignImportProposal(toArcs(node.path("arcs"))));
}
}
// --- Parsing de l'arbre --------------------------------------------------
private List<ArcProposal> toArcs(JsonNode arcsNode) {
List<ArcProposal> arcs = new ArrayList<>();
if (arcsNode != null && arcsNode.isArray()) {
for (JsonNode arc : arcsNode) {
arcs.add(new ArcProposal(
text(arc, "name"),
text(arc, "description"),
text(arc, "type"),
toChapters(arc.path("chapters")),
null));
}
}
return arcs;
}
private List<ChapterProposal> toChapters(JsonNode chaptersNode) {
List<ChapterProposal> chapters = new ArrayList<>();
if (chaptersNode != null && chaptersNode.isArray()) {
for (JsonNode ch : chaptersNode) {
chapters.add(new ChapterProposal(
text(ch, "name"),
text(ch, "description"),
toScenes(ch.path("scenes")),
null));
}
}
return chapters;
}
private List<SceneProposal> toScenes(JsonNode scenesNode) {
List<SceneProposal> scenes = new ArrayList<>();
if (scenesNode != null && scenesNode.isArray()) {
for (JsonNode sc : scenesNode) {
scenes.add(new SceneProposal(
text(sc, "name"), text(sc, "description"),
text(sc, "player_narration"), text(sc, "gm_notes"),
toRooms(sc.path("rooms")), null));
}
}
return scenes;
}
private List<RoomProposal> toRooms(JsonNode roomsNode) {
List<RoomProposal> rooms = new ArrayList<>();
if (roomsNode != null && roomsNode.isArray()) {
for (JsonNode rm : roomsNode) {
rooms.add(new RoomProposal(
text(rm, "name"), text(rm, "description"),
text(rm, "enemies"), text(rm, "loot")));
}
}
return rooms;
}
// --- Helpers -------------------------------------------------------------
private ByteArrayResource filePart(byte[] pdfBytes, String filename) {
return new ByteArrayResource(pdfBytes) {
@Override
public String getFilename() {
return (filename == null || filename.isBlank()) ? "campaign.pdf" : filename;
}
};
}
private static String text(JsonNode node, String field) {
JsonNode v = node.path(field);
return v.isMissingNode() || v.isNull() ? "" : v.asText();
}
private JsonNode readJson(String data) {
try {
return objectMapper.readTree(data);
} catch (Exception e) {
return null;
}
}
private String readMessage(String data) {
JsonNode node = readJson(data);
if (node != null && node.hasNonNull("message")) {
return node.get("message").asText();
}
return data;
}
}

View File

@@ -1,92 +0,0 @@
package com.loremind.infrastructure.ai;
import com.loremind.domain.campaigncontext.CatalogItem;
import com.loremind.domain.campaigncontext.ports.ItemCatalogGenerationException;
import com.loremind.domain.campaigncontext.ports.ItemCatalogGenerator;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.client.ResourceAccessException;
import org.springframework.web.client.RestClientResponseException;
import org.springframework.web.client.RestTemplate;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* Adapter de sortie : génère un catalogue d'objets via le Brain (POST one-shot).
*/
@Component
public class BrainItemCatalogClient implements ItemCatalogGenerator {
private static final String GENERATE_PATH = "/generate/item-catalog";
private final RestTemplate restTemplate;
private final String baseUrl;
public BrainItemCatalogClient(
RestTemplate restTemplate,
@Value("${brain.base-url}") String baseUrl) {
this.restTemplate = restTemplate;
this.baseUrl = baseUrl;
}
@Override
@SuppressWarnings("unchecked")
public GeneratedCatalog generate(String description, String context) {
Map<String, Object> req = new LinkedHashMap<>();
req.put("description", description == null ? "" : description);
req.put("context", context == null ? "" : context);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<Map<String, Object>> entity = new HttpEntity<>(req, headers);
Map<String, Object> resp;
try {
resp = restTemplate.postForObject(baseUrl + GENERATE_PATH, entity, Map.class);
} catch (ResourceAccessException e) {
throw new ItemCatalogGenerationException("Le Brain est injoignable (timeout ou arrêté).", e);
} catch (RestClientResponseException e) {
throw new ItemCatalogGenerationException(
"Le Brain a répondu HTTP " + e.getStatusCode().value() + " : " + e.getResponseBodyAsString(), e);
} catch (Exception e) {
throw new ItemCatalogGenerationException("Erreur inattendue lors de l'appel au Brain.", e);
}
if (resp == null) {
throw new ItemCatalogGenerationException("Le Brain a renvoyé une réponse vide.");
}
List<CatalogItem> items = new ArrayList<>();
Object rawItems = resp.get("items");
if (rawItems instanceof List<?> list) {
for (Object item : list) {
if (!(item instanceof Map<?, ?> m)) continue;
String name = asString(m.get("name"));
if (name == null || name.isBlank()) continue;
items.add(CatalogItem.builder()
.name(name)
.price(asString(m.get("price")))
.category(asString(m.get("category")))
.description(asString(m.get("description")))
.build());
}
}
if (items.isEmpty()) {
throw new ItemCatalogGenerationException("Aucun objet généré — réessaie ou reformule.");
}
String name = asString(resp.get("name"));
return new GeneratedCatalog(
name != null && !name.isBlank() ? name : description,
asString(resp.get("description")),
items);
}
private static String asString(Object o) {
return o != null ? o.toString() : null;
}
}

View File

@@ -1,139 +0,0 @@
package com.loremind.infrastructure.ai;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.loremind.domain.campaigncontext.ports.NotebookChatStreamer;
import com.loremind.domain.campaigncontext.ports.NotebookException;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.MediaType;
import org.springframework.http.codec.ServerSentEvent;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Flux;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Consumer;
import java.util.stream.Collectors;
/**
* Adapter de sortie : relaie le chat ANCRÉ (RAG) du Brain via SSE (WebClient).
* Pattern identique aux imports streamés (cf. BrainRulesImportClient).
*/
@Component
public class BrainNotebookChatClient implements NotebookChatStreamer {
private static final String PATH = "/chat/notebook/stream";
private static final String DEEP_PATH = "/chat/notebook/deep/stream";
private static final ParameterizedTypeReference<ServerSentEvent<String>> SSE_STRING_TYPE =
new ParameterizedTypeReference<>() {};
private final WebClient webClient;
private final ObjectMapper objectMapper;
private final long timeoutSeconds;
public BrainNotebookChatClient(
WebClient.Builder webClientBuilder,
ObjectMapper objectMapper,
@Value("${brain.base-url}") String baseUrl,
@Value("${brain.import-timeout-seconds:600}") long timeoutSeconds) {
this.webClient = webClientBuilder.baseUrl(baseUrl).build();
this.objectMapper = objectMapper;
this.timeoutSeconds = timeoutSeconds;
}
@Override
public void stream(
List<String> sourceIds,
List<Msg> messages,
String context,
boolean deep,
Consumer<String> onToken,
Consumer<Progress> onProgress,
Runnable onDone,
Consumer<Throwable> onError) {
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("source_ids", sourceIds);
payload.put("messages", messages.stream()
.map(m -> Map.<String, Object>of("role", m.role(), "content", m.content()))
.collect(Collectors.toList()));
payload.put("context", context == null ? "" : context);
Flux<ServerSentEvent<String>> flux = webClient.post()
.uri(deep ? DEEP_PATH : PATH)
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.TEXT_EVENT_STREAM)
.bodyValue(payload)
.retrieve()
.bodyToFlux(SSE_STRING_TYPE);
boolean[] terminated = {false};
try {
flux
.timeout(Duration.ofSeconds(timeoutSeconds))
.doOnNext(sse -> handleEvent(sse, terminated, onToken, onProgress, onDone, onError))
.blockLast();
if (!terminated[0]) {
onDone.run(); // flux terminé sans event done explicite
}
} catch (Exception e) {
if (!terminated[0]) {
String cause = e.getClass().getSimpleName()
+ (e.getMessage() != null ? "" + e.getMessage() : "");
onError.accept(new NotebookException(
"Erreur lors du streaming du chat depuis le Brain : " + cause, e));
}
}
}
private void handleEvent(
ServerSentEvent<String> sse,
boolean[] terminated,
Consumer<String> onToken,
Consumer<Progress> onProgress,
Runnable onDone,
Consumer<Throwable> onError) {
String event = sse.event();
String data = sse.data() == null ? "" : sse.data();
if ("token".equals(event)) {
String token = readField(data, "token");
if (token != null && !token.isEmpty()) onToken.accept(token);
} else if ("progress".equals(event)) {
onProgress.accept(new Progress(readInt(data, "current"), readInt(data, "total")));
} else if ("done".equals(event)) {
terminated[0] = true;
onDone.run();
} else if ("error".equals(event)) {
terminated[0] = true;
onError.accept(new NotebookException("Le Brain a signalé une erreur : " + readMessage(data)));
}
}
private int readInt(String data, String field) {
try {
JsonNode node = objectMapper.readTree(data);
return node.hasNonNull(field) ? node.get(field).asInt() : 0;
} catch (Exception e) {
return 0;
}
}
private String readField(String data, String field) {
try {
JsonNode node = objectMapper.readTree(data);
return node.hasNonNull(field) ? node.get(field).asText() : null;
} catch (Exception e) {
return null;
}
}
private String readMessage(String data) {
String msg = readField(data, "message");
return msg != null ? msg : data;
}
}

View File

@@ -1,100 +0,0 @@
package com.loremind.infrastructure.ai;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.loremind.domain.campaigncontext.ports.NotebookException;
import com.loremind.domain.campaigncontext.ports.NotebookIndexer;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.ResourceAccessException;
import org.springframework.web.client.RestClientResponseException;
import org.springframework.web.client.RestTemplate;
/**
* Adapter de sortie : indexe une source de notebook via le Brain (multipart,
* one-shot bloquant — l'indexation d'un livre peut prendre du temps, d'où le
* RestTemplate à timeout long {@code brainImportRestTemplate}).
*/
@Component
public class BrainNotebookIndexClient implements NotebookIndexer {
private static final Logger LOG = LoggerFactory.getLogger(BrainNotebookIndexClient.class);
private static final String INDEX_PATH = "/index/notebook-source";
private final RestTemplate restTemplate;
private final String baseUrl;
public BrainNotebookIndexClient(
@Qualifier("brainImportRestTemplate") RestTemplate restTemplate,
@Value("${brain.base-url}") String baseUrl) {
this.restTemplate = restTemplate;
this.baseUrl = baseUrl;
}
@Override
public IndexResult index(String sourceId, byte[] pdfBytes, String filename) {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.MULTIPART_FORM_DATA);
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
body.add("source_id", sourceId);
body.add("file", filePart(pdfBytes, filename));
HttpEntity<MultiValueMap<String, Object>> entity = new HttpEntity<>(body, headers);
try {
IndexResponse resp = restTemplate.postForObject(
baseUrl + INDEX_PATH, entity, IndexResponse.class);
if (resp == null) {
throw new NotebookException("Le Brain a renvoyé une réponse vide à l'indexation.");
}
return new IndexResult(resp.chunks, resp.pageCount, resp.ocrPageCount);
} catch (ResourceAccessException e) {
throw new NotebookException("Le Brain est injoignable (timeout ou arrêté).", e);
} catch (RestClientResponseException e) {
throw new NotebookException(
"Le Brain a répondu HTTP " + e.getStatusCode().value()
+ " : " + e.getResponseBodyAsString(), e);
} catch (NotebookException e) {
throw e;
} catch (Exception e) {
throw new NotebookException("Erreur inattendue lors de l'indexation via le Brain.", e);
}
}
@Override
public void delete(String sourceId) {
// Best-effort : si le Brain est down, on ne bloque pas la suppression côté Core
// (les vecteurs orphelins seront simplement ignorés / nettoyables plus tard).
try {
restTemplate.delete(baseUrl + INDEX_PATH + "/" + sourceId);
} catch (Exception e) {
LOG.warn("Suppression des vecteurs de la source {} échouée (ignorée) : {}", sourceId, e.getMessage());
}
}
private ByteArrayResource filePart(byte[] pdfBytes, String filename) {
return new ByteArrayResource(pdfBytes) {
@Override
public String getFilename() {
return (filename == null || filename.isBlank()) ? "source.pdf" : filename;
}
};
}
/** Réponse JSON du Brain (snake_case). */
private static class IndexResponse {
public int chunks;
@JsonProperty("page_count")
public int pageCount;
@JsonProperty("ocr_page_count")
public int ocrPageCount;
}
}

View File

@@ -1,120 +0,0 @@
package com.loremind.infrastructure.ai;
import com.loremind.domain.campaigncontext.RandomTableEntry;
import com.loremind.domain.campaigncontext.ports.RandomTableGenerationException;
import com.loremind.domain.campaigncontext.ports.RandomTableGenerator;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.client.ResourceAccessException;
import org.springframework.web.client.RestClientResponseException;
import org.springframework.web.client.RestTemplate;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* Adapter de sortie : implémente {@link RandomTableGenerator} en appelant le Brain
* (POST one-shot, RestTemplate). Le secret inter-service est injecté par l'intercepteur
* du bean {@code brainRestTemplate}.
*/
@Component
public class BrainRandomTableClient implements RandomTableGenerator {
private static final String GENERATE_PATH = "/generate/random-table";
private static final String IMPROVISE_PATH = "/improvise/table-roll";
private final RestTemplate restTemplate;
private final String baseUrl;
public BrainRandomTableClient(
RestTemplate restTemplate,
@Value("${brain.base-url}") String baseUrl) {
this.restTemplate = restTemplate;
this.baseUrl = baseUrl;
}
@Override
public GeneratedTable generate(String description, String diceFormula, String context) {
Map<String, Object> req = new LinkedHashMap<>();
req.put("description", description == null ? "" : description);
req.put("dice_formula", diceFormula == null ? "1d20" : diceFormula);
req.put("context", context == null ? "" : context);
Map<String, Object> resp = post(GENERATE_PATH, req);
if (resp == null) {
throw new RandomTableGenerationException("Le Brain a renvoyé une réponse vide.");
}
List<RandomTableEntry> entries = new ArrayList<>();
Object rawEntries = resp.get("entries");
if (rawEntries instanceof List<?> list) {
for (Object item : list) {
if (!(item instanceof Map<?, ?> m)) continue;
Integer min = asInt(m.get("min_roll"));
Integer max = asInt(m.get("max_roll"));
String label = asString(m.get("label"));
if (min == null || max == null || label == null || label.isBlank()) continue;
entries.add(RandomTableEntry.builder()
.minRoll(min)
.maxRoll(Math.max(min, max))
.label(label)
.detail(asString(m.get("detail")))
.build());
}
}
if (entries.isEmpty()) {
throw new RandomTableGenerationException("Aucune entrée générée — réessaie ou reformule.");
}
String name = asString(resp.get("name"));
return new GeneratedTable(
name != null && !name.isBlank() ? name : description,
asString(resp.get("description")),
entries);
}
@Override
public String improvise(String tableName, String resultLabel, String resultDetail, String context) {
Map<String, Object> req = new LinkedHashMap<>();
req.put("table_name", tableName == null ? "" : tableName);
req.put("result_label", resultLabel == null ? "" : resultLabel);
req.put("result_detail", resultDetail == null ? "" : resultDetail);
req.put("context", context == null ? "" : context);
Map<String, Object> resp = post(IMPROVISE_PATH, req);
String narration = resp != null ? asString(resp.get("narration")) : null;
return narration != null ? narration : "";
}
@SuppressWarnings("unchecked")
private Map<String, Object> post(String path, Map<String, Object> body) {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<Map<String, Object>> entity = new HttpEntity<>(body, headers);
try {
return restTemplate.postForObject(baseUrl + path, entity, Map.class);
} catch (ResourceAccessException e) {
throw new RandomTableGenerationException("Le Brain est injoignable (timeout ou arrêté).", e);
} catch (RestClientResponseException e) {
throw new RandomTableGenerationException(
"Le Brain a répondu HTTP " + e.getStatusCode().value() + " : " + e.getResponseBodyAsString(), e);
} catch (Exception e) {
throw new RandomTableGenerationException("Erreur inattendue lors de l'appel au Brain.", e);
}
}
private static Integer asInt(Object o) {
if (o instanceof Number n) return n.intValue();
if (o instanceof String s) {
try { return Integer.parseInt(s.trim()); } catch (NumberFormatException ignored) { return null; }
}
return null;
}
private static String asString(Object o) {
return o != null ? o.toString() : null;
}
}

View File

@@ -1,253 +0,0 @@
package com.loremind.infrastructure.ai;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.loremind.domain.gamesystemcontext.RulesImportProgress;
import com.loremind.domain.gamesystemcontext.RulesImportResult;
import com.loremind.domain.gamesystemcontext.ports.RulesImportException;
import com.loremind.domain.gamesystemcontext.ports.RulesPdfImporter;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.client.MultipartBodyBuilder;
import org.springframework.http.codec.ServerSentEvent;
import org.springframework.stereotype.Component;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.ResourceAccessException;
import org.springframework.web.client.RestClientResponseException;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.reactive.function.BodyInserters;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Flux;
import java.time.Duration;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Consumer;
/**
* Adapter de sortie : implémente {@link RulesPdfImporter} en appelant le Brain
* Python via HTTP multipart.
* <p>
* Deux variantes :
* - {@link #importRules} : one-shot bloquant (RestTemplate, POST /import/rules).
* - {@link #importRulesStreaming} : streamé (WebClient + SSE, POST /import/rules/stream)
* pour remonter l'avancement d'un import long.
* <p>
* Le RestTemplate ({@code brainImportRestTemplate}) a un timeout long ; le secret
* inter-service est ajouté par l'intercepteur du bean (RestTemplate) et par le
* WebClientCustomizer (WebClient).
*/
@Component
public class BrainRulesImportClient implements RulesPdfImporter {
private static final String IMPORT_RULES_PATH = "/import/rules";
private static final String IMPORT_RULES_STREAM_PATH = "/import/rules/stream";
private static final ParameterizedTypeReference<ServerSentEvent<String>> SSE_STRING_TYPE =
new ParameterizedTypeReference<>() {};
private final RestTemplate restTemplate;
private final WebClient webClient;
private final ObjectMapper objectMapper;
private final String baseUrl;
private final long importTimeoutSeconds;
public BrainRulesImportClient(
@Qualifier("brainImportRestTemplate") RestTemplate restTemplate,
WebClient.Builder webClientBuilder,
ObjectMapper objectMapper,
@Value("${brain.base-url}") String baseUrl,
@Value("${brain.import-timeout-seconds:600}") long importTimeoutSeconds) {
this.restTemplate = restTemplate;
this.webClient = webClientBuilder.baseUrl(baseUrl).build();
this.objectMapper = objectMapper;
this.baseUrl = baseUrl;
this.importTimeoutSeconds = importTimeoutSeconds;
}
// --- One-shot (bloquant) -------------------------------------------------
@Override
public RulesImportResult importRules(byte[] pdfBytes, String filename) {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.MULTIPART_FORM_DATA);
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
body.add("file", filePart(pdfBytes, filename));
HttpEntity<MultiValueMap<String, Object>> entity = new HttpEntity<>(body, headers);
try {
BrainRulesImportResponse response = restTemplate.postForObject(
baseUrl + IMPORT_RULES_PATH, entity, BrainRulesImportResponse.class);
if (response == null || response.getSections() == null) {
throw new RulesImportException("Le Brain a renvoyé une réponse vide.");
}
return new RulesImportResult(
Map.copyOf(response.getSections()),
response.getPageCount(),
response.getOcrPageCount());
} catch (ResourceAccessException e) {
throw new RulesImportException(
"Le Brain est injoignable (timeout ou service arrêté).", e);
} catch (RestClientResponseException e) {
throw new RulesImportException(
"Le Brain a répondu HTTP " + e.getStatusCode().value()
+ " : " + e.getResponseBodyAsString(), e);
} catch (RulesImportException e) {
throw e;
} catch (Exception e) {
throw new RulesImportException("Erreur inattendue lors de l'import via le Brain.", e);
}
}
// --- Streamé (SSE) -------------------------------------------------------
@Override
public void importRulesStreaming(
byte[] pdfBytes,
String filename,
Consumer<RulesImportProgress> onProgress,
Consumer<RulesImportResult> onDone,
Consumer<Throwable> onError) {
MultipartBodyBuilder parts = new MultipartBodyBuilder();
parts.part("file", filePart(pdfBytes, filename))
.filename(filename == null || filename.isBlank() ? "rules.pdf" : filename);
Flux<ServerSentEvent<String>> flux = webClient.post()
.uri(IMPORT_RULES_STREAM_PATH)
.contentType(MediaType.MULTIPART_FORM_DATA)
.accept(MediaType.TEXT_EVENT_STREAM)
.body(BodyInserters.fromMultipartData(parts.build()))
.retrieve()
.bodyToFlux(SSE_STRING_TYPE);
// Holders mutables : le flux est consommé séquentiellement (blockLast),
// donc pas de souci de concurrence sur ces compteurs.
int[] pageCount = {0};
int[] ocrPageCount = {0};
boolean[] terminated = {false};
try {
flux
.timeout(Duration.ofSeconds(importTimeoutSeconds))
.doOnNext(sse -> handleEvent(
sse, pageCount, ocrPageCount, terminated, onProgress, onDone, onError))
.blockLast();
// Flux terminé sans event done/error (ex: connexion coupée) → on signale.
if (!terminated[0]) {
onError.accept(new RulesImportException(
"Le flux d'import s'est interrompu avant la fin."));
}
} catch (Exception e) {
if (!terminated[0]) {
// On EXPOSE la cause réelle (type + message) : sans ça, l'UI n'a qu'un
// message générique et le diagnostic est impossible (timeout WebClient,
// connexion coupée, réponse non-2xx du Brain, etc.).
String cause = e.getClass().getSimpleName()
+ (e.getMessage() != null ? "" + e.getMessage() : "");
onError.accept(new RulesImportException(
"Erreur lors du streaming d'import depuis le Brain : " + cause, e));
}
}
}
private void handleEvent(
ServerSentEvent<String> sse,
int[] pageCount,
int[] ocrPageCount,
boolean[] terminated,
Consumer<RulesImportProgress> onProgress,
Consumer<RulesImportResult> onDone,
Consumer<Throwable> onError) {
String event = sse.event();
String data = sse.data() == null ? "" : sse.data();
if ("error".equals(event)) {
terminated[0] = true;
onError.accept(new RulesImportException(
"Le Brain a signalé une erreur : " + readMessage(data)));
return;
}
if ("extracting".equals(event)) {
// Phase d'extraction : total inconnu (0) → l'UI affiche "Extraction…".
onProgress.accept(new RulesImportProgress(0, 0, 0, 0, List.of()));
return;
}
JsonNode node = readJson(data);
if (node == null) return;
if ("start".equals(event)) {
pageCount[0] = node.path("page_count").asInt();
ocrPageCount[0] = node.path("ocr_page_count").asInt();
onProgress.accept(new RulesImportProgress(
0, node.path("total").asInt(), pageCount[0], ocrPageCount[0], List.of()));
} else if ("progress".equals(event)) {
onProgress.accept(new RulesImportProgress(
node.path("current").asInt(),
node.path("total").asInt(),
pageCount[0],
ocrPageCount[0],
toStringList(node.path("new_sections"))));
} else if ("done".equals(event)) {
terminated[0] = true;
onDone.accept(new RulesImportResult(
toStringMap(node.path("sections")),
node.path("page_count").asInt(),
node.path("ocr_page_count").asInt()));
}
}
// --- Helpers -------------------------------------------------------------
/** ByteArrayResource avec nom de fichier : sans nom, l'upload n'est pas vu comme un fichier. */
private ByteArrayResource filePart(byte[] pdfBytes, String filename) {
return new ByteArrayResource(pdfBytes) {
@Override
public String getFilename() {
return (filename == null || filename.isBlank()) ? "rules.pdf" : filename;
}
};
}
private JsonNode readJson(String data) {
try {
return objectMapper.readTree(data);
} catch (Exception e) {
return null; // morceau de flux non-JSON inattendu : on l'ignore.
}
}
private String readMessage(String data) {
JsonNode node = readJson(data);
if (node != null && node.hasNonNull("message")) {
return node.get("message").asText();
}
return data;
}
private List<String> toStringList(JsonNode array) {
List<String> out = new ArrayList<>();
if (array != null && array.isArray()) {
array.forEach(n -> out.add(n.asText()));
}
return out;
}
private Map<String, String> toStringMap(JsonNode object) {
Map<String, String> out = new LinkedHashMap<>();
if (object != null && object.isObject()) {
object.fields().forEachRemaining(e -> out.put(e.getKey(), e.getValue().asText()));
}
return out;
}
}

View File

@@ -1,26 +0,0 @@
package com.loremind.infrastructure.ai;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.Map;
/**
* DTO interne de l'Adapter : JSON reçu du Brain sur POST /import/rules.
*
* @Data + @NoArgsConstructor : requis par Jackson pour la désérialisation.
*/
@Data
@NoArgsConstructor
class BrainRulesImportResponse {
@JsonProperty("sections")
private Map<String, String> sections;
@JsonProperty("page_count")
private int pageCount;
@JsonProperty("ocr_page_count")
private int ocrPageCount;
}

View File

@@ -5,7 +5,6 @@ import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.boot.web.reactive.function.client.WebClientCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.web.client.RestTemplate;
import java.time.Duration;
@@ -24,7 +23,6 @@ public class RestTemplateConfig {
private static final String INTERNAL_SECRET_HEADER = "X-Internal-Secret";
@Bean
@Primary
public RestTemplate brainRestTemplate(
RestTemplateBuilder builder,
@Value("${brain.timeout-seconds}") long timeoutSeconds,
@@ -41,29 +39,6 @@ public class RestTemplateConfig {
.build();
}
/**
* RestTemplate dédié à l'import de PDF (règles/campagne) au timeout LONG :
* l'extraction + la structuration map-reduce d'un livre entier enchaîne de
* nombreux appels LLM et dépasse facilement le timeout des appels courts.
* Même entête d'auth inter-service que {@link #brainRestTemplate}.
*/
@Bean
public RestTemplate brainImportRestTemplate(
RestTemplateBuilder builder,
@Value("${brain.import-timeout-seconds:600}") long importTimeoutSeconds,
@Value("${brain.internal-secret}") String internalSecret) {
return builder
.setConnectTimeout(Duration.ofSeconds(10))
.setReadTimeout(Duration.ofSeconds(importTimeoutSeconds))
.additionalInterceptors((request, body, execution) -> {
if (internalSecret != null && !internalSecret.isBlank()) {
request.getHeaders().set(INTERNAL_SECRET_HEADER, internalSecret);
}
return execution.execute(request, body);
})
.build();
}
/**
* Ajoute X-Internal-Secret comme header par defaut a tous les WebClient
* construits via le builder auto-configure par Spring Boot. Evite de

View File

@@ -1,49 +0,0 @@
package com.loremind.infrastructure.persistence.entity;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import lombok.ToString;
/**
* Entité JPA d'un objet de catalogue (enfant de {@link ItemCatalogJpaEntity}).
* Ordonné par {@code position}. Référence parente exclue de toString/equals.
*/
@Entity
@Table(name = "catalog_items", indexes = {
@Index(name = "idx_catalog_items_catalog_id", columnList = "catalog_id")
})
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class CatalogItemJpaEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
@Column(length = 64)
private String price;
@Column(length = 128)
private String category;
@Column(columnDefinition = "TEXT")
private String description;
@Column(nullable = false)
private int position;
@ManyToOne(optional = false, fetch = FetchType.LAZY)
@JoinColumn(name = "catalog_id", nullable = false)
@ToString.Exclude
@EqualsAndHashCode.Exclude
private ItemCatalogJpaEntity catalog;
}

View File

@@ -1,71 +0,0 @@
package com.loremind.infrastructure.persistence.entity;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
/**
* Entité JPA d'un catalogue d'objets (parent) avec ses objets ordonnés (enfants).
*/
@Entity
@Table(name = "item_catalogs", indexes = {
@Index(name = "idx_item_catalogs_campaign_id", columnList = "campaign_id")
})
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ItemCatalogJpaEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
@Column(columnDefinition = "TEXT")
private String description;
@Column(length = 64)
private String icon;
@Column(name = "campaign_id", nullable = false)
private Long campaignId;
@Column(name = "\"order\"", nullable = false)
private int order;
@OneToMany(
mappedBy = "catalog",
cascade = CascadeType.ALL,
orphanRemoval = true,
fetch = FetchType.LAZY
)
@OrderBy("position ASC")
@Builder.Default
private List<CatalogItemJpaEntity> items = new ArrayList<>();
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
updatedAt = LocalDateTime.now();
}
@PreUpdate
protected void onUpdate() {
updatedAt = LocalDateTime.now();
}
}

View File

@@ -1,47 +0,0 @@
package com.loremind.infrastructure.persistence.entity;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
@Entity
@Table(name = "notebooks", indexes = {
@Index(name = "idx_notebooks_campaign_id", columnList = "campaign_id")
})
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class NotebookJpaEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
@Column(name = "campaign_id", nullable = false)
private Long campaignId;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
updatedAt = LocalDateTime.now();
}
@PreUpdate
protected void onUpdate() {
updatedAt = LocalDateTime.now();
}
}

View File

@@ -1,41 +0,0 @@
package com.loremind.infrastructure.persistence.entity;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
@Entity
@Table(name = "notebook_messages", indexes = {
@Index(name = "idx_notebook_messages_notebook_id", columnList = "notebook_id")
})
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class NotebookMessageJpaEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "notebook_id", nullable = false)
private Long notebookId;
@Column(nullable = false, length = 16)
private String role;
@Column(columnDefinition = "TEXT", nullable = false)
private String content;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@PrePersist
protected void onCreate() {
if (createdAt == null) createdAt = LocalDateTime.now();
}
}

View File

@@ -1,47 +0,0 @@
package com.loremind.infrastructure.persistence.entity;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
@Entity
@Table(name = "notebook_sources", indexes = {
@Index(name = "idx_notebook_sources_notebook_id", columnList = "notebook_id")
})
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class NotebookSourceJpaEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "notebook_id", nullable = false)
private Long notebookId;
@Column(nullable = false)
private String filename;
@Column(nullable = false, length = 16)
private String status;
@Column(name = "chunk_count", nullable = false)
private int chunkCount;
@Column(name = "page_count", nullable = false)
private int pageCount;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@PrePersist
protected void onCreate() {
if (createdAt == null) createdAt = LocalDateTime.now();
}
}

View File

@@ -54,9 +54,6 @@ public class NpcJpaEntity {
@Column(name = "campaign_id", nullable = false)
private Long campaignId;
@Column(name = "folder")
private String folder;
@Column(name = "\"order\"", nullable = false)
private int order;

View File

@@ -1,51 +0,0 @@
package com.loremind.infrastructure.persistence.entity;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import lombok.ToString;
/**
* Entité JPA d'une entrée de table aléatoire (enfant de {@link RandomTableJpaEntity}).
* Ordonnée par {@code position}. La référence parente est exclue de toString/equals
* pour éviter les récursions infinies (relation bidirectionnelle).
*/
@Entity
@Table(name = "random_table_entries", indexes = {
@Index(name = "idx_random_table_entries_table_id", columnList = "random_table_id")
})
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class RandomTableEntryJpaEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "min_roll", nullable = false)
private int minRoll;
@Column(name = "max_roll", nullable = false)
private int maxRoll;
@Column(nullable = false)
private String label;
@Column(columnDefinition = "TEXT")
private String detail;
/** Position d'affichage dans la table (ordre des entrées). */
@Column(nullable = false)
private int position;
@ManyToOne(optional = false, fetch = FetchType.LAZY)
@JoinColumn(name = "random_table_id", nullable = false)
@ToString.Exclude
@EqualsAndHashCode.Exclude
private RandomTableJpaEntity randomTable;
}

View File

@@ -1,73 +0,0 @@
package com.loremind.infrastructure.persistence.entity;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
/**
* Entité JPA d'une table aléatoire (parent) avec ses entrées ordonnées (enfants).
* Tables créées automatiquement par Hibernate (ddl-auto=update).
*/
@Entity
@Table(name = "random_tables")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class RandomTableJpaEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
@Column(columnDefinition = "TEXT")
private String description;
@Column(name = "dice_formula", nullable = false, length = 32)
private String diceFormula;
@Column(length = 64)
private String icon;
@Column(name = "campaign_id", nullable = false)
private Long campaignId;
@Column(name = "\"order\"", nullable = false)
private int order;
@OneToMany(
mappedBy = "randomTable",
cascade = CascadeType.ALL,
orphanRemoval = true,
fetch = FetchType.LAZY
)
@OrderBy("position ASC")
@Builder.Default
private List<RandomTableEntryJpaEntity> entries = new ArrayList<>();
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
updatedAt = LocalDateTime.now();
}
@PreUpdate
protected void onUpdate() {
updatedAt = LocalDateTime.now();
}
}

View File

@@ -1,13 +0,0 @@
package com.loremind.infrastructure.persistence.jpa;
import com.loremind.infrastructure.persistence.entity.ItemCatalogJpaEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface ItemCatalogJpaRepository extends JpaRepository<ItemCatalogJpaEntity, Long> {
List<ItemCatalogJpaEntity> findByCampaignIdOrderByOrderAsc(Long campaignId);
}

View File

@@ -1,12 +0,0 @@
package com.loremind.infrastructure.persistence.jpa;
import com.loremind.infrastructure.persistence.entity.NotebookJpaEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface NotebookJpaRepository extends JpaRepository<NotebookJpaEntity, Long> {
List<NotebookJpaEntity> findByCampaignIdOrderByUpdatedAtDesc(Long campaignId);
}

View File

@@ -1,13 +0,0 @@
package com.loremind.infrastructure.persistence.jpa;
import com.loremind.infrastructure.persistence.entity.NotebookMessageJpaEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface NotebookMessageJpaRepository extends JpaRepository<NotebookMessageJpaEntity, Long> {
List<NotebookMessageJpaEntity> findByNotebookIdOrderByCreatedAtAsc(Long notebookId);
void deleteByNotebookId(Long notebookId);
}

View File

@@ -1,13 +0,0 @@
package com.loremind.infrastructure.persistence.jpa;
import com.loremind.infrastructure.persistence.entity.NotebookSourceJpaEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface NotebookSourceJpaRepository extends JpaRepository<NotebookSourceJpaEntity, Long> {
List<NotebookSourceJpaEntity> findByNotebookIdOrderByCreatedAtAsc(Long notebookId);
void deleteByNotebookId(Long notebookId);
}

View File

@@ -1,13 +0,0 @@
package com.loremind.infrastructure.persistence.jpa;
import com.loremind.infrastructure.persistence.entity.RandomTableJpaEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface RandomTableJpaRepository extends JpaRepository<RandomTableJpaEntity, Long> {
List<RandomTableJpaEntity> findByCampaignIdOrderByOrderAsc(Long campaignId);
}

View File

@@ -1,101 +0,0 @@
package com.loremind.infrastructure.persistence.postgres;
import com.loremind.domain.campaigncontext.CatalogItem;
import com.loremind.domain.campaigncontext.ItemCatalog;
import com.loremind.domain.campaigncontext.ports.ItemCatalogRepository;
import com.loremind.infrastructure.persistence.entity.CatalogItemJpaEntity;
import com.loremind.infrastructure.persistence.entity.ItemCatalogJpaEntity;
import com.loremind.infrastructure.persistence.jpa.ItemCatalogJpaRepository;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
@Repository
public class PostgresItemCatalogRepository implements ItemCatalogRepository {
private final ItemCatalogJpaRepository jpaRepository;
public PostgresItemCatalogRepository(ItemCatalogJpaRepository jpaRepository) {
this.jpaRepository = jpaRepository;
}
@Override
@Transactional
public ItemCatalog save(ItemCatalog catalog) {
ItemCatalogJpaEntity entity = (catalog.getId() != null)
? jpaRepository.findById(Long.parseLong(catalog.getId())).orElseGet(ItemCatalogJpaEntity::new)
: new ItemCatalogJpaEntity();
entity.setName(catalog.getName());
entity.setDescription(catalog.getDescription());
entity.setIcon(catalog.getIcon());
entity.setCampaignId(Long.parseLong(catalog.getCampaignId()));
entity.setOrder(catalog.getOrder());
// Remplacement en bloc des objets (orphanRemoval supprime les anciens).
entity.getItems().clear();
int position = 0;
for (CatalogItem it : catalog.getItems()) {
entity.getItems().add(CatalogItemJpaEntity.builder()
.name(it.getName())
.price(it.getPrice())
.category(it.getCategory())
.description(it.getDescription())
.position(position++)
.catalog(entity)
.build());
}
return toDomainEntity(jpaRepository.save(entity));
}
@Override
@Transactional(readOnly = true)
public Optional<ItemCatalog> findById(String id) {
return jpaRepository.findById(Long.parseLong(id)).map(this::toDomainEntity);
}
@Override
@Transactional(readOnly = true)
public List<ItemCatalog> findByCampaignId(String campaignId) {
return jpaRepository.findByCampaignIdOrderByOrderAsc(Long.parseLong(campaignId)).stream()
.map(this::toDomainEntity)
.collect(Collectors.toList());
}
@Override
public void deleteById(String id) {
jpaRepository.deleteById(Long.parseLong(id));
}
@Override
public boolean existsById(String id) {
return jpaRepository.existsById(Long.parseLong(id));
}
private ItemCatalog toDomainEntity(ItemCatalogJpaEntity e) {
List<CatalogItem> items = e.getItems().stream()
.map(c -> CatalogItem.builder()
.name(c.getName())
.price(c.getPrice())
.category(c.getCategory())
.description(c.getDescription())
.build())
.collect(Collectors.toCollection(ArrayList::new));
return ItemCatalog.builder()
.id(e.getId().toString())
.name(e.getName())
.description(e.getDescription())
.icon(e.getIcon())
.campaignId(e.getCampaignId().toString())
.order(e.getOrder())
.items(items)
.createdAt(e.getCreatedAt())
.updatedAt(e.getUpdatedAt())
.build();
}
}

View File

@@ -1,155 +0,0 @@
package com.loremind.infrastructure.persistence.postgres;
import com.loremind.domain.campaigncontext.Notebook;
import com.loremind.domain.campaigncontext.NotebookMessage;
import com.loremind.domain.campaigncontext.NotebookSource;
import com.loremind.domain.campaigncontext.ports.NotebookRepository;
import com.loremind.infrastructure.persistence.entity.NotebookJpaEntity;
import com.loremind.infrastructure.persistence.entity.NotebookMessageJpaEntity;
import com.loremind.infrastructure.persistence.entity.NotebookSourceJpaEntity;
import com.loremind.infrastructure.persistence.jpa.NotebookJpaRepository;
import com.loremind.infrastructure.persistence.jpa.NotebookMessageJpaRepository;
import com.loremind.infrastructure.persistence.jpa.NotebookSourceJpaRepository;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
@Repository
public class PostgresNotebookRepository implements NotebookRepository {
private final NotebookJpaRepository notebookJpa;
private final NotebookSourceJpaRepository sourceJpa;
private final NotebookMessageJpaRepository messageJpa;
public PostgresNotebookRepository(
NotebookJpaRepository notebookJpa,
NotebookSourceJpaRepository sourceJpa,
NotebookMessageJpaRepository messageJpa) {
this.notebookJpa = notebookJpa;
this.sourceJpa = sourceJpa;
this.messageJpa = messageJpa;
}
// --- Notebook ---
@Override
public Notebook save(Notebook notebook) {
NotebookJpaEntity entity = notebook.getId() != null
? notebookJpa.findById(Long.parseLong(notebook.getId())).orElseGet(NotebookJpaEntity::new)
: new NotebookJpaEntity();
entity.setName(notebook.getName());
entity.setCampaignId(Long.parseLong(notebook.getCampaignId()));
return toNotebook(notebookJpa.save(entity));
}
@Override
public Optional<Notebook> findById(String id) {
return notebookJpa.findById(Long.parseLong(id)).map(this::toNotebook);
}
@Override
public List<Notebook> findByCampaignId(String campaignId) {
return notebookJpa.findByCampaignIdOrderByUpdatedAtDesc(Long.parseLong(campaignId)).stream()
.map(this::toNotebook).collect(Collectors.toList());
}
@Override
@Transactional
public void deleteById(String id) {
Long nid = Long.parseLong(id);
messageJpa.deleteByNotebookId(nid);
sourceJpa.deleteByNotebookId(nid);
notebookJpa.deleteById(nid);
}
@Override
public boolean existsById(String id) {
return notebookJpa.existsById(Long.parseLong(id));
}
// --- Sources ---
@Override
public NotebookSource saveSource(NotebookSource source) {
NotebookSourceJpaEntity entity = source.getId() != null
? sourceJpa.findById(Long.parseLong(source.getId())).orElseGet(NotebookSourceJpaEntity::new)
: new NotebookSourceJpaEntity();
entity.setNotebookId(Long.parseLong(source.getNotebookId()));
entity.setFilename(source.getFilename());
entity.setStatus(source.getStatus());
entity.setChunkCount(source.getChunkCount());
entity.setPageCount(source.getPageCount());
return toSource(sourceJpa.save(entity));
}
@Override
public Optional<NotebookSource> findSourceById(String id) {
return sourceJpa.findById(Long.parseLong(id)).map(this::toSource);
}
@Override
public List<NotebookSource> findSourcesByNotebookId(String notebookId) {
return sourceJpa.findByNotebookIdOrderByCreatedAtAsc(Long.parseLong(notebookId)).stream()
.map(this::toSource).collect(Collectors.toList());
}
@Override
public void deleteSourceById(String id) {
sourceJpa.deleteById(Long.parseLong(id));
}
// --- Messages ---
@Override
public NotebookMessage saveMessage(NotebookMessage message) {
NotebookMessageJpaEntity entity = NotebookMessageJpaEntity.builder()
.notebookId(Long.parseLong(message.getNotebookId()))
.role(message.getRole())
.content(message.getContent())
.build();
return toMessage(messageJpa.save(entity));
}
@Override
public List<NotebookMessage> findMessagesByNotebookId(String notebookId) {
return messageJpa.findByNotebookIdOrderByCreatedAtAsc(Long.parseLong(notebookId)).stream()
.map(this::toMessage).collect(Collectors.toList());
}
// --- Mapping ---
private Notebook toNotebook(NotebookJpaEntity e) {
return Notebook.builder()
.id(e.getId().toString())
.name(e.getName())
.campaignId(e.getCampaignId().toString())
.createdAt(e.getCreatedAt())
.updatedAt(e.getUpdatedAt())
.build();
}
private NotebookSource toSource(NotebookSourceJpaEntity e) {
return NotebookSource.builder()
.id(e.getId().toString())
.notebookId(e.getNotebookId().toString())
.filename(e.getFilename())
.status(e.getStatus())
.chunkCount(e.getChunkCount())
.pageCount(e.getPageCount())
.createdAt(e.getCreatedAt())
.build();
}
private NotebookMessage toMessage(NotebookMessageJpaEntity e) {
return NotebookMessage.builder()
.id(e.getId().toString())
.notebookId(e.getNotebookId().toString())
.role(e.getRole())
.content(e.getContent())
.createdAt(e.getCreatedAt())
.build();
}
}

View File

@@ -59,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())
.folder(e.getFolder())
.order(e.getOrder())
.createdAt(e.getCreatedAt())
.updatedAt(e.getUpdatedAt())
@@ -77,7 +76,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()))
.folder(n.getFolder())
.order(n.getOrder())
.createdAt(n.getCreatedAt())
.updatedAt(n.getUpdatedAt())

View File

@@ -1,106 +0,0 @@
package com.loremind.infrastructure.persistence.postgres;
import com.loremind.domain.campaigncontext.RandomTable;
import com.loremind.domain.campaigncontext.RandomTableEntry;
import com.loremind.domain.campaigncontext.ports.RandomTableRepository;
import com.loremind.infrastructure.persistence.entity.RandomTableEntryJpaEntity;
import com.loremind.infrastructure.persistence.entity.RandomTableJpaEntity;
import com.loremind.infrastructure.persistence.jpa.RandomTableJpaRepository;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
@Repository
public class PostgresRandomTableRepository implements RandomTableRepository {
private final RandomTableJpaRepository jpaRepository;
public PostgresRandomTableRepository(RandomTableJpaRepository jpaRepository) {
this.jpaRepository = jpaRepository;
}
@Override
@Transactional
public RandomTable save(RandomTable table) {
// Création OU mise à jour : on charge l'entité gérée si elle existe afin que
// le remplacement des entrées (clear + add) déclenche bien orphanRemoval.
RandomTableJpaEntity entity = (table.getId() != null)
? jpaRepository.findById(Long.parseLong(table.getId())).orElseGet(RandomTableJpaEntity::new)
: new RandomTableJpaEntity();
entity.setName(table.getName());
entity.setDescription(table.getDescription());
entity.setDiceFormula(table.getDiceFormula());
entity.setIcon(table.getIcon());
entity.setCampaignId(Long.parseLong(table.getCampaignId()));
entity.setOrder(table.getOrder());
// Remplacement en bloc des entrées (les anciennes sont supprimées via orphanRemoval).
entity.getEntries().clear();
int position = 0;
for (RandomTableEntry e : table.getEntries()) {
entity.getEntries().add(RandomTableEntryJpaEntity.builder()
.minRoll(e.getMinRoll())
.maxRoll(e.getMaxRoll())
.label(e.getLabel())
.detail(e.getDetail())
.position(position++)
.randomTable(entity)
.build());
}
RandomTableJpaEntity saved = jpaRepository.save(entity);
return toDomainEntity(saved);
}
@Override
@Transactional(readOnly = true)
public Optional<RandomTable> findById(String id) {
return jpaRepository.findById(Long.parseLong(id)).map(this::toDomainEntity);
}
@Override
@Transactional(readOnly = true)
public List<RandomTable> findByCampaignId(String campaignId) {
return jpaRepository.findByCampaignIdOrderByOrderAsc(Long.parseLong(campaignId)).stream()
.map(this::toDomainEntity)
.collect(Collectors.toList());
}
@Override
public void deleteById(String id) {
jpaRepository.deleteById(Long.parseLong(id));
}
@Override
public boolean existsById(String id) {
return jpaRepository.existsById(Long.parseLong(id));
}
private RandomTable toDomainEntity(RandomTableJpaEntity e) {
List<RandomTableEntry> entries = e.getEntries().stream()
.map(c -> RandomTableEntry.builder()
.minRoll(c.getMinRoll())
.maxRoll(c.getMaxRoll())
.label(c.getLabel())
.detail(c.getDetail())
.build())
.collect(Collectors.toCollection(ArrayList::new));
return RandomTable.builder()
.id(e.getId().toString())
.name(e.getName())
.description(e.getDescription())
.diceFormula(e.getDiceFormula())
.icon(e.getIcon())
.campaignId(e.getCampaignId().toString())
.order(e.getOrder())
.entries(entries)
.createdAt(e.getCreatedAt())
.updatedAt(e.getUpdatedAt())
.build();
}
}

View File

@@ -1,97 +0,0 @@
package com.loremind.infrastructure.web.controller;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.loremind.application.campaigncontext.CampaignAdaptService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.core.task.TaskExecutor;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.io.IOException;
import java.util.Map;
/**
* REST Controller : conseils d'adaptation d'un PDF à une campagne existante (SSE).
* - POST /api/campaigns/{id}/adapt-pdf/stream (multipart) → flux de tokens markdown.
* Ne persiste rien : la sortie est du conseil libre à appliquer manuellement.
*/
@RestController
@RequestMapping("/api/campaigns/{campaignId}/adapt-pdf")
public class CampaignAdaptController {
private static final Logger log = LoggerFactory.getLogger(CampaignAdaptController.class);
private static final long SSE_TIMEOUT_MS = 15 * 60 * 1000L;
private final CampaignAdaptService campaignAdaptService;
private final TaskExecutor taskExecutor;
private final ObjectMapper objectMapper;
public CampaignAdaptController(
CampaignAdaptService campaignAdaptService,
@Qualifier("applicationTaskExecutor") TaskExecutor taskExecutor,
ObjectMapper objectMapper) {
this.campaignAdaptService = campaignAdaptService;
this.taskExecutor = taskExecutor;
this.objectMapper = objectMapper;
}
@PostMapping(value = "/stream",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter adaptStream(
@PathVariable String campaignId,
@RequestParam("file") MultipartFile file,
@RequestParam(value = "messages", required = false) String messagesJson) throws IOException {
SseEmitter emitter = new SseEmitter(SSE_TIMEOUT_MS);
if (file == null || file.isEmpty()) {
sendError(emitter, "Fichier PDF vide.");
return emitter;
}
byte[] bytes = file.getBytes();
String filename = file.getOriginalFilename();
taskExecutor.execute(() -> {
try {
campaignAdaptService.adviseStreaming(
campaignId, bytes, filename, messagesJson,
token -> sendToken(emitter, token),
() -> {
sendEvent(emitter, "done", Map.of());
emitter.complete();
},
error -> {
log.warn("Adaptation PDF échouée : {}", error.getMessage());
sendError(emitter, error.getMessage());
});
} catch (IllegalArgumentException e) {
sendError(emitter, "Campagne introuvable.");
} catch (Exception e) {
log.warn("Adaptation PDF échouée : {}", e.getMessage());
sendError(emitter, e.getMessage());
}
});
return emitter;
}
private void sendToken(SseEmitter emitter, String token) {
sendEvent(emitter, "token", Map.of("token", token));
}
private void sendError(SseEmitter emitter, String message) {
sendEvent(emitter, "error", Map.of("message", message != null ? message : "Erreur inconnue."));
emitter.complete();
}
private void sendEvent(SseEmitter emitter, String eventName, Object payload) {
try {
emitter.send(SseEmitter.event().name(eventName).data(
objectMapper.writeValueAsString(payload), MediaType.APPLICATION_JSON));
} catch (IOException e) {
emitter.completeWithError(e);
}
}
}

View File

@@ -1,119 +0,0 @@
package com.loremind.infrastructure.web.controller;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.loremind.application.campaigncontext.CampaignImportService;
import com.loremind.domain.campaigncontext.CampaignImportProposal;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.core.task.TaskExecutor;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.io.IOException;
import java.util.Map;
/**
* REST Controller pour l'import d'un PDF de campagne → arbre arc/chapitre/scène.
* <p>
* - POST /api/campaigns/{id}/import-structure/stream (multipart) → SSE de la
* proposition (progress + done). Ne persiste rien.
* - POST /api/campaigns/{id}/import-structure/apply (JSON arbre révisé) →
* crée les entités et renvoie le récapitulatif.
*/
@RestController
@RequestMapping("/api/campaigns/{campaignId}/import-structure")
public class CampaignImportController {
private static final Logger log = LoggerFactory.getLogger(CampaignImportController.class);
/** 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;
private final ObjectMapper objectMapper;
public CampaignImportController(
CampaignImportService campaignImportService,
@Qualifier("applicationTaskExecutor") TaskExecutor taskExecutor,
ObjectMapper objectMapper) {
this.campaignImportService = campaignImportService;
this.taskExecutor = taskExecutor;
this.objectMapper = objectMapper;
}
@PostMapping(value = "/stream",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter importStream(
@PathVariable String campaignId,
@RequestParam("file") MultipartFile file) throws IOException {
SseEmitter emitter = new SseEmitter(IMPORT_SSE_TIMEOUT_MS);
if (file == null || file.isEmpty()) {
sendError(emitter, "Fichier PDF vide.");
return emitter;
}
byte[] bytes = file.getBytes();
String filename = file.getOriginalFilename();
taskExecutor.execute(() -> {
try {
campaignImportService.importStructureStreaming(
bytes, filename,
progress -> sendEvent(emitter, "progress", progress),
proposal -> {
sendEvent(emitter, "done", proposal);
emitter.complete();
},
error -> {
log.warn("Import campagne (stream) échoué : {}", error.getMessage());
sendError(emitter, error.getMessage());
});
} catch (Exception e) {
log.warn("Import campagne (stream) échoué : {}", e.getMessage());
sendError(emitter, e.getMessage());
}
});
return emitter;
}
@PostMapping(value = "/apply", consumes = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<CampaignImportService.ApplyResult> apply(
@PathVariable String campaignId,
@RequestBody CampaignImportProposal proposal) {
try {
CampaignImportService.ApplyResult result =
campaignImportService.applyStructure(campaignId, proposal);
return ResponseEntity.ok(result);
} catch (IllegalArgumentException e) {
return ResponseEntity.notFound().build();
}
}
// --- Helpers SSE ---------------------------------------------------------
private void sendEvent(SseEmitter emitter, String eventName, Object payload) {
try {
emitter.send(SseEmitter.event().name(eventName).data(
objectMapper.writeValueAsString(payload), MediaType.APPLICATION_JSON));
} catch (IOException e) {
emitter.completeWithError(e);
}
}
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 (IOException e) {
emitter.completeWithError(e);
}
}
}

View File

@@ -1,59 +1,33 @@
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;
import com.loremind.infrastructure.web.dto.gamesystemcontext.GameSystemDTO;
import com.loremind.infrastructure.web.dto.gamesystemcontext.RulesImportResponseDTO;
import com.loremind.infrastructure.web.dto.shared.TemplateFieldDTO;
import com.loremind.infrastructure.web.mapper.GameSystemMapper;
import com.loremind.infrastructure.web.mapper.TemplateFieldMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.core.task.TaskExecutor;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@RestController
@RequestMapping("/api/game-systems")
public class GameSystemController {
private static final Logger log = LoggerFactory.getLogger(GameSystemController.class);
/** 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;
private final TemplateFieldMapper templateFieldMapper;
private final TaskExecutor taskExecutor;
private final ObjectMapper objectMapper;
public GameSystemController(GameSystemService gameSystemService,
GameSystemMapper gameSystemMapper,
TemplateFieldMapper templateFieldMapper,
@Qualifier("applicationTaskExecutor") TaskExecutor taskExecutor,
ObjectMapper objectMapper) {
TemplateFieldMapper templateFieldMapper) {
this.gameSystemService = gameSystemService;
this.gameSystemMapper = gameSystemMapper;
this.templateFieldMapper = templateFieldMapper;
this.taskExecutor = taskExecutor;
this.objectMapper = objectMapper;
}
@PostMapping
@@ -97,96 +71,6 @@ public class GameSystemController {
return ResponseEntity.noContent().build();
}
/**
* Import d'un PDF de règles → proposition de sections (titre → markdown).
* Ne persiste RIEN : l'UI présente la proposition pour révision/édition,
* puis l'utilisateur enregistre le GameSystem via les endpoints habituels.
*/
@PostMapping(value = "/import-rules", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<RulesImportResponseDTO> importRules(@RequestParam("file") MultipartFile file) {
if (file == null || file.isEmpty()) {
return ResponseEntity.badRequest().build();
}
try {
RulesImportResult result = gameSystemService.importRulesFromPdf(
file.getBytes(), file.getOriginalFilename());
return ResponseEntity.ok(new RulesImportResponseDTO(
result.sections(), result.pageCount(), result.ocrPageCount()));
} catch (IOException e) {
return ResponseEntity.badRequest().build();
} catch (RulesImportException e) {
// Brain injoignable / LLM en erreur / PDF illisible : 502 (dépendance amont).
// On loggue la vraie cause (message du Brain propagé) pour le diagnostic.
log.warn("Import de règles échoué : {}", e.getMessage());
return ResponseEntity.status(HttpStatus.BAD_GATEWAY).build();
}
}
/**
* Variante streamée de l'import : émet l'avancement (SSE) puis le résultat,
* pour que l'UI affiche une progression pendant un import long.
* <p>
* Événements : {@code progress} (current/total/pageCount/ocrPageCount/newSectionTitles),
* {@code done} (sections + compteurs), {@code error} (message).
*/
@PostMapping(value = "/import-rules/stream",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter importRulesStream(@RequestParam("file") MultipartFile file) throws IOException {
SseEmitter emitter = new SseEmitter(IMPORT_SSE_TIMEOUT_MS);
if (file == null || file.isEmpty()) {
sendImportError(emitter, "Fichier PDF vide.");
return emitter;
}
// Les octets sont lus sur le thread servlet (le MultipartFile n'est plus
// disponible une fois la requête asynchrone) avant de partir en tâche de fond.
byte[] bytes = file.getBytes();
String filename = file.getOriginalFilename();
taskExecutor.execute(() -> {
try {
gameSystemService.importRulesFromPdfStreaming(
bytes, filename,
progress -> sendImportEvent(emitter, "progress", progress),
result -> {
sendImportEvent(emitter, "done", result);
emitter.complete();
},
error -> {
log.warn("Import de règles (stream) échoué : {}", error.getMessage());
sendImportError(emitter, error.getMessage());
});
} catch (Exception e) {
log.warn("Import de règles (stream) échoué : {}", e.getMessage());
sendImportError(emitter, e.getMessage());
}
});
return emitter;
}
/** Sérialise `payload` en JSON et l'envoie comme évènement SSE nommé. */
private void sendImportEvent(SseEmitter emitter, String eventName, Object payload) {
try {
emitter.send(SseEmitter.event().name(eventName).data(
objectMapper.writeValueAsString(payload), MediaType.APPLICATION_JSON));
} catch (IOException e) {
emitter.completeWithError(e);
}
}
/** Envoie un évènement `error` {message} puis termine le flux. */
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 (IOException e) {
emitter.completeWithError(e);
}
}
private GameSystemService.GameSystemData toData(GameSystemDTO dto) {
return new GameSystemService.GameSystemData(
dto.getName(),

View File

@@ -1,84 +0,0 @@
package com.loremind.infrastructure.web.controller;
import com.loremind.application.campaigncontext.ItemCatalogService;
import com.loremind.domain.campaigncontext.ItemCatalog;
import com.loremind.domain.campaigncontext.ports.ItemCatalogGenerationException;
import com.loremind.infrastructure.web.dto.campaigncontext.ItemCatalogDTO;
import com.loremind.infrastructure.web.mapper.ItemCatalogMapper;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
import java.util.stream.Collectors;
@RestController
@RequestMapping("/api/item-catalogs")
public class ItemCatalogController {
private final ItemCatalogService service;
private final ItemCatalogMapper mapper;
public ItemCatalogController(ItemCatalogService service, ItemCatalogMapper mapper) {
this.service = service;
this.mapper = mapper;
}
@PostMapping
public ResponseEntity<ItemCatalogDTO> create(@RequestBody ItemCatalogDTO dto) {
ItemCatalog created = service.createCatalog(toData(dto, null));
return ResponseEntity.ok(mapper.toDTO(created));
}
@GetMapping("/{id}")
public ResponseEntity<ItemCatalogDTO> getById(@PathVariable String id) {
return service.getCatalogById(id)
.map(c -> ResponseEntity.ok(mapper.toDTO(c)))
.orElse(ResponseEntity.notFound().build());
}
@GetMapping("/campaign/{campaignId}")
public ResponseEntity<List<ItemCatalogDTO>> getByCampaign(@PathVariable String campaignId) {
List<ItemCatalogDTO> dtos = service.getCatalogsByCampaignId(campaignId).stream()
.map(mapper::toDTO)
.collect(Collectors.toList());
return ResponseEntity.ok(dtos);
}
@PutMapping("/{id}")
public ResponseEntity<ItemCatalogDTO> update(@PathVariable String id, @RequestBody ItemCatalogDTO dto) {
ItemCatalog updated = service.updateCatalog(id, toData(dto, dto.getOrder()));
return ResponseEntity.ok(mapper.toDTO(updated));
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable String id) {
service.deleteCatalog(id);
return ResponseEntity.noContent().build();
}
/** Génère une PROPOSITION de catalogue via l'IA (non persistée) — l'UI préremplit le formulaire. */
@PostMapping("/generate")
public ResponseEntity<ItemCatalogDTO> generate(@RequestBody GenerateRequest req) {
try {
ItemCatalog proposal = service.generateProposal(req.campaignId(), req.description());
return ResponseEntity.ok(mapper.toDTO(proposal));
} catch (ItemCatalogGenerationException e) {
throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, e.getMessage(), e);
}
}
private ItemCatalogService.CatalogData toData(ItemCatalogDTO dto, Integer order) {
return new ItemCatalogService.CatalogData(
dto.getName(),
dto.getDescription(),
dto.getIcon(),
mapper.toDomainItems(dto.getItems()),
dto.getCampaignId(),
order
);
}
public record GenerateRequest(String campaignId, String description) {}
}

View File

@@ -1,214 +0,0 @@
package com.loremind.infrastructure.web.controller;
import com.loremind.application.campaigncontext.NotebookService;
import com.loremind.domain.campaigncontext.Notebook;
import com.loremind.domain.campaigncontext.NotebookSource;
import com.loremind.domain.campaigncontext.ports.NotebookChatStreamer;
import com.loremind.domain.campaigncontext.ports.NotebookException;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.core.task.TaskExecutor;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.io.IOException;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* REST Controller des notebooks (atelier RAG). CRUD + upload/indexation de sources
* + chat ancré streamé (SSE) qui persiste la conversation.
*/
@RestController
@RequestMapping("/api/notebooks")
public class NotebookController {
// L'analyse approfondie (map-reduce sur tout le doc) peut être longue sur un gros
// livre / un modèle lent → 30 min. Pour aller plus vite : modèle gros-contexte
// (moins de lots). Au-delà, l'expiration est gérée proprement (pas de crash).
private static final long SSE_TIMEOUT_MS = 30 * 60 * 1000L;
private final NotebookService service;
private final NotebookChatStreamer chatStreamer;
private final TaskExecutor taskExecutor;
public NotebookController(
NotebookService service,
NotebookChatStreamer chatStreamer,
@Qualifier("applicationTaskExecutor") TaskExecutor taskExecutor) {
this.service = service;
this.chatStreamer = chatStreamer;
this.taskExecutor = taskExecutor;
}
// --- Notebooks ---
@PostMapping
public ResponseEntity<Notebook> create(@RequestBody CreateRequest req) {
return ResponseEntity.ok(service.createNotebook(req.campaignId(), req.name()));
}
@GetMapping("/campaign/{campaignId}")
public ResponseEntity<List<Notebook>> listByCampaign(@PathVariable String campaignId) {
return ResponseEntity.ok(service.getNotebooksByCampaign(campaignId));
}
@GetMapping("/{id}")
public ResponseEntity<Map<String, Object>> get(@PathVariable String id) {
Notebook nb = service.getNotebook(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Notebook introuvable"));
Map<String, Object> out = new LinkedHashMap<>();
out.put("id", nb.getId());
out.put("name", nb.getName());
out.put("campaignId", nb.getCampaignId());
out.put("sources", service.getSources(id));
out.put("messages", service.getMessages(id));
return ResponseEntity.ok(out);
}
@PutMapping("/{id}")
public ResponseEntity<Notebook> rename(@PathVariable String id, @RequestBody RenameRequest req) {
return ResponseEntity.ok(service.renameNotebook(id, req.name()));
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable String id) {
service.deleteNotebook(id);
return ResponseEntity.noContent().build();
}
// --- Sources ---
@PostMapping("/{id}/sources")
public ResponseEntity<NotebookSource> addSource(
@PathVariable String id,
@RequestParam("file") MultipartFile file) {
try {
byte[] bytes = file.getBytes();
NotebookSource source = service.addSource(id, file.getOriginalFilename(), bytes);
return ResponseEntity.ok(source);
} catch (IOException e) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Fichier illisible", e);
} catch (NotebookException e) {
throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, e.getMessage(), e);
}
}
@DeleteMapping("/sources/{sourceId}")
public ResponseEntity<Void> deleteSource(@PathVariable String sourceId) {
service.deleteSource(sourceId);
return ResponseEntity.noContent().build();
}
// --- Chat ancré streamé ---
@PostMapping(value = "/{id}/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter chatStream(@PathVariable String id, @RequestBody ChatRequest req) {
SseEmitter emitter = new SseEmitter(SSE_TIMEOUT_MS);
Notebook nb = service.getNotebook(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Notebook introuvable"));
String userMessage = req.message() == null ? "" : req.message().trim();
if (userMessage.isEmpty()) {
fail(emitter, new IllegalArgumentException("Message vide."));
return emitter;
}
// Persiste le message utilisateur AVANT le stream (l'historique inclura ce tour).
service.addMessage(id, "user", userMessage);
List<NotebookChatStreamer.Msg> history = service.getMessages(id).stream()
.map(m -> new NotebookChatStreamer.Msg(m.getRole(), m.getContent()))
.toList();
List<String> sourceIds = service.readySourceIds(id);
String context = service.buildContext(nb.getCampaignId());
boolean deep = req.deep() != null && req.deep();
taskExecutor.execute(() -> {
StringBuilder assistant = new StringBuilder();
chatStreamer.stream(
sourceIds, history, context, deep,
token -> { assistant.append(token); sendToken(emitter, token); },
progress -> sendProgress(emitter, progress),
() -> {
// Persiste la réponse de l'assistant à la fin du stream.
if (assistant.length() > 0) {
service.addMessage(id, "assistant", assistant.toString());
}
complete(emitter);
},
error -> fail(emitter, error));
});
return emitter;
}
// --- Helpers SSE ---
// IMPORTANT : on attrape AUSSI IllegalStateException. Si le flux a déjà été fermé
// (timeout async, client déconnecté), `emitter.send/complete` la lève — et comme
// ces helpers tournent dans un thread d'exécuteur, une exception non gérée y
// remontait jusqu'au pool ("Exception in thread task-1: ResponseBodyEmitter has
// already completed"). On l'ignore silencieusement : il n'y a plus rien à envoyer.
private void sendToken(SseEmitter emitter, String token) {
try {
emitter.send(SseEmitter.event().name("token").data("{\"token\":" + jsonEscape(token) + "}"));
} 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")
.data("{\"current\":" + p.current() + ",\"total\":" + p.total() + "}"));
} catch (IOException | IllegalStateException e) {
// flux fermé/expiré : on cesse d'écrire
}
}
private void complete(SseEmitter emitter) {
try {
emitter.send(SseEmitter.event().name("done").data("{}"));
emitter.complete();
} catch (IOException | IllegalStateException e) {
// flux déjà fermé/expiré : rien à compléter
}
}
private void fail(SseEmitter emitter, Throwable error) {
try {
String message = error.getMessage() != null ? error.getMessage() : error.getClass().getSimpleName();
emitter.send(SseEmitter.event().name("error").data("{\"message\":" + jsonEscape(message) + "}"));
emitter.complete();
} catch (IOException | IllegalStateException e) {
// flux déjà fermé/expiré : rien à envoyer
}
}
private String jsonEscape(String raw) {
if (raw == null) return "\"\"";
StringBuilder sb = new StringBuilder(raw.length() + 2).append('"');
for (int i = 0; i < raw.length(); i++) {
char c = raw.charAt(i);
switch (c) {
case '"': sb.append("\\\""); break;
case '\\': sb.append("\\\\"); break;
case '\n': sb.append("\\n"); break;
case '\r': sb.append("\\r"); break;
case '\t': sb.append("\\t"); break;
default:
if (c < 0x20) sb.append(String.format("\\u%04x", (int) c));
else sb.append(c);
}
}
return sb.append('"').toString();
}
public record CreateRequest(String campaignId, String name) {}
public record RenameRequest(String name) {}
public record ChatRequest(String message, Boolean deep) {}
}

View File

@@ -64,7 +64,6 @@ public class NpcController {
dto.getImageValues(),
dto.getKeyValueValues(),
dto.getCampaignId(),
dto.getFolder(),
order
);
}

View File

@@ -1,100 +0,0 @@
package com.loremind.infrastructure.web.controller;
import com.loremind.application.campaigncontext.RandomTableService;
import com.loremind.domain.campaigncontext.RandomTable;
import com.loremind.domain.campaigncontext.ports.RandomTableGenerationException;
import com.loremind.infrastructure.web.dto.campaigncontext.RandomTableDTO;
import com.loremind.infrastructure.web.mapper.RandomTableMapper;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@RestController
@RequestMapping("/api/random-tables")
public class RandomTableController {
private final RandomTableService service;
private final RandomTableMapper mapper;
public RandomTableController(RandomTableService service, RandomTableMapper mapper) {
this.service = service;
this.mapper = mapper;
}
@PostMapping
public ResponseEntity<RandomTableDTO> create(@RequestBody RandomTableDTO dto) {
RandomTable created = service.createTable(toData(dto, null));
return ResponseEntity.ok(mapper.toDTO(created));
}
@GetMapping("/{id}")
public ResponseEntity<RandomTableDTO> getById(@PathVariable String id) {
return service.getTableById(id)
.map(t -> ResponseEntity.ok(mapper.toDTO(t)))
.orElse(ResponseEntity.notFound().build());
}
@GetMapping("/campaign/{campaignId}")
public ResponseEntity<List<RandomTableDTO>> getByCampaign(@PathVariable String campaignId) {
List<RandomTableDTO> dtos = service.getTablesByCampaignId(campaignId).stream()
.map(mapper::toDTO)
.collect(Collectors.toList());
return ResponseEntity.ok(dtos);
}
@PutMapping("/{id}")
public ResponseEntity<RandomTableDTO> update(@PathVariable String id, @RequestBody RandomTableDTO dto) {
RandomTable updated = service.updateTable(id, toData(dto, dto.getOrder()));
return ResponseEntity.ok(mapper.toDTO(updated));
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable String id) {
service.deleteTable(id);
return ResponseEntity.noContent().build();
}
/** Génère une PROPOSITION de table via l'IA (non persistée) — l'UI préremplit le formulaire. */
@PostMapping("/generate")
public ResponseEntity<RandomTableDTO> generate(@RequestBody GenerateRequest req) {
try {
RandomTable proposal = service.generateProposal(req.campaignId(), req.description(), req.diceFormula());
return ResponseEntity.ok(mapper.toDTO(proposal));
} catch (RandomTableGenerationException e) {
throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, e.getMessage(), e);
}
}
/** Improvisation IA d'un court récit sur un résultat tiré (utilisé en partie). */
@PostMapping("/improvise")
public ResponseEntity<Map<String, String>> improvise(@RequestBody ImproviseRequest req) {
try {
String narration = service.improviseRoll(
req.campaignId(), req.tableName(), req.resultLabel(), req.resultDetail());
return ResponseEntity.ok(Map.of("narration", narration));
} catch (RandomTableGenerationException e) {
throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, e.getMessage(), e);
}
}
public record GenerateRequest(String campaignId, String description, String diceFormula) {}
public record ImproviseRequest(String campaignId, String tableName, String resultLabel, String resultDetail) {}
private RandomTableService.TableData toData(RandomTableDTO dto, Integer order) {
return new RandomTableService.TableData(
dto.getName(),
dto.getDescription(),
dto.getDiceFormula(),
dto.getIcon(),
mapper.toDomainEntries(dto.getEntries()),
dto.getCampaignId(),
order
);
}
}

View File

@@ -140,21 +140,6 @@ public class SettingsController {
return forward(HttpMethod.GET, "/models/onemin", null);
}
@GetMapping("/models/openrouter")
public ResponseEntity<Map<String, Object>> listOpenRouterModels() {
return forward(HttpMethod.GET, "/models/openrouter", null);
}
@GetMapping("/models/mistral")
public ResponseEntity<Map<String, Object>> listMistralModels() {
return forward(HttpMethod.GET, "/models/mistral", null);
}
@GetMapping("/models/gemini")
public ResponseEntity<Map<String, Object>> listGeminiModels() {
return forward(HttpMethod.GET, "/models/gemini", null);
}
/**
* Serialiseur JSON minimal pour eviter d'instancier ObjectMapper a chaque
* appel. Suffisant pour notre cas d'usage : Map<String,Object> avec des

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