Compare commits
68 Commits
v0.8.3
...
439f43875b
| Author | SHA1 | Date | |
|---|---|---|---|
| 439f43875b | |||
| 79a68bc27b | |||
| c3d3394ec7 | |||
| 694f687fec | |||
| 87865338a0 | |||
| 586ddceff6 | |||
| 4b9b7f0995 | |||
| 3d73b1e6a7 | |||
| 759e47fc1f | |||
| f71bf3fcad | |||
| 0cd99dfb32 | |||
| f24ef0891e | |||
| 7c74c12f3e | |||
| 86836ad81c | |||
| 7c4a42327d | |||
| 52e389db24 | |||
| efaf5a3794 | |||
| 4fe93b5ff3 | |||
| 0f2d1b1efe | |||
| 5ff05242a8 | |||
| b06c77a1eb | |||
| 03bc669efe | |||
| c3873ddd84 | |||
| d7ceeac1b0 | |||
| cdbd3cd9b4 | |||
| a708c74425 | |||
| 9ad7651c44 | |||
| 389392fd1d | |||
| aaebeaa547 | |||
| 03ee3855f5 | |||
| 94a39cf3b4 | |||
| efe6f6c2b0 | |||
| 73a9d15786 | |||
| dfe05cf2d2 | |||
| fcba907438 | |||
| 5739602702 | |||
| addf78f01d | |||
| 5e04e84ee4 | |||
| 8d5c2e2b7f | |||
| 788d2c12f2 | |||
| b25a9746cf | |||
| 41fda9aeee | |||
| 550078268c | |||
| 0582690dca | |||
| 88278bd1dd | |||
| d24d6459a0 | |||
| 4b866e5212 | |||
| 6c6bd20f0d | |||
| 2764228abf | |||
| f95d69c915 | |||
| 70351e9d9a | |||
| ff4905126d | |||
| 0e5b5a7de4 | |||
| c8c032336b | |||
| dda27e55fc | |||
| 83ac67471e | |||
| e3c8232e38 | |||
| a4df9fc759 | |||
| f1989c1d77 | |||
| 8efdf5d0e0 | |||
| 96bc5de942 | |||
| 84ccdd53ad | |||
| 29978058ee | |||
| e510f64336 | |||
| f189f67aaf | |||
| 8efa148739 | |||
| 8f4dd3e9d6 | |||
| bf38b6695f |
@@ -42,19 +42,24 @@ jobs:
|
||||
username: ${{ env.GHCR_NAMESPACE }}
|
||||
password: ${{ secrets.GHCR_TOKEN }}
|
||||
|
||||
- name: Extract version
|
||||
# Detection du canal :
|
||||
# - tag vX.Y.Z -> stable (push :latest + :version sur les repos publics)
|
||||
# - tag vX.Y.Z-beta* -> beta (push :beta + :version sur les repos GHCR prives
|
||||
# loremind-beta-<component> ; backup Gitea avec :version)
|
||||
- name: Extract version & channel
|
||||
id: meta
|
||||
run: echo "version=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT
|
||||
run: |
|
||||
VERSION="${GITHUB_REF_NAME#v}"
|
||||
echo "version=${VERSION}" >> $GITHUB_OUTPUT
|
||||
if [[ "${VERSION}" == *-beta* ]]; then
|
||||
echo "channel=beta" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "channel=stable" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
# Push vers les deux registries en un seul build (build-push-action
|
||||
# accepte une liste de tags ; aucun build supplementaire necessaire).
|
||||
# Naming :
|
||||
# - Gitea : conserve l'ancien pattern ietm64/<component> pour ne pas
|
||||
# casser les installs existantes qui ont REGISTRY=git.igmlcreation.fr
|
||||
# dans leur .env.
|
||||
# - GHCR : nouveau pattern igmlcreation/loremind-<component> qui evite
|
||||
# la collision avec d'autres projets de l'org.
|
||||
- name: Build & push ${{ matrix.component }}
|
||||
# Build & push canal STABLE
|
||||
- name: Build & push ${{ matrix.component }} (stable)
|
||||
if: steps.meta.outputs.channel == 'stable'
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ./${{ matrix.component }}
|
||||
@@ -64,3 +69,73 @@ jobs:
|
||||
${{ env.GITEA_REGISTRY }}/${{ env.GITEA_REGISTRY_USER }}/${{ matrix.component }}:${{ steps.meta.outputs.version }}
|
||||
${{ env.GHCR_REGISTRY }}/${{ env.GHCR_NAMESPACE }}/loremind-${{ matrix.component }}:latest
|
||||
${{ env.GHCR_REGISTRY }}/${{ env.GHCR_NAMESPACE }}/loremind-${{ matrix.component }}:${{ steps.meta.outputs.version }}
|
||||
|
||||
# Build & push canal BETA
|
||||
# GHCR : repos prives loremind-beta-<component> (gated par PAT distribue
|
||||
# via le relais Patreon aux tiers Compagnon).
|
||||
# Gitea : backup prive avec :version uniquement (pas de :latest pour ne
|
||||
# pas faire upgrader les installs branchees sur Gitea).
|
||||
- name: Build & push ${{ matrix.component }} (beta)
|
||||
if: steps.meta.outputs.channel == 'beta'
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ./${{ matrix.component }}
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.GITEA_REGISTRY }}/${{ env.GITEA_REGISTRY_USER }}/${{ matrix.component }}:${{ steps.meta.outputs.version }}
|
||||
${{ env.GHCR_REGISTRY }}/${{ env.GHCR_NAMESPACE }}/loremind-beta-${{ matrix.component }}:beta
|
||||
${{ env.GHCR_REGISTRY }}/${{ env.GHCR_NAMESPACE }}/loremind-beta-${{ matrix.component }}:${{ steps.meta.outputs.version }}
|
||||
|
||||
# Job separe pour le sidecar `switcher`.
|
||||
# Pourquoi separe : le switcher est volontairement HORS de IMAGE_NAMESPACE
|
||||
# (cf. docker-compose.yml). Il est toujours pulle depuis le repo public
|
||||
# `loremind-switcher`, quel que soit le canal de l'instance. On le build
|
||||
# donc uniquement sur les releases stables — pas la peine de re-publier
|
||||
# une variante beta du switcher, c'est une infrastructure neutre.
|
||||
build-switcher:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Detect channel
|
||||
id: meta
|
||||
run: |
|
||||
VERSION="${GITHUB_REF_NAME#v}"
|
||||
echo "version=${VERSION}" >> $GITHUB_OUTPUT
|
||||
if [[ "${VERSION}" == *-beta* ]]; then
|
||||
echo "channel=beta" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "channel=stable" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Login to Gitea Registry
|
||||
if: steps.meta.outputs.channel == 'stable'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.GITEA_REGISTRY }}
|
||||
username: ${{ env.GITEA_REGISTRY_USER }}
|
||||
password: ${{ secrets.DOCKER_PAT }}
|
||||
|
||||
- name: Login to GHCR
|
||||
if: steps.meta.outputs.channel == 'stable'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.GHCR_REGISTRY }}
|
||||
username: ${{ env.GHCR_NAMESPACE }}
|
||||
password: ${{ secrets.GHCR_TOKEN }}
|
||||
|
||||
- name: Build & push switcher (stable only)
|
||||
if: steps.meta.outputs.channel == 'stable'
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ./switcher
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.GITEA_REGISTRY }}/${{ env.GITEA_REGISTRY_USER }}/switcher:latest
|
||||
${{ env.GITEA_REGISTRY }}/${{ env.GITEA_REGISTRY_USER }}/switcher:${{ steps.meta.outputs.version }}
|
||||
${{ env.GHCR_REGISTRY }}/${{ env.GHCR_NAMESPACE }}/loremind-switcher:latest
|
||||
${{ env.GHCR_REGISTRY }}/${{ env.GHCR_NAMESPACE }}/loremind-switcher:${{ steps.meta.outputs.version }}
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -107,3 +107,4 @@ docker-compose.override.yml
|
||||
# Relais OAuth Patreon (repo Gitea separe, clone localement pour facilite)
|
||||
# ============================================================================
|
||||
relay/
|
||||
scripts/bump-version.mjs
|
||||
|
||||
67
README.md
67
README.md
@@ -1,37 +1,66 @@
|
||||
# LoreMind
|
||||
|
||||
> Application web auto-hébergeable pour MJ qui veulent centraliser leur univers, leurs campagnes et leurs personnages — avec un assistant IA contextuel.
|
||||
|
||||
[](LICENSE)
|
||||
[](https://loremind-docs.igmlcreation.fr/)
|
||||
[](https://loremind-demo.igmlcreation.fr/)
|
||||
[](https://www.patreon.com/c/IGMLCreation)
|
||||
[](https://discord.gg/cPpFzCjEzQ)
|
||||
|
||||
## Découvrir LoreMind en vidéo
|
||||
|
||||
[](https://www.youtube.com/watch?v=llJkmlotbB8)
|
||||
|
||||

|
||||
|
||||
Loremind est une application web angular auto-hébergable afin de venir en aide aux Maîtres de jeu qui souhaitent centraliser leur univers et leurs campagnes.
|
||||
Cette dernière intègre un moteur IA qui va ingérer le contenu du lore et de la campagne afin de pouvoir répondre à des questions précises sur l'univers ou la campagne, mais également proposer des idées de création dans le contexte de la campagne et du lore.
|
||||
Pour le moment seul Ollama est supporté pour la partie locale, il y-a également une intégration pour 1min.ai. Plus tard, d'autres moteurs seront supportés.
|
||||
## Ce que ça fait
|
||||
|
||||
LoreMind regroupe ce qu'un MJ utilise habituellement éparpillé entre plusieurs outils. L'application s'articule autour de trois modules principaux, augmentés par un assistant IA qui exploite tout votre contenu.
|
||||
|
||||
### Lore
|
||||
|
||||
Construire votre univers avec une arborescence de pages templatées : lieux, factions, PNJ, événements, organisations... Chaque type de page suit un template configurable, ce qui garantit la cohérence et facilite la navigation dans des univers riches.
|
||||
|
||||
### Game System
|
||||
|
||||
Stocker les règles de votre système de jeu (D&D, Nimble, créations maison...) et définir les modèles de fiches de personnages associés. Les règles indexées peuvent être injectées dans le contexte de l'IA pour des réponses fidèles à votre système.
|
||||
|
||||
### Campaign
|
||||
|
||||
Structurer vos campagnes en Arcs → Chapitres → Scènes avec séparation claire du contenu MJ et du contenu joueurs. Gérer les PJ et PNJ via des fiches dynamiques basées sur les templates du game system retenu.
|
||||
|
||||
### Assistant IA
|
||||
|
||||
Un assistant contextuel qui pioche dans votre Lore, vos règles et vos campagnes pour répondre à vos questions, suggérer du contenu cohérent, ou rebondir sur une situation improvisée en table.
|
||||
|
||||
L'IA s'exécute **en local via [Ollama](https://ollama.com/)** ou via **[1min.ai](https://1min.ai/)**. D'autres moteurs seront supportés à l'avenir.
|
||||
|
||||
## Documentation
|
||||
|
||||
La documentation complète est accessible sur le site [loremind-docs](https://loremind-docs.igmlcreation.fr/)
|
||||
Toute la documentation (installation, configuration, prise en main) est sur **[loremind-docs.igmlcreation.fr](https://loremind-docs.igmlcreation.fr/)**.
|
||||
|
||||
Pour l'installation, consultez le guide dans cette dernière .
|
||||
## Démo en ligne
|
||||
|
||||
## Fonctionnalités
|
||||
Une instance de démonstration est disponible sur **[loremind-demo.igmlcreation.fr](https://loremind-demo.igmlcreation.fr/)**.
|
||||
|
||||
- Gestion centralisée du Lore : Lieux, Factions, PNJ, et tous les éléments de votre univers
|
||||
- Suivi de campagnes : Sessions, actions des joueurs, chronologie
|
||||
- Moteur IA intégré : Génération automatique de contenu (PNJ, Villes, Quêtes) à partir de templates
|
||||
Quelques limites à connaître :
|
||||
- 10 utilisateurs maximum simultanés (instances isolées)
|
||||
- Session limitée à 20 minutes avant réinitialisation
|
||||
- Partie IA non incluse dans la démo (nécessite Ollama ou 1min.ai côté serveur)
|
||||
|
||||
## Démo
|
||||
## Soutenir le projet
|
||||
|
||||
Une démo est disponible sur le site [loremind-demo](https://loremind-demo.igmlcreation.fr/)
|
||||
LoreMind est **et restera gratuit en auto-hébergement**. Le développement avance plus vite avec votre soutien :
|
||||
|
||||
!! Attention, la démo est uniquement accessible à 10 personnes à la fois (instances personnalisées). Cette limite est mise en place pour éviter l'overhead sur les ressources serveur.
|
||||
- **[Patreon](https://www.patreon.com/c/IGMLCreation)** — accès anticipé aux features, vote sur la roadmap, devlogs exclusifs
|
||||
- **[Discord](https://discord.gg/cPpFzCjEzQ)** — annonces, support, retours utilisateurs
|
||||
|
||||
Cette dernière est utilisable 20 minutes maximum par session avant d'être réinitialiser.
|
||||
Vous comprendrez également qu'elle ne contient pas de démo pour la partie IA, pour laquelle il faut configurer un serveur Ollama (et qui ferait donc exploser le serveur) ou utiliser 1min.ai.
|
||||
|
||||
## License
|
||||
## Licence
|
||||
|
||||
LoreMind est distribué sous licence **[GNU AGPL v3](LICENSE)**.
|
||||
|
||||
En pratique :
|
||||
- Tu peux l'utiliser gratuitement, l'héberger où tu veux, le modifier, le redistribuer.
|
||||
- Si tu modifies le code et que tu exposes l'application modifiée sur un réseau (même en SaaS privé), tu dois rendre tes modifications publiques sous la même licence.
|
||||
- Les univers (Lore) et campagnes que tu crées avec LoreMind **t'appartiennent entièrement** — la licence ne couvre que le code de l'application.
|
||||
- Vous pouvez l'utiliser gratuitement, l'héberger, la modifier, la redistribuer.
|
||||
- Si vous modifiez le code et que vous exposez l'application modifiée sur un réseau (même en SaaS privé), vous devez rendre vos modifications publiques sous la même licence.
|
||||
- Les univers (Lore) et campagnes que vous créez avec LoreMind **vous appartiennent entièrement** — la licence ne couvre que le code de l'application.
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
FROM python:3.12-slim
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends curl \
|
||||
# curl : healthcheck docker.
|
||||
# tesseract-ocr (+ langues fra/eng) : repli OCR de l'import de PDF de regles
|
||||
# pour les pages sans couche texte (scans). Inutile pour les PDF born-digital
|
||||
# mais necessaire pour couvrir tous les cas.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
curl \
|
||||
tesseract-ocr \
|
||||
tesseract-ocr-fra \
|
||||
tesseract-ocr-eng \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt .
|
||||
|
||||
125
brain/app/application/adapt_campaign.py
Normal file
125
brain/app/application/adapt_campaign.py
Normal file
@@ -0,0 +1,125 @@
|
||||
"""Use case : conseils d'adaptation d'un PDF à une campagne EXISTANTE.
|
||||
|
||||
L'IA connaît la campagne de l'utilisateur (un « brief » : structure arcs/chapitres/
|
||||
scènes + PNJ + univers/lore), lit le contenu du PDF, et rédige des recommandations
|
||||
d'INTÉGRATION/ADAPTATION (où insérer, reskins de PNJ, transposition à l'univers,
|
||||
doublons à réconcilier…). Sortie en markdown, streamée token par token.
|
||||
|
||||
Contrairement à l'IMPORT (qui produit une arborescence à créer), ici on produit
|
||||
du CONSEIL libre : rien n'est créé, l'utilisateur applique à la main.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import AsyncIterator
|
||||
|
||||
from app.domain.models import ChatMessage
|
||||
from app.domain.ports import LLMChatProvider, PdfExtractionError, PdfTextExtractor
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Plus créatif que l'import (tâche de structuration) : ici on conseille/adapte.
|
||||
_TEMPERATURE = 0.7
|
||||
|
||||
_SYSTEM_PREFIX = (
|
||||
"Tu es un assistant pour Maître de Jeu de jeu de rôle. L'utilisateur a une "
|
||||
"campagne EXISTANTE (décrite plus bas) et souhaite ADAPTER et INTÉGRER le "
|
||||
"contenu d'un PDF (aventure, donjon, supplément) à CETTE campagne précise."
|
||||
)
|
||||
|
||||
_SYSTEM_SUFFIX = (
|
||||
"Produis des CONSEILS D'ADAPTATION concrets, actionnables et en FRANÇAIS, "
|
||||
"en markdown structuré (titres ##, listes). Couvre notamment :\n"
|
||||
"- **Où l'insérer** : à quel(s) arc(s)/chapitre(s) EXISTANT(s) rattacher ce "
|
||||
"contenu, dans quel ordre, et — si l'arc est un hub — sous quelles conditions de déblocage.\n"
|
||||
"- **Reskins / liens PNJ** : quels PNJ EXISTANTS de la campagne peuvent incarner "
|
||||
"ou remplacer les personnages clés du PDF.\n"
|
||||
"- **Adaptation à l'univers** : comment transposer lieux, factions, noms propres et "
|
||||
"ton vers l'univers de l'utilisateur plutôt que le cadre d'origine du PDF.\n"
|
||||
"- **Doublons / conflits** : ce qui recoupe l'existant et comment le réconcilier.\n"
|
||||
"- **Ajustements de ton et de difficulté**.\n\n"
|
||||
"Réfère-toi TOUJOURS aux éléments existants par leur NOM. Ne réécris PAS le PDF en "
|
||||
"entier : donne des recommandations. Si une information manque, propose des options."
|
||||
)
|
||||
|
||||
|
||||
class AdaptCampaignUseCase:
|
||||
"""Génère (en streaming) des conseils d'adaptation d'un PDF à une campagne."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
llm: LLMChatProvider,
|
||||
extractor: PdfTextExtractor,
|
||||
max_input_tokens: int = 10000,
|
||||
) -> None:
|
||||
self._llm = llm
|
||||
self._extractor = extractor
|
||||
# L'adaptation envoie le PDF en UNE requête (pas de découpage). On plafonne
|
||||
# donc l'entrée pour ne pas dépasser la taille de requête acceptée par le
|
||||
# provider (sinon HTTP 400). Calé sur la taille des morceaux d'import.
|
||||
self._max_input_tokens = max_input_tokens
|
||||
|
||||
async def stream(
|
||||
self,
|
||||
pdf_bytes: bytes,
|
||||
brief: str,
|
||||
messages: list[ChatMessage],
|
||||
) -> AsyncIterator[str]:
|
||||
"""Conversationnel : le PDF + la campagne sont le CONTEXTE (system prompt),
|
||||
`messages` est l'échange (demande initiale, puis feedbacks de l'utilisateur)."""
|
||||
doc = self._extractor.extract(pdf_bytes)
|
||||
pdf_text = doc.full_text
|
||||
if not pdf_text.strip():
|
||||
raise PdfExtractionError("Aucun texte exploitable n'a été extrait du PDF.")
|
||||
|
||||
brief = brief or ""
|
||||
pdf_text, truncated = self._fit_pdf_to_budget(pdf_text, brief)
|
||||
|
||||
logger.info(
|
||||
"Adaptation campagne : %s page(s) (%s via OCR), brief %s car., PDF %s car.%s, %s message(s).",
|
||||
doc.page_count, doc.ocr_page_count, len(brief), len(pdf_text),
|
||||
" (tronqué)" if truncated else "", len(messages),
|
||||
)
|
||||
|
||||
trunc_note = (
|
||||
"\n[Note : PDF tronqué pour tenir dans une requête — base-toi sur ce début.]"
|
||||
if truncated else ""
|
||||
)
|
||||
# Concaténation (pas .format) : brief/PDF peuvent contenir des { } littéraux.
|
||||
system_prompt = (
|
||||
f"{_SYSTEM_PREFIX}\n\n"
|
||||
"--- CAMPAGNE EXISTANTE DE L'UTILISATEUR ---\n"
|
||||
f"{brief.strip() or '(campagne encore vide)'}\n\n"
|
||||
"--- CONTENU DU PDF À ADAPTER ---\n"
|
||||
f"{pdf_text}{trunc_note}\n\n"
|
||||
f"{_SYSTEM_SUFFIX}\n\n"
|
||||
"Tu es en CONVERSATION : à chaque message de l'utilisateur, ajuste, corrige "
|
||||
"ou propose des alternatives en gardant tout ce contexte à l'esprit."
|
||||
)
|
||||
|
||||
# 1er tour : si aucun message, on lance la demande initiale par défaut.
|
||||
convo = messages or [ChatMessage(
|
||||
role="user",
|
||||
content="Propose-moi comment intégrer et adapter ce PDF à ma campagne.",
|
||||
)]
|
||||
|
||||
async for token in self._llm.stream_chat(
|
||||
convo, system_prompt=system_prompt, temperature=_TEMPERATURE
|
||||
):
|
||||
yield token
|
||||
|
||||
def _fit_pdf_to_budget(self, pdf_text: str, brief: str) -> tuple[str, bool]:
|
||||
"""Tronque le texte du PDF pour que (brief + PDF) tienne dans le budget tokens.
|
||||
|
||||
Évite un HTTP 400 « requête trop grosse » côté provider. Réserve une marge
|
||||
pour le prompt système et le brief.
|
||||
"""
|
||||
import tiktoken
|
||||
|
||||
enc = tiktoken.get_encoding("cl100k_base")
|
||||
brief_tokens = len(enc.encode(brief))
|
||||
budget = max(2000, self._max_input_tokens - brief_tokens - 1000) # 1000 = marge système
|
||||
pdf_tokens = enc.encode(pdf_text)
|
||||
if len(pdf_tokens) <= budget:
|
||||
return pdf_text, False
|
||||
return enc.decode(pdf_tokens[:budget]), True
|
||||
@@ -23,10 +23,13 @@ from app.domain.models import (
|
||||
CharacterSummary,
|
||||
NpcSummary,
|
||||
GameSystemContext,
|
||||
JournalEntrySummary,
|
||||
LoreStructuralContext,
|
||||
NarrativeEntityContext,
|
||||
PageContext,
|
||||
PageSummary,
|
||||
QuestSummary,
|
||||
SessionContext,
|
||||
)
|
||||
from app.domain.ports import LLMChatProvider
|
||||
|
||||
@@ -67,6 +70,7 @@ class ChatUseCase:
|
||||
campaign_context: CampaignStructuralContext | None = None,
|
||||
narrative_entity: NarrativeEntityContext | None = None,
|
||||
game_system_context: GameSystemContext | None = None,
|
||||
session_context: SessionContext | None = None,
|
||||
) -> AsyncIterator[str]:
|
||||
"""Streame les tokens de la réponse assistant pour le dernier message user.
|
||||
|
||||
@@ -76,7 +80,7 @@ class ChatUseCase:
|
||||
cette règle à la frontière HTTP.
|
||||
"""
|
||||
system_prompt = self._build_system_prompt(
|
||||
lore_context, page_context, campaign_context, narrative_entity, game_system_context
|
||||
lore_context, page_context, campaign_context, narrative_entity, game_system_context, session_context
|
||||
)
|
||||
async for token in self._llm.stream_chat(
|
||||
messages,
|
||||
@@ -92,12 +96,13 @@ class ChatUseCase:
|
||||
campaign_context: CampaignStructuralContext | None = None,
|
||||
narrative_entity: NarrativeEntityContext | None = None,
|
||||
game_system_context: GameSystemContext | None = None,
|
||||
session_context: SessionContext | None = None,
|
||||
) -> str:
|
||||
"""Version publique — utilisée par le controller HTTP pour compter
|
||||
les tokens du system prompt avant de streamer (jauge de contexte).
|
||||
"""
|
||||
return self._build_system_prompt(
|
||||
lore_context, page_context, campaign_context, narrative_entity, game_system_context
|
||||
lore_context, page_context, campaign_context, narrative_entity, game_system_context, session_context
|
||||
)
|
||||
|
||||
# --- Construction du system prompt --------------------------------------
|
||||
@@ -109,6 +114,7 @@ class ChatUseCase:
|
||||
campaign: CampaignStructuralContext | None,
|
||||
narrative: NarrativeEntityContext | None,
|
||||
game_system: GameSystemContext | None = None,
|
||||
session: SessionContext | None = None,
|
||||
) -> str:
|
||||
sections = [_BASE_SYSTEM]
|
||||
if lore is not None:
|
||||
@@ -121,6 +127,8 @@ class ChatUseCase:
|
||||
sections.append(self._format_page(page))
|
||||
if narrative is not None:
|
||||
sections.append(self._format_narrative_entity(narrative))
|
||||
if session is not None:
|
||||
sections.append(self._format_session(session))
|
||||
return "\n\n".join(sections)
|
||||
|
||||
# --- Blocs Lore ---------------------------------------------------------
|
||||
@@ -288,7 +296,8 @@ class ChatUseCase:
|
||||
else:
|
||||
for scene in chapter.scenes:
|
||||
sc_hint = ChatUseCase._illustration_hint(scene.illustration_count)
|
||||
block.append(f" - {scene.name} (scène){sc_hint}")
|
||||
scene_kind = " (lieu explorable)" if scene.rooms else " (scène)"
|
||||
block.append(f" - {scene.name}{scene_kind}{sc_hint}")
|
||||
if scene.description:
|
||||
block.append(f" Description : {scene.description}")
|
||||
for br in scene.branches:
|
||||
@@ -296,6 +305,19 @@ class ChatUseCase:
|
||||
block.append(
|
||||
f' → "{br.label}" vers {br.target_scene_name}{cond}'
|
||||
)
|
||||
# Pièces du lieu explorable (mode donjon)
|
||||
for room in scene.rooms:
|
||||
floor = f" [étage {room.floor}]" if room.floor is not None else ""
|
||||
block.append(f" ◆ {room.name}{floor}")
|
||||
if room.description:
|
||||
block.append(f" {room.description}")
|
||||
if room.enemies:
|
||||
block.append(f" Ennemis : {room.enemies}")
|
||||
for rb in room.branches:
|
||||
cond = f" (si : {rb.condition})" if rb.condition else ""
|
||||
block.append(
|
||||
f' ↳ "{rb.label}" vers {rb.target_room_name}{cond}'
|
||||
)
|
||||
return block
|
||||
|
||||
@staticmethod
|
||||
@@ -342,6 +364,141 @@ class ChatUseCase:
|
||||
f"{sections_block}"
|
||||
)
|
||||
|
||||
# --- Bloc Session de jeu (Play Context) ---------------------------------
|
||||
|
||||
_ENTRY_TYPE_LABELS = {
|
||||
"NOTE": "Note du MJ",
|
||||
"EVENT": "Évènement",
|
||||
"DICE_ROLL": "Jet de dés",
|
||||
"PLAYER_ACTION": "Action joueur",
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _format_session(sc: SessionContext) -> str:
|
||||
"""Bloc journal de la session en cours + résumé des sessions précédentes.
|
||||
|
||||
Fournit à l'IA le contexte temporel : ce qui s'est passé jusqu'ici,
|
||||
dans l'ordre chronologique. Permet de référencer un PNJ rencontré,
|
||||
rappeler un évènement antérieur, ou rebondir sur une action joueur.
|
||||
|
||||
Pour les sessions PRÉCÉDENTES de la même campagne, on ne remonte que
|
||||
les EVENTs (les moments marquants) pour préserver le contexte LLM.
|
||||
"""
|
||||
status = "EN COURS" if sc.active else "TERMINÉE"
|
||||
started = f" — démarrée {sc.started_at}" if sc.started_at else ""
|
||||
|
||||
previous_block = ChatUseCase._format_previous_events(sc.previous_events)
|
||||
hub_block = ChatUseCase._format_hub_status(sc)
|
||||
|
||||
if not sc.entries:
|
||||
current_block = "(Aucune entrée dans le journal pour l'instant — la session vient de commencer.)"
|
||||
else:
|
||||
lines: list[str] = []
|
||||
for e in sc.entries:
|
||||
label = ChatUseCase._ENTRY_TYPE_LABELS.get(e.type, e.type)
|
||||
ts = f" [{e.occurred_at}]" if e.occurred_at else ""
|
||||
content = e.content.replace("\n", "\n ")
|
||||
lines.append(f"- {label}{ts} : {content}")
|
||||
current_block = "\n".join(lines)
|
||||
|
||||
return (
|
||||
"--- SESSION DE JEU EN COURS ---\n"
|
||||
f"Nom : {sc.session_name}\n"
|
||||
f"Statut : {status}{started}\n"
|
||||
f"{hub_block}"
|
||||
f"{previous_block}"
|
||||
"\nJournal chronologique de la session courante (du plus ancien au plus récent) :\n"
|
||||
f"{current_block}\n\n"
|
||||
"IMPORTANT : tu es l'assistant du MJ PENDANT la partie. Tes réponses doivent :\n"
|
||||
"- Tenir compte des évènements déjà capturés (sessions précédentes + journal courant).\n"
|
||||
"- Être concrètes et utiles en temps réel : descriptions sensorielles, "
|
||||
"réactions de PNJ cohérentes avec leur fiche, suggestions de complications "
|
||||
"qui s'enchaînent à ce qui vient de se passer.\n"
|
||||
"- Éviter les longs développements : le MJ est en train d'animer une partie, "
|
||||
"il a besoin d'idées immédiatement actionnables."
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _format_hub_status(sc: SessionContext) -> str:
|
||||
"""Bloc Hub : quêtes ouvertes + flags actifs.
|
||||
|
||||
Vide si la campagne n'a aucun Arc HUB (toutes les listes vides côté Core).
|
||||
Les quêtes LOCKED apparaissent par leur TITRE uniquement : l'IA sait
|
||||
qu'elles existent (utile pour les teasers, les rumeurs en jeu) mais ne
|
||||
peut pas spoiler leurs détails.
|
||||
"""
|
||||
if (not sc.available_quests
|
||||
and not sc.in_progress_quests
|
||||
and not sc.locked_quest_titles
|
||||
and not sc.active_flags):
|
||||
return ""
|
||||
|
||||
lines = ["", "État du Hub (quêtes parallèles et faits narratifs) :"]
|
||||
|
||||
if sc.in_progress_quests:
|
||||
lines.append(" Quêtes en cours :")
|
||||
lines.extend(ChatUseCase._format_quest_lines(sc.in_progress_quests))
|
||||
|
||||
if sc.available_quests:
|
||||
lines.append(" Quêtes disponibles (non démarrées, prêtes à être lancées) :")
|
||||
lines.extend(ChatUseCase._format_quest_lines(sc.available_quests))
|
||||
|
||||
if sc.locked_quest_titles:
|
||||
titles = ", ".join(f'"{t}"' for t in sc.locked_quest_titles)
|
||||
lines.append(
|
||||
" Quêtes encore verrouillées (existent mais non accessibles — "
|
||||
f"tu peux y faire allusion sous forme de rumeurs sans spoiler leur contenu) : {titles}"
|
||||
)
|
||||
|
||||
if sc.active_flags:
|
||||
lines.append(
|
||||
" Faits actifs : " + ", ".join(f"`{f}`" for f in sc.active_flags)
|
||||
)
|
||||
|
||||
lines.append(
|
||||
" Conseille des actions cohérentes avec ces quêtes ouvertes. Ne fais "
|
||||
"PAS comme si une quête verrouillée était déjà accessible aux PJ."
|
||||
)
|
||||
lines.append("") # séparateur visuel avant le récap des sessions précédentes
|
||||
return "\n".join(lines)
|
||||
|
||||
@staticmethod
|
||||
def _format_quest_lines(quests: list[QuestSummary]) -> list[str]:
|
||||
"""Sérialise une liste de QuestSummary en lignes indentées."""
|
||||
out: list[str] = []
|
||||
for q in quests:
|
||||
arc = f" [arc : {q.arc_name}]" if q.arc_name else ""
|
||||
out.append(f" - {q.name}{arc}")
|
||||
if q.description:
|
||||
out.append(f" Synopsis : {q.description}")
|
||||
return out
|
||||
|
||||
@staticmethod
|
||||
def _format_previous_events(events: list[JournalEntrySummary]) -> str:
|
||||
"""Bloc "Story so far" : EVENTs marquants des sessions antérieures.
|
||||
|
||||
Vide si la campagne en est à sa première session. On groupe par
|
||||
session source pour aider l'IA à situer chaque évènement temporellement.
|
||||
"""
|
||||
if not events:
|
||||
return ""
|
||||
|
||||
# Groupement par session source en préservant l'ordre d'apparition.
|
||||
grouped: dict[str, list[JournalEntrySummary]] = {}
|
||||
for e in events:
|
||||
key = e.source_session_name or "(session inconnue)"
|
||||
grouped.setdefault(key, []).append(e)
|
||||
|
||||
lines = ["\nRécapitulatif des sessions précédentes (évènements marquants uniquement) :"]
|
||||
for session_name, items in grouped.items():
|
||||
lines.append(f" • {session_name} :")
|
||||
for e in items:
|
||||
ts = f" [{e.occurred_at}]" if e.occurred_at else ""
|
||||
content = e.content.replace("\n", "\n ")
|
||||
lines.append(f" - {content}{ts}")
|
||||
lines.append("") # ligne vide avant le bloc journal courant
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
@staticmethod
|
||||
def _format_narrative_entity(ne: NarrativeEntityContext) -> str:
|
||||
"""Bloc équivalent à _format_page mais pour Arc/Chapter/Scene."""
|
||||
|
||||
55
brain/app/application/chunking.py
Normal file
55
brain/app/application/chunking.py
Normal file
@@ -0,0 +1,55 @@
|
||||
"""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
|
||||
312
brain/app/application/import_campaign.py
Normal file
312
brain/app/application/import_campaign.py
Normal file
@@ -0,0 +1,312 @@
|
||||
"""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
|
||||
from app.application.llm_json import load_json_object
|
||||
from app.application.llm_retry import generate_with_retry
|
||||
from app.domain.models import (
|
||||
ArcProposal,
|
||||
CampaignImportResult,
|
||||
ChapterProposal,
|
||||
RoomProposal,
|
||||
SceneProposal,
|
||||
)
|
||||
from app.domain.ports import LLMProvider, PdfTextExtractor
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_TEMPERATURE = 0.2
|
||||
|
||||
# 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()
|
||||
for i, chunk in enumerate(chunks):
|
||||
merger.add(await self._map_chunk(chunk, index=i, total=total))
|
||||
arcs, chapters, scenes = merger.counts()
|
||||
yield {
|
||||
"type": "progress",
|
||||
"current": i + 1,
|
||||
"total": total,
|
||||
"arc_count": arcs,
|
||||
"chapter_count": chapters,
|
||||
"scene_count": scenes,
|
||||
}
|
||||
|
||||
yield {
|
||||
"type": "done",
|
||||
"arcs": _serialize_arcs(merger.result()),
|
||||
"page_count": doc.page_count,
|
||||
"ocr_page_count": doc.ocr_page_count,
|
||||
}
|
||||
|
||||
# --- MAP : un morceau → sous-arbre ---------------------------------------
|
||||
|
||||
async def _map_chunk(self, chunk: str, *, index: int, total: int) -> list[dict]:
|
||||
prompt = (
|
||||
_MAP_SYSTEM.format(default_arc=_DEFAULT_ARC_NAME)
|
||||
+ f"\n\n--- EXTRAIT {index + 1}/{total} ---\n{chunk}\n\n"
|
||||
"Renvoie maintenant le JSON de l'arborescence."
|
||||
)
|
||||
raw = await generate_with_retry(
|
||||
self._llm, prompt, output_format="json", temperature=_TEMPERATURE)
|
||||
return self._parse_arcs(raw, index=index)
|
||||
|
||||
@staticmethod
|
||||
def _parse_arcs(raw: str, *, index: int) -> list[dict]:
|
||||
"""Parse robuste : objet JSON équilibré, ou récupération partielle si tronqué."""
|
||||
parsed, recovered = load_json_object(raw)
|
||||
if parsed is None:
|
||||
logger.warning("Morceau %s : aucun objet JSON exploitable, ignoré.", index)
|
||||
return []
|
||||
if recovered:
|
||||
logger.warning(
|
||||
"Morceau %s : sortie tronquée — récupération des éléments complets "
|
||||
"(envisagez des morceaux plus petits).", index)
|
||||
if isinstance(parsed, dict):
|
||||
arcs = parsed.get("arcs", [])
|
||||
return arcs if isinstance(arcs, list) else []
|
||||
return []
|
||||
|
||||
|
||||
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
|
||||
]
|
||||
197
brain/app/application/import_rules.py
Normal file
197
brain/app/application/import_rules.py
Normal file
@@ -0,0 +1,197 @@
|
||||
"""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
|
||||
from app.application.llm_json import load_json_object
|
||||
from app.application.llm_retry import generate_with_retry
|
||||
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é.
|
||||
_TEMPERATURE = 0.2
|
||||
|
||||
# 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()}
|
||||
|
||||
|
||||
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()
|
||||
for i, chunk in enumerate(chunks):
|
||||
new_titles = merger.add(await self._map_chunk(chunk, index=i, total=total))
|
||||
yield {
|
||||
"type": "progress",
|
||||
"current": i + 1,
|
||||
"total": total,
|
||||
"new_sections": new_titles,
|
||||
}
|
||||
|
||||
yield {
|
||||
"type": "done",
|
||||
"sections": merger.result(),
|
||||
"page_count": doc.page_count,
|
||||
"ocr_page_count": doc.ocr_page_count,
|
||||
}
|
||||
|
||||
# --- MAP : un morceau → sections -----------------------------------------
|
||||
|
||||
async def _map_chunk(self, chunk: str, *, index: int, total: int) -> dict[str, str]:
|
||||
prompt = (
|
||||
_MAP_SYSTEM.format(
|
||||
canonical="\n".join(f" - {s}" for s in _CANONICAL_SECTIONS)
|
||||
)
|
||||
+ f"\n\n--- EXTRAIT {index + 1}/{total} ---\n{chunk}\n\n"
|
||||
"Renvoie maintenant le JSON des sections."
|
||||
)
|
||||
raw = await generate_with_retry(
|
||||
self._llm, prompt, output_format="json", temperature=_TEMPERATURE)
|
||||
return self._parse_sections(raw, index=index)
|
||||
|
||||
@staticmethod
|
||||
def _parse_sections(raw: str, *, index: int) -> dict[str, str]:
|
||||
"""Parse robuste : objet JSON équilibré, ou récupération partielle si tronqué."""
|
||||
parsed, recovered = load_json_object(raw)
|
||||
if parsed is None:
|
||||
logger.warning("Morceau %s : aucun objet JSON exploitable, ignoré.", index)
|
||||
return {}
|
||||
if recovered:
|
||||
logger.warning(
|
||||
"Morceau %s : sortie tronquée — récupération des sections complètes "
|
||||
"(envisagez des morceaux plus petits).", index)
|
||||
if not isinstance(parsed, dict):
|
||||
logger.warning("Morceau %s : le LLM n'a pas renvoyé un objet, ignoré.", index)
|
||||
return {}
|
||||
return {str(k): str(v) for k, v in parsed.items()}
|
||||
126
brain/app/application/llm_json.py
Normal file
126
brain/app/application/llm_json.py
Normal file
@@ -0,0 +1,126 @@
|
||||
"""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
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
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 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
|
||||
47
brain/app/application/llm_retry.py
Normal file
47
brain/app/application/llm_retry.py
Normal file
@@ -0,0 +1,47 @@
|
||||
"""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
|
||||
|
||||
from app.domain.ports import LLMProvider, LLMProviderError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_ATTEMPTS = 3
|
||||
_BASE_DELAY_SECONDS = 2.0
|
||||
|
||||
|
||||
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 x2)."""
|
||||
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
|
||||
if attempt < _ATTEMPTS - 1:
|
||||
logger.warning(
|
||||
"Appel LLM échoué (tentative %s/%s) : %s — nouvelle tentative dans %ss.",
|
||||
attempt + 1, _ATTEMPTS, exc, delay,
|
||||
)
|
||||
await asyncio.sleep(delay)
|
||||
delay *= 2
|
||||
assert last_error is not None
|
||||
raise last_error
|
||||
@@ -30,7 +30,10 @@ class Settings(BaseSettings):
|
||||
|
||||
ollama_base_url: str = "http://localhost:11434"
|
||||
llm_model: str = "gemma4:26b"
|
||||
llm_timeout_seconds: int = 120
|
||||
# Timeout HTTP des appels au LLM. Les imports/adaptations PDF génèrent de gros
|
||||
# blocs (surtout avec l'extraction riche) → 120s était trop court. Surchargeable
|
||||
# depuis l'UI (Paramètres) si un import lourd dépasse encore.
|
||||
llm_timeout_seconds: int = 300
|
||||
|
||||
# Fenêtre de contexte (num_ctx Ollama). Défaut Ollama = 2048, trop étroit
|
||||
# dès que le Structural Context du Lore dépasse ~10 pages (b9). On monte
|
||||
@@ -44,6 +47,13 @@ class Settings(BaseSettings):
|
||||
onemin_api_key: str = ""
|
||||
onemin_model: str = "gpt-4o-mini"
|
||||
|
||||
# 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).
|
||||
|
||||
@@ -29,6 +29,7 @@ _ALLOWED_KEYS = frozenset({
|
||||
"llm_num_ctx",
|
||||
"onemin_api_key",
|
||||
"onemin_model",
|
||||
"import_chunk_tokens",
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -122,9 +122,29 @@ class SceneBranchHint:
|
||||
condition: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RoomBranchHint:
|
||||
"""Indice d'une sortie entre pièces (donjon). target_room_name déjà résolu côté Core."""
|
||||
|
||||
label: str
|
||||
target_room_name: str
|
||||
condition: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RoomSummary:
|
||||
"""Pièce d'un lieu explorable. Projection plate pour le prompt IA (pas de notes MJ)."""
|
||||
|
||||
name: str
|
||||
floor: int | None = None
|
||||
description: str | None = None
|
||||
enemies: str | None = None
|
||||
branches: list[RoomBranchHint] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SceneSummary:
|
||||
"""Résumé d'une scène : nom + description courte + illustrations + branches."""
|
||||
"""Résumé d'une scène : nom + description courte + illustrations + branches + pièces."""
|
||||
|
||||
name: str
|
||||
description: str | None
|
||||
@@ -133,6 +153,8 @@ class SceneSummary:
|
||||
illustration_count: int = 0
|
||||
# Connexions narratives sortantes (livre dont vous etes le heros).
|
||||
branches: list[SceneBranchHint] = field(default_factory=list)
|
||||
# Pièces du lieu explorable (vide = scène classique).
|
||||
rooms: list[RoomSummary] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -229,3 +251,178 @@ class GameSystemContext:
|
||||
system_name: str
|
||||
system_description: str | None
|
||||
sections: dict[str, str]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class JournalEntrySummary:
|
||||
"""Une entrée du journal d'une Session.
|
||||
|
||||
`source_session_name` n'est renseigné que pour les entrées issues de
|
||||
sessions précédentes (option 3 : continuité narrative entre séances).
|
||||
"""
|
||||
|
||||
type: str
|
||||
content: str
|
||||
occurred_at: str | None
|
||||
source_session_name: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class QuestSummary:
|
||||
"""Résumé d'une quête (Chapter dans un Arc HUB) pour le system prompt.
|
||||
|
||||
Volontairement sans notes MJ ni statut texte : c'est déjà classé côté Core
|
||||
dans available_quests / in_progress_quests / locked_quest_titles.
|
||||
"""
|
||||
|
||||
name: str
|
||||
arc_name: str
|
||||
description: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SessionContext:
|
||||
"""Contexte d'une Session de jeu en cours (Play Context).
|
||||
|
||||
Combine plusieurs niveaux :
|
||||
- `entries` : journal COMPLET de la session courante (cappé ~80 entrées)
|
||||
- `previous_events` : EVENTs marquants des sessions précédentes (continuité)
|
||||
- `available_quests` / `in_progress_quests` : quêtes du Hub ouvertes
|
||||
- `locked_quest_titles` : titres seuls des quêtes verrouillées (anti-spoiler)
|
||||
- `active_flags` : noms des flags de campagne actuellement à true
|
||||
"""
|
||||
|
||||
session_name: str
|
||||
active: bool
|
||||
started_at: str | None
|
||||
entries: list[JournalEntrySummary]
|
||||
previous_events: list[JournalEntrySummary]
|
||||
available_quests: list[QuestSummary] = field(default_factory=list)
|
||||
in_progress_quests: list[QuestSummary] = field(default_factory=list)
|
||||
locked_quest_titles: list[str] = field(default_factory=list)
|
||||
active_flags: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
# ─────────────────────── Import de PDF (règles → GameSystem) ───────────────────────
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExtractedPage:
|
||||
"""Texte extrait d'UNE page de PDF, avec la trace de la méthode utilisée.
|
||||
|
||||
`used_ocr=True` signale que la page n'avait pas de couche texte exploitable
|
||||
(born-digital absent) et a donc été rasterisée puis passée à l'OCR. Permet
|
||||
au CLI/diagnostic de dire à l'utilisateur si son PDF est "texte" ou "scan".
|
||||
"""
|
||||
|
||||
index: int # 0-based
|
||||
text: str
|
||||
used_ocr: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExtractedDocument:
|
||||
"""Résultat brut de l'extraction d'un PDF : une entrée par page."""
|
||||
|
||||
pages: list[ExtractedPage]
|
||||
|
||||
@property
|
||||
def page_count(self) -> int:
|
||||
return len(self.pages)
|
||||
|
||||
@property
|
||||
def ocr_page_count(self) -> int:
|
||||
return sum(1 for p in self.pages if p.used_ocr)
|
||||
|
||||
@property
|
||||
def full_text(self) -> str:
|
||||
"""Concatène le texte de toutes les pages, séparées par un saut double."""
|
||||
return "\n\n".join(p.text for p in self.pages if p.text.strip())
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RulesImportResult:
|
||||
"""Proposition structurée de règles : sections markdown indexées par titre.
|
||||
|
||||
`sections` = {titre H2 → contenu markdown}. C'est une PROPOSITION : rien
|
||||
n'est persisté côté Core tant que l'utilisateur n'a pas validé/édité.
|
||||
`page_count` / `ocr_page_count` remontent au diagnostic d'extraction.
|
||||
"""
|
||||
|
||||
sections: dict[str, str]
|
||||
page_count: int
|
||||
ocr_page_count: int
|
||||
|
||||
def to_markdown(self) -> str:
|
||||
"""Assemble les sections en un markdown monolithique (## titre + contenu).
|
||||
|
||||
Format aligné sur `GameSystem.rulesMarkdown` côté Core (découpé par H2).
|
||||
"""
|
||||
blocks = [f"## {title}\n\n{content.strip()}" for title, content in self.sections.items()]
|
||||
return "\n\n".join(blocks).strip() + "\n"
|
||||
|
||||
|
||||
# ─────────────────────── Import de PDF de campagne (arbre arc→chapitre→scène) ──────────────
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RoomProposal:
|
||||
"""Pièce d'un lieu explorable (donjon) proposée pour une scène."""
|
||||
|
||||
name: str
|
||||
description: str
|
||||
enemies: str = ""
|
||||
loot: str = ""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SceneProposal:
|
||||
"""Scène proposée. `rooms` non vide => donjon/lieu explorable.
|
||||
|
||||
On capture aussi, quand le livre les fournit, le texte d'encadré « à lire aux
|
||||
joueurs » (`player_narration`) et les secrets/développement MJ (`gm_notes`).
|
||||
"""
|
||||
|
||||
name: str
|
||||
description: str
|
||||
player_narration: str = ""
|
||||
gm_notes: str = ""
|
||||
rooms: list[RoomProposal] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ChapterProposal:
|
||||
"""Chapitre proposé : nom + synopsis + ses scènes."""
|
||||
|
||||
name: str
|
||||
description: str
|
||||
scenes: list[SceneProposal] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ArcProposal:
|
||||
"""Arc proposé : nom + synopsis + type (LINEAR/HUB) + ses chapitres."""
|
||||
|
||||
name: str
|
||||
description: str
|
||||
arc_type: str = "LINEAR"
|
||||
chapters: list[ChapterProposal] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CampaignImportResult:
|
||||
"""Proposition d'arborescence narrative extraite d'un PDF de campagne.
|
||||
|
||||
PROPOSITION non persistée : l'UI laisse l'utilisateur réviser/éditer l'arbre
|
||||
avant la création effective des arcs/chapitres/scènes côté Core.
|
||||
"""
|
||||
|
||||
arcs: list[ArcProposal]
|
||||
page_count: int
|
||||
ocr_page_count: int
|
||||
|
||||
def counts(self) -> tuple[int, int, int]:
|
||||
"""(nb arcs, nb chapitres, nb scènes) — pour le diagnostic / la progression."""
|
||||
chapters = sum(len(a.chapters) for a in self.arcs)
|
||||
scenes = sum(len(c.scenes) for a in self.arcs for c in a.chapters)
|
||||
return len(self.arcs), chapters, scenes
|
||||
|
||||
@@ -7,7 +7,10 @@ En Python moderne on privilégie Protocol (PEP 544) sur ABC pour bénéficier
|
||||
du duck typing structurel : toute classe qui possède les bonnes méthodes
|
||||
satisfait le contrat, sans héritage explicite.
|
||||
"""
|
||||
from typing import AsyncIterator, Protocol
|
||||
from typing import TYPE_CHECKING, AsyncIterator, Protocol
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.domain.models import ExtractedDocument
|
||||
|
||||
|
||||
class LLMProvider(Protocol):
|
||||
@@ -78,6 +81,32 @@ class LLMChatProvider(Protocol):
|
||||
...
|
||||
|
||||
|
||||
class PdfTextExtractor(Protocol):
|
||||
"""Port sortant — extrait le texte d'un PDF (born-digital ou scan).
|
||||
|
||||
L'implémentation décide de sa stratégie (couche texte directe, repli OCR
|
||||
page par page…). Le domaine ne connaît ni PyMuPDF ni Tesseract.
|
||||
"""
|
||||
|
||||
def extract(self, pdf_bytes: bytes) -> "ExtractedDocument":
|
||||
"""Extrait le texte du PDF fourni sous forme d'octets.
|
||||
|
||||
Args:
|
||||
pdf_bytes: contenu binaire du fichier PDF.
|
||||
|
||||
Returns:
|
||||
ExtractedDocument : une entrée par page (texte + flag OCR).
|
||||
|
||||
Raises:
|
||||
PdfExtractionError: si le PDF est illisible/corrompu.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
class PdfExtractionError(Exception):
|
||||
"""Erreur du domaine : un PDF n'a pas pu être lu/extrait."""
|
||||
|
||||
|
||||
class LLMProviderError(Exception):
|
||||
"""Erreur du domaine signalant qu'un LLMProvider n'a pas pu générer.
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ avec des marqueurs de role lisibles pour le modele.
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import AsyncIterator
|
||||
|
||||
import httpx
|
||||
@@ -22,6 +23,8 @@ 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"
|
||||
|
||||
@@ -48,6 +51,18 @@ 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,
|
||||
@@ -55,18 +70,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:
|
||||
"""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
|
||||
"""One-shot, mais via l'endpoint STREAMING (puis recollage).
|
||||
|
||||
return self._extract_result(data)
|
||||
On NE passe PAS par l'endpoint non-streame `chat-with-ai` : sur les longues
|
||||
generations (gros imports), la passerelle Cloudflare de 1min.ai coupe la
|
||||
connexion au bout de ~100s et renvoie un HTTP 524. En streaming, des octets
|
||||
circulent en continu => pas de 524, quelle que soit la duree. On accumule
|
||||
tous les fragments pour reconstituer la reponse complete.
|
||||
"""
|
||||
chunks: list[str] = []
|
||||
async for token in self._stream_prompt(prompt):
|
||||
chunks.append(token)
|
||||
return "".join(chunks)
|
||||
|
||||
async def stream_chat(
|
||||
self,
|
||||
@@ -75,17 +90,18 @@ class OneMinAiLLMProvider:
|
||||
system_prompt: str | None = None,
|
||||
temperature: float | None = None,
|
||||
) -> AsyncIterator[str]:
|
||||
"""Streame via SSE.
|
||||
|
||||
1min.ai expose deux evenements utiles :
|
||||
- `event: content` → `data: {"content": "..."}`
|
||||
- `event: done` → fin du stream
|
||||
- `event: error` → erreur serveur
|
||||
On yield le champ `content` au fil de l'arrivee.
|
||||
"""
|
||||
"""Streame une conversation : aplatit les messages puis delegue au coeur SSE."""
|
||||
prompt = self._flatten_messages(messages, system_prompt)
|
||||
url = f"{_API_BASE}?isStreaming=true"
|
||||
async for token in self._stream_prompt(prompt):
|
||||
yield token
|
||||
|
||||
async def _stream_prompt(self, prompt: str) -> AsyncIterator[str]:
|
||||
"""Coeur du streaming SSE 1min.ai (`?isStreaming=true`) pour un prompt brut.
|
||||
|
||||
1min.ai expose : `event: content` → `data: {"content": "..."}`, `event: done`,
|
||||
`event: error`. On yield le champ `content` au fil de l'arrivee.
|
||||
"""
|
||||
url = f"{_API_BASE}?isStreaming=true"
|
||||
async with httpx.AsyncClient(timeout=self._timeout) as client:
|
||||
try:
|
||||
async with client.stream(
|
||||
@@ -95,9 +111,7 @@ class OneMinAiLLMProvider:
|
||||
async for token in self._parse_sse(response):
|
||||
yield token
|
||||
except httpx.HTTPError as exc:
|
||||
raise LLMProviderError(
|
||||
f"Erreur lors du streaming 1min.ai : {exc}"
|
||||
) from exc
|
||||
raise LLMProviderError(self._format_http_error(exc)) from exc
|
||||
|
||||
# --- Helpers ------------------------------------------------------------
|
||||
|
||||
@@ -146,12 +160,21 @@ class OneMinAiLLMProvider:
|
||||
"""
|
||||
record = payload.get("aiRecord") or {}
|
||||
detail = record.get("aiRecordDetail") or {}
|
||||
result = detail.get("resultObject") or []
|
||||
if isinstance(result, list):
|
||||
result = detail.get("resultObject")
|
||||
if isinstance(result, list) and result:
|
||||
return "".join(str(x) for x in result)
|
||||
if isinstance(result, str):
|
||||
if isinstance(result, str) and result:
|
||||
return result
|
||||
raise LLMProviderError("Reponse 1min.ai inattendue : resultObject absent.")
|
||||
|
||||
# Schema inattendu : on remonte un EXTRAIT du vrai payload pour diagnostiquer.
|
||||
# Causes frequentes : credits/quota 1min.ai epuises, moderation, modele
|
||||
# indisponible, ou reponse asynchrone (record cree mais resultat pas encore
|
||||
# pret). Sans ce detail, l'erreur "resultObject absent" est aveugle.
|
||||
snippet = json.dumps(payload, ensure_ascii=False)
|
||||
if len(snippet) > 800:
|
||||
snippet = snippet[:800] + "…"
|
||||
logger.warning("Reponse 1min.ai inattendue (resultObject absent) : %s", snippet)
|
||||
raise LLMProviderError(f"Reponse 1min.ai inattendue (resultObject absent) : {snippet}")
|
||||
|
||||
@staticmethod
|
||||
def _flatten_messages(
|
||||
|
||||
102
brain/app/infrastructure/pdf_extractor.py
Normal file
102
brain/app/infrastructure/pdf_extractor.py
Normal file
@@ -0,0 +1,102 @@
|
||||
"""Adapter d'extraction de texte PDF — implémente le port PdfTextExtractor.
|
||||
|
||||
Stratégie HYBRIDE auto :
|
||||
1. On tente d'abord l'extraction de la couche texte (PyMuPDF). Les PDF
|
||||
"born-digital" (livres de règles officiels type Nimble, faits dans
|
||||
InDesign/Affinity : très graphiques mais avec une vraie couche texte)
|
||||
passent par là → rapide, fidèle, AUCUN OCR.
|
||||
2. Si une page ne rend (quasi) aucun texte → c'est probablement une image
|
||||
(scan ou page 100% illustrée). On rasterise la page et on la passe à
|
||||
Tesseract (OCR). Gère donc aussi les scans purs et les PDF mixtes.
|
||||
|
||||
Tesseract est un binaire SYSTÈME (installé dans l'image Docker du Brain). S'il
|
||||
est absent (ex: exécution locale Windows sans install), l'OCR est désactivé
|
||||
proprement : les pages-images ressortent vides mais l'extraction ne plante pas,
|
||||
et le diagnostic le signale (used_ocr reste False, texte vide).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
import pymupdf as fitz # PyMuPDF — on importe par le nom canonique `pymupdf`
|
||||
# (et NON `import fitz`) pour éviter la collision avec le faux paquet PyPI "fitz"
|
||||
# qui échoue sur `from frontend import *`.
|
||||
|
||||
from app.domain.models import ExtractedDocument, ExtractedPage
|
||||
from app.domain.ports import PdfExtractionError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# En dessous de ce nombre de caractères "significatifs" sur une page, on
|
||||
# considère qu'il n'y a pas de couche texte exploitable → repli OCR.
|
||||
_MIN_TEXT_CHARS = 20
|
||||
|
||||
# DPI de rasterisation avant OCR. 300 = bon compromis qualité/vitesse pour du
|
||||
# texte de livre. Plus haut = plus lent et plus gourmand en mémoire.
|
||||
_OCR_DPI = 300
|
||||
|
||||
# Langues Tesseract : français + anglais (la plupart des règles de JDR FR ont
|
||||
# des termes anglais résiduels). Doivent être installées dans l'image Docker
|
||||
# (tesseract-ocr-fra, tesseract-ocr-eng).
|
||||
_OCR_LANGS = "fra+eng"
|
||||
|
||||
|
||||
class PyMuPdfTextExtractor:
|
||||
"""Extracteur PDF basé sur PyMuPDF, avec repli OCR Tesseract optionnel."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
# On détecte la disponibilité de l'OCR une seule fois (le binaire
|
||||
# Tesseract ne va pas apparaître/disparaître en cours d'exécution).
|
||||
self._ocr_available = self._detect_ocr()
|
||||
|
||||
@staticmethod
|
||||
def _detect_ocr() -> bool:
|
||||
"""True si pytesseract + le binaire Tesseract sont disponibles."""
|
||||
try:
|
||||
import pytesseract
|
||||
|
||||
pytesseract.get_tesseract_version()
|
||||
return True
|
||||
except Exception as exc: # ImportError, TesseractNotFoundError, etc.
|
||||
logger.warning(
|
||||
"OCR indisponible (Tesseract non installé ?) : %s. "
|
||||
"Les pages sans couche texte ressortiront vides.",
|
||||
exc,
|
||||
)
|
||||
return False
|
||||
|
||||
def extract(self, pdf_bytes: bytes) -> ExtractedDocument:
|
||||
try:
|
||||
doc = fitz.open(stream=pdf_bytes, filetype="pdf")
|
||||
except Exception as exc:
|
||||
raise PdfExtractionError(f"PDF illisible ou corrompu : {exc}") from exc
|
||||
|
||||
pages: list[ExtractedPage] = []
|
||||
try:
|
||||
for index, page in enumerate(doc):
|
||||
text = (page.get_text() or "").strip()
|
||||
used_ocr = False
|
||||
if len(text) < _MIN_TEXT_CHARS and self._ocr_available:
|
||||
ocr_text = self._ocr_page(page)
|
||||
if ocr_text.strip():
|
||||
text = ocr_text.strip()
|
||||
used_ocr = True
|
||||
pages.append(ExtractedPage(index=index, text=text, used_ocr=used_ocr))
|
||||
finally:
|
||||
doc.close()
|
||||
|
||||
return ExtractedDocument(pages=pages)
|
||||
|
||||
@staticmethod
|
||||
def _ocr_page(page: "fitz.Page") -> str:
|
||||
"""Rasterise une page et lui applique l'OCR Tesseract."""
|
||||
import pytesseract
|
||||
from PIL import Image
|
||||
|
||||
pix = page.get_pixmap(dpi=_OCR_DPI)
|
||||
img = Image.frombytes("RGB", (pix.width, pix.height), pix.samples)
|
||||
try:
|
||||
return pytesseract.image_to_string(img, lang=_OCR_LANGS)
|
||||
except Exception as exc:
|
||||
logger.warning("Échec OCR sur la page %s : %s", page.number, exc)
|
||||
return ""
|
||||
@@ -10,12 +10,15 @@ from typing import Annotated, AsyncIterator, Literal
|
||||
import hmac
|
||||
import httpx
|
||||
import tiktoken
|
||||
from fastapi import Depends, FastAPI, HTTPException, Request
|
||||
from fastapi import Depends, FastAPI, File, Form, HTTPException, Request, UploadFile
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
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.core.config import Settings, get_settings
|
||||
from app.core.settings_store import save_overrides
|
||||
from app.domain.models import (
|
||||
@@ -26,22 +29,28 @@ from app.domain.models import (
|
||||
NpcSummary,
|
||||
ChatMessage,
|
||||
GameSystemContext,
|
||||
JournalEntrySummary,
|
||||
LoreStructuralContext,
|
||||
NarrativeEntityContext,
|
||||
PageContext,
|
||||
PageGenerationContext,
|
||||
PageSummary,
|
||||
QuestSummary,
|
||||
RoomBranchHint,
|
||||
RoomSummary,
|
||||
SceneBranchHint,
|
||||
SceneSummary,
|
||||
SessionContext,
|
||||
)
|
||||
from app.domain.ports import LLMProvider, LLMProviderError
|
||||
from app.domain.ports import LLMProvider, LLMProviderError, PdfExtractionError
|
||||
from app.infrastructure.ollama_adapter import OllamaLLMProvider
|
||||
from app.infrastructure.onemin_adapter import OneMinAiLLMProvider
|
||||
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.8.3",
|
||||
version="0.10.0-beta",
|
||||
)
|
||||
|
||||
|
||||
@@ -169,6 +178,24 @@ class SceneBranchHintDTO(BaseModel):
|
||||
condition: str | None = None
|
||||
|
||||
|
||||
class RoomBranchHintDTO(BaseModel):
|
||||
"""Sortie d'une pièce vers une autre pièce du même lieu (donjon)."""
|
||||
|
||||
label: str
|
||||
target_room_name: str
|
||||
condition: str | None = None
|
||||
|
||||
|
||||
class RoomSummaryDTO(BaseModel):
|
||||
"""Pièce d'un lieu explorable. Omise par le Core si la scène est classique."""
|
||||
|
||||
name: str
|
||||
floor: int | None = None
|
||||
description: str | None = None
|
||||
enemies: str | None = None
|
||||
branches: list[RoomBranchHintDTO] = Field(default_factory=list)
|
||||
|
||||
|
||||
class SceneSummaryDTO(BaseModel):
|
||||
"""Résumé d'une scène : nom + description courte (synopsis)."""
|
||||
|
||||
@@ -179,6 +206,8 @@ class SceneSummaryDTO(BaseModel):
|
||||
illustration_count: int = 0
|
||||
# Branches narratives sortantes, omises cote Core si vides.
|
||||
branches: list[SceneBranchHintDTO] = Field(default_factory=list)
|
||||
# Pièces du lieu explorable, omises par Core si scène classique.
|
||||
rooms: list[RoomSummaryDTO] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ChapterSummaryDTO(BaseModel):
|
||||
@@ -243,13 +272,54 @@ class GameSystemContextDTO(BaseModel):
|
||||
sections: dict[str, str] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class JournalEntrySummaryDTO(BaseModel):
|
||||
"""Une entrée du journal de session.
|
||||
|
||||
`source_session_name` est présent uniquement pour les évènements issus
|
||||
des sessions précédentes — sert à ancrer temporellement dans le prompt.
|
||||
"""
|
||||
|
||||
type: str
|
||||
content: str
|
||||
occurred_at: str | None = None
|
||||
source_session_name: str | None = None
|
||||
|
||||
|
||||
class QuestSummaryDTO(BaseModel):
|
||||
"""Résumé d'une quête (Chapter dans un Arc HUB). Voir QuestSummary côté domaine."""
|
||||
|
||||
name: str
|
||||
arc_name: str
|
||||
description: str | None = None
|
||||
|
||||
|
||||
class SessionContextDTO(BaseModel):
|
||||
"""Contexte d'une Session de jeu en cours (Play Context).
|
||||
|
||||
Combine le journal complet (`entries`), les EVENTs des sessions précédentes
|
||||
(`previous_events`), et — depuis l'ajout du mode Hub — l'état des quêtes
|
||||
Hub de la campagne (disponibles / en cours / verrouillées) plus les flags
|
||||
narratifs actuellement actifs.
|
||||
"""
|
||||
|
||||
session_name: str
|
||||
active: bool
|
||||
started_at: str | None = None
|
||||
entries: list[JournalEntrySummaryDTO] = Field(default_factory=list)
|
||||
previous_events: list[JournalEntrySummaryDTO] = Field(default_factory=list)
|
||||
available_quests: list[QuestSummaryDTO] = Field(default_factory=list)
|
||||
in_progress_quests: list[QuestSummaryDTO] = Field(default_factory=list)
|
||||
locked_quest_titles: list[str] = Field(default_factory=list)
|
||||
active_flags: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ChatStreamRequestDTO(BaseModel):
|
||||
"""Requête de chat streamé : historique + contextes structurels.
|
||||
|
||||
Les 4 contextes (lore, page, campaign, narrative_entity) sont optionnels,
|
||||
mais au moins l'un des deux "niveaux haut" (lore_context ou
|
||||
campaign_context) doit être fourni. Le validateur `check_scope` applique
|
||||
cette règle à la frontière HTTP.
|
||||
Les contextes (lore, page, campaign, narrative_entity, session) sont
|
||||
optionnels, mais au moins l'un des contextes "racines" (lore_context,
|
||||
campaign_context ou session_context) doit être fourni. Le validateur
|
||||
`check_scope` applique cette règle à la frontière HTTP.
|
||||
"""
|
||||
|
||||
messages: list[ChatMessageDTO] = Field(min_length=1)
|
||||
@@ -258,10 +328,15 @@ class ChatStreamRequestDTO(BaseModel):
|
||||
campaign_context: CampaignContextDTO | None = None
|
||||
narrative_entity: NarrativeEntityDTO | None = None
|
||||
game_system_context: GameSystemContextDTO | None = None
|
||||
session_context: SessionContextDTO | None = None
|
||||
|
||||
def has_scope(self) -> bool:
|
||||
"""Vrai si au moins un contexte racine (Lore ou Campagne) est fourni."""
|
||||
return self.lore_context is not None or self.campaign_context is not None
|
||||
"""Vrai si au moins un contexte racine (Lore, Campagne ou Session) est fourni."""
|
||||
return (
|
||||
self.lore_context is not None
|
||||
or self.campaign_context is not None
|
||||
or self.session_context is not None
|
||||
)
|
||||
|
||||
|
||||
# --- Factories d'injection de dépendance ---
|
||||
@@ -304,6 +379,40 @@ 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)
|
||||
|
||||
|
||||
# --- Endpoints ---
|
||||
|
||||
|
||||
@@ -357,6 +466,177 @@ 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)})
|
||||
|
||||
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)})
|
||||
|
||||
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,
|
||||
@@ -385,6 +665,7 @@ async def chat_stream(
|
||||
campaign_context = _to_campaign_context(body.campaign_context)
|
||||
narrative_entity = _to_narrative_entity(body.narrative_entity)
|
||||
game_system_context = _to_game_system_context(body.game_system_context)
|
||||
session_context = _to_session_context(body.session_context)
|
||||
|
||||
# --- Comptage tokens pour la jauge de contexte frontend ---
|
||||
# On construit le system prompt une fois ici pour le compter — le use case
|
||||
@@ -397,6 +678,7 @@ async def chat_stream(
|
||||
campaign_context=campaign_context,
|
||||
narrative_entity=narrative_entity,
|
||||
game_system_context=game_system_context,
|
||||
session_context=session_context,
|
||||
)
|
||||
# Dernier message = "current" (souvent user), le reste = historique accumulé.
|
||||
current_msg = messages[-1] if messages else None
|
||||
@@ -421,6 +703,7 @@ async def chat_stream(
|
||||
campaign_context=campaign_context,
|
||||
narrative_entity=narrative_entity,
|
||||
game_system_context=game_system_context,
|
||||
session_context=session_context,
|
||||
):
|
||||
# json.dumps avec ensure_ascii=False pour préserver les accents
|
||||
yield f"data: {json.dumps({'token': token}, ensure_ascii=False)}\n\n"
|
||||
@@ -549,6 +832,23 @@ def _to_campaign_context(dto: CampaignContextDTO | None) -> CampaignStructuralCo
|
||||
)
|
||||
for br in sc.branches
|
||||
],
|
||||
rooms=[
|
||||
RoomSummary(
|
||||
name=room.name,
|
||||
floor=room.floor,
|
||||
description=room.description,
|
||||
enemies=room.enemies,
|
||||
branches=[
|
||||
RoomBranchHint(
|
||||
label=rb.label,
|
||||
target_room_name=rb.target_room_name,
|
||||
condition=rb.condition,
|
||||
)
|
||||
for rb in room.branches
|
||||
],
|
||||
)
|
||||
for room in sc.rooms
|
||||
],
|
||||
)
|
||||
for sc in ch.scenes
|
||||
],
|
||||
@@ -594,6 +894,10 @@ class SettingsDTO(BaseModel):
|
||||
# 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):
|
||||
@@ -606,6 +910,8 @@ class SettingsUpdateDTO(BaseModel):
|
||||
# Chaine vide => on efface la cle. None => pas de changement.
|
||||
onemin_api_key: str | 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:
|
||||
@@ -616,6 +922,8 @@ def _to_settings_dto(s: Settings) -> SettingsDTO:
|
||||
onemin_model=s.onemin_model,
|
||||
onemin_api_key_set=bool(s.onemin_api_key),
|
||||
llm_num_ctx=s.llm_num_ctx,
|
||||
import_chunk_tokens=s.import_chunk_tokens,
|
||||
llm_timeout_seconds=s.llm_timeout_seconds,
|
||||
)
|
||||
|
||||
|
||||
@@ -867,3 +1175,36 @@ def _to_game_system_context(dto: GameSystemContextDTO | None) -> GameSystemConte
|
||||
system_description=dto.system_description,
|
||||
sections=dict(dto.sections),
|
||||
)
|
||||
|
||||
|
||||
def _to_session_context(dto: SessionContextDTO | None) -> SessionContext | None:
|
||||
if dto is None:
|
||||
return None
|
||||
return SessionContext(
|
||||
session_name=dto.session_name,
|
||||
active=dto.active,
|
||||
started_at=dto.started_at,
|
||||
entries=[_to_journal_entry(e) for e in dto.entries],
|
||||
previous_events=[_to_journal_entry(e) for e in dto.previous_events],
|
||||
available_quests=[_to_quest_summary(q) for q in dto.available_quests],
|
||||
in_progress_quests=[_to_quest_summary(q) for q in dto.in_progress_quests],
|
||||
locked_quest_titles=list(dto.locked_quest_titles),
|
||||
active_flags=list(dto.active_flags),
|
||||
)
|
||||
|
||||
|
||||
def _to_quest_summary(dto: QuestSummaryDTO) -> QuestSummary:
|
||||
return QuestSummary(
|
||||
name=dto.name,
|
||||
arc_name=dto.arc_name,
|
||||
description=dto.description,
|
||||
)
|
||||
|
||||
|
||||
def _to_journal_entry(dto: JournalEntrySummaryDTO) -> JournalEntrySummary:
|
||||
return JournalEntrySummary(
|
||||
type=dto.type,
|
||||
content=dto.content,
|
||||
occurred_at=dto.occurred_at,
|
||||
source_session_name=dto.source_session_name,
|
||||
)
|
||||
|
||||
@@ -2,6 +2,8 @@ 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
|
||||
|
||||
@@ -10,3 +12,12 @@ 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.*
|
||||
|
||||
107
brain/scripts/test_import_rules.py
Normal file
107
brain/scripts/test_import_rules.py
Normal file
@@ -0,0 +1,107 @@
|
||||
"""CLI de test pour l'import de règles PDF — boucle de feedback rapide.
|
||||
|
||||
But : tester l'extraction + la structuration en sections sur un VRAI PDF
|
||||
(ex: livre Nimble), SANS passer par HTTP, le Core ou l'UI.
|
||||
|
||||
Usage (depuis le dossier brain/, venv activé) :
|
||||
python scripts/test_import_rules.py "chemin/vers/regles.pdf"
|
||||
|
||||
Le provider LLM utilisé est celui configuré (.env + overrides de l'écran
|
||||
Paramètres) — donc 1min.ai si tu l'as sélectionné. Le script :
|
||||
1. extrait le texte (et dit, page par page, si l'OCR s'est déclenché),
|
||||
2. découpe + structure via le LLM,
|
||||
3. affiche un résumé des sections,
|
||||
4. écrit le markdown complet dans "<pdf>.rules.md" à côté du PDF.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Permet `import app...` quel que soit le cwd : on ajoute la racine brain/.
|
||||
_BRAIN_ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(_BRAIN_ROOT))
|
||||
|
||||
from app.application.import_rules import ImportRulesUseCase # noqa: E402
|
||||
from app.core.config import get_settings # noqa: E402
|
||||
from app.domain.ports import LLMProvider, LLMProviderError, PdfExtractionError # noqa: E402
|
||||
from app.infrastructure.ollama_adapter import OllamaLLMProvider # noqa: E402
|
||||
from app.infrastructure.onemin_adapter import OneMinAiLLMProvider # noqa: E402
|
||||
from app.infrastructure.pdf_extractor import PyMuPdfTextExtractor # noqa: E402
|
||||
|
||||
|
||||
def _build_provider() -> LLMProvider:
|
||||
"""Réplique la factory de main.py (choix provider selon les settings)."""
|
||||
settings = get_settings()
|
||||
print(f"→ Provider LLM : {settings.llm_provider} "
|
||||
f"(modèle : {settings.onemin_model if settings.llm_provider == 'onemin' else settings.llm_model})")
|
||||
if settings.llm_provider == "onemin":
|
||||
return OneMinAiLLMProvider(settings)
|
||||
return OllamaLLMProvider(settings)
|
||||
|
||||
|
||||
async def _run(pdf_path: Path) -> None:
|
||||
pdf_bytes = pdf_path.read_bytes()
|
||||
print(f"→ PDF chargé : {pdf_path.name} ({len(pdf_bytes) // 1024} Ko)\n")
|
||||
|
||||
extractor = PyMuPdfTextExtractor()
|
||||
|
||||
# 1. Extraction seule d'abord, pour le diagnostic page/OCR avant tout LLM.
|
||||
try:
|
||||
doc = extractor.extract(pdf_bytes)
|
||||
except PdfExtractionError as exc:
|
||||
print(f"✗ Extraction impossible : {exc}")
|
||||
return
|
||||
|
||||
print(f"=== Extraction : {doc.page_count} page(s), "
|
||||
f"{doc.ocr_page_count} via OCR, "
|
||||
f"{len(doc.full_text)} caractères ===")
|
||||
if doc.ocr_page_count == 0:
|
||||
print(" → PDF born-digital détecté (couche texte présente, OCR non nécessaire).")
|
||||
else:
|
||||
print(f" → {doc.ocr_page_count} page(s) sans couche texte : OCR déclenché.")
|
||||
if not doc.full_text.strip():
|
||||
print("\n✗ Aucun texte extrait. Si le PDF est un scan, vérifie que Tesseract "
|
||||
"est installé (sinon l'OCR est désactivé).")
|
||||
return
|
||||
|
||||
# 2. Structuration via le LLM (réutilise l'extraction déjà faite indirectement :
|
||||
# le use case ré-extrait, coût négligeable vs l'appel LLM).
|
||||
print("\n=== Structuration via LLM (peut prendre un moment selon le nombre de morceaux)… ===")
|
||||
use_case = ImportRulesUseCase(llm=_build_provider(), extractor=extractor)
|
||||
try:
|
||||
result = await use_case.execute(pdf_bytes)
|
||||
except LLMProviderError as exc:
|
||||
print(f"✗ Échec LLM : {exc}")
|
||||
return
|
||||
|
||||
if not result.sections:
|
||||
print("\n✗ Aucune section proposée (le modèle n'a rien renvoyé d'exploitable).")
|
||||
return
|
||||
|
||||
print(f"\n=== {len(result.sections)} section(s) proposée(s) ===")
|
||||
for title, content in result.sections.items():
|
||||
preview = content.strip().replace("\n", " ")[:90]
|
||||
print(f" • {title} ({len(content)} car.) — {preview}…")
|
||||
|
||||
out_path = pdf_path.with_suffix(".rules.md")
|
||||
out_path.write_text(result.to_markdown(), encoding="utf-8")
|
||||
print(f"\n✓ Markdown complet écrit dans : {out_path}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s — %(message)s")
|
||||
if len(sys.argv) != 2:
|
||||
print("Usage : python scripts/test_import_rules.py \"chemin/vers/regles.pdf\"")
|
||||
sys.exit(1)
|
||||
pdf_path = Path(sys.argv[1]).expanduser()
|
||||
if not pdf_path.is_file():
|
||||
print(f"✗ Fichier introuvable : {pdf_path}")
|
||||
sys.exit(1)
|
||||
asyncio.run(_run(pdf_path))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
13
core/pom.xml
13
core/pom.xml
@@ -8,13 +8,13 @@
|
||||
<parent>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-parent</artifactId>
|
||||
<version>3.2.0</version>
|
||||
<version>3.2.12</version>
|
||||
<relativePath/>
|
||||
</parent>
|
||||
|
||||
<groupId>com.loremind</groupId>
|
||||
<artifactId>loremind-core</artifactId>
|
||||
<version>0.8.3</version>
|
||||
<version>0.10.0-beta</version>
|
||||
<name>LoreMind Core</name>
|
||||
<description>Backend Core - Architecture Hexagonale</description>
|
||||
|
||||
@@ -96,6 +96,15 @@
|
||||
<artifactId>bcprov-jdk18on</artifactId>
|
||||
<version>1.78.1</version>
|
||||
</dependency>
|
||||
<!-- Google Tink : runtime requis par com.nimbusds.jose.crypto.Ed25519Verifier
|
||||
(depuis Nimbus 9.x, la verification EdDSA delegue a Tink.subtle.Ed25519Verify).
|
||||
Tink n'est PAS une dependance transitive de nimbus-jose-jwt → il faut
|
||||
l'ajouter explicitement, sinon NoClassDefFoundError au premier verify(). -->
|
||||
<dependency>
|
||||
<groupId>com.google.crypto.tink</groupId>
|
||||
<artifactId>tink</artifactId>
|
||||
<version>1.14.1</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
|
||||
@@ -50,6 +50,15 @@ public class ArcService {
|
||||
return arcRepository.save(arc);
|
||||
}
|
||||
|
||||
/**
|
||||
* Création à partir d'un Arc complet (utilisé par le controller pour faire passer
|
||||
* les nouveaux champs comme type sans démultiplier les paramètres).
|
||||
*/
|
||||
public Arc createArc(Arc input) {
|
||||
input.setId(null);
|
||||
return arcRepository.save(input);
|
||||
}
|
||||
|
||||
public Optional<Arc> getArcById(String id) {
|
||||
return arcRepository.findById(id);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
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.campaigncontext.ports.CampaignPdfAdvisor;
|
||||
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
|
||||
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;
|
||||
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) — la même
|
||||
* matière que le chat de campagne — 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 CampaignStructuralContextBuilder campaignContextBuilder;
|
||||
private final LoreStructuralContextBuilder loreContextBuilder;
|
||||
private final CampaignPdfAdvisor advisor;
|
||||
|
||||
public CampaignAdaptService(
|
||||
CampaignRepository campaignRepository,
|
||||
CampaignStructuralContextBuilder campaignContextBuilder,
|
||||
LoreStructuralContextBuilder loreContextBuilder,
|
||||
CampaignPdfAdvisor advisor) {
|
||||
this.campaignRepository = campaignRepository;
|
||||
this.campaignContextBuilder = campaignContextBuilder;
|
||||
this.loreContextBuilder = loreContextBuilder;
|
||||
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, buildBrief(campaign), messagesJson, onToken, onComplete, onError);
|
||||
}
|
||||
|
||||
/** Construit un résumé markdown de la campagne (structure + PNJ + lore). */
|
||||
private String buildBrief(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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package com.loremind.application.campaigncontext;
|
||||
|
||||
import com.loremind.domain.campaigncontext.Arc;
|
||||
import com.loremind.domain.campaigncontext.ArcType;
|
||||
import com.loremind.domain.campaigncontext.CampaignImportProgress;
|
||||
import com.loremind.domain.campaigncontext.CampaignImportProposal;
|
||||
import com.loremind.domain.campaigncontext.CampaignImportProposal.ArcProposal;
|
||||
import com.loremind.domain.campaigncontext.CampaignImportProposal.ChapterProposal;
|
||||
import com.loremind.domain.campaigncontext.CampaignImportProposal.RoomProposal;
|
||||
import com.loremind.domain.campaigncontext.CampaignImportProposal.SceneProposal;
|
||||
import com.loremind.domain.campaigncontext.Chapter;
|
||||
import com.loremind.domain.campaigncontext.Room;
|
||||
import com.loremind.domain.campaigncontext.Scene;
|
||||
import com.loremind.domain.campaigncontext.ports.CampaignPdfImporter;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
* Service applicatif pour l'import d'un PDF de campagne.
|
||||
*
|
||||
* <p>Deux temps, conformes au principe « revue avant écriture » :
|
||||
* 1. {@link #importStructureStreaming} génère une PROPOSITION d'arbre (rien
|
||||
* n'est persisté), streamée pour l'avancement.
|
||||
* 2. {@link #applyStructure} crée réellement les arcs/chapitres/scènes une fois
|
||||
* l'arbre révisé/édité par l'utilisateur.</p>
|
||||
*/
|
||||
@Service
|
||||
public class CampaignImportService {
|
||||
|
||||
private final CampaignPdfImporter campaignPdfImporter;
|
||||
private final CampaignService campaignService;
|
||||
private final ArcService arcService;
|
||||
private final ChapterService chapterService;
|
||||
private final SceneService sceneService;
|
||||
|
||||
public CampaignImportService(
|
||||
CampaignPdfImporter campaignPdfImporter,
|
||||
CampaignService campaignService,
|
||||
ArcService arcService,
|
||||
ChapterService chapterService,
|
||||
SceneService sceneService) {
|
||||
this.campaignPdfImporter = campaignPdfImporter;
|
||||
this.campaignService = campaignService;
|
||||
this.arcService = arcService;
|
||||
this.chapterService = chapterService;
|
||||
this.sceneService = sceneService;
|
||||
}
|
||||
|
||||
/** Résumé de ce qui a été créé par {@link #applyStructure}. */
|
||||
public record ApplyResult(int arcsCreated, int chaptersCreated, int scenesCreated) {}
|
||||
|
||||
/** Génère la proposition d'arbre (streamée). Ne persiste rien. */
|
||||
public void importStructureStreaming(
|
||||
byte[] pdfBytes,
|
||||
String filename,
|
||||
Consumer<CampaignImportProgress> onProgress,
|
||||
Consumer<CampaignImportProposal> onDone,
|
||||
Consumer<Throwable> onError) {
|
||||
campaignPdfImporter.importCampaignStreaming(pdfBytes, filename, onProgress, onDone, onError);
|
||||
}
|
||||
|
||||
/**
|
||||
* Crée les arcs/chapitres/scènes de l'arbre révisé dans la campagne. Les
|
||||
* arcs sont ajoutés APRÈS les arcs existants (ordre continué). Tout est créé
|
||||
* dans une seule transaction (rollback si une étape échoue).
|
||||
*/
|
||||
@Transactional
|
||||
public ApplyResult applyStructure(String campaignId, CampaignImportProposal proposal) {
|
||||
if (!campaignService.campaignExists(campaignId)) {
|
||||
throw new IllegalArgumentException("Campagne introuvable : " + campaignId);
|
||||
}
|
||||
|
||||
int arcsCreated = 0, chaptersCreated = 0, scenesCreated = 0;
|
||||
|
||||
// Les nouveaux nœuds sont ordonnés APRÈS les frères existants (déjà comptés
|
||||
// via leur existingId dans l'arbre fusionné venu de la revue).
|
||||
int arcOrder = countExisting(proposal.arcs(), ArcProposal::existingId);
|
||||
for (ArcProposal arcP : proposal.arcs()) {
|
||||
if (isBlank(arcP.name())) continue;
|
||||
String arcId;
|
||||
if (!isBlank(arcP.existingId())) {
|
||||
arcId = arcP.existingId(); // arc déjà présent → on s'y rattache
|
||||
} else {
|
||||
arcOrder++;
|
||||
Arc arc = arcService.createArc(Arc.builder()
|
||||
.name(arcP.name().trim())
|
||||
.description(nullIfBlank(arcP.description()))
|
||||
.campaignId(campaignId)
|
||||
.order(arcOrder)
|
||||
.type(parseArcType(arcP.type()))
|
||||
.build());
|
||||
arcId = arc.getId();
|
||||
arcsCreated++;
|
||||
}
|
||||
|
||||
int chapterOrder = countExisting(arcP.chapters(), ChapterProposal::existingId);
|
||||
for (ChapterProposal chapP : safe(arcP.chapters())) {
|
||||
if (isBlank(chapP.name())) continue;
|
||||
String chapId;
|
||||
if (!isBlank(chapP.existingId())) {
|
||||
chapId = chapP.existingId();
|
||||
} else {
|
||||
chapterOrder++;
|
||||
Chapter chapter = chapterService.createChapter(
|
||||
chapP.name().trim(), nullIfBlank(chapP.description()), arcId, chapterOrder);
|
||||
chapId = chapter.getId();
|
||||
chaptersCreated++;
|
||||
}
|
||||
|
||||
int sceneOrder = countExisting(chapP.scenes(), SceneProposal::existingId);
|
||||
for (SceneProposal sceneP : safe(chapP.scenes())) {
|
||||
if (isBlank(sceneP.name())) continue;
|
||||
if (!isBlank(sceneP.existingId())) continue; // scène déjà présente
|
||||
sceneOrder++;
|
||||
sceneService.createScene(Scene.builder()
|
||||
.name(sceneP.name().trim())
|
||||
.description(nullIfBlank(sceneP.description()))
|
||||
.playerNarration(nullIfBlank(sceneP.playerNarration()))
|
||||
.gmSecretNotes(nullIfBlank(sceneP.gmNotes()))
|
||||
.chapterId(chapId)
|
||||
.order(sceneOrder)
|
||||
.rooms(toRooms(sceneP.rooms()))
|
||||
.build());
|
||||
scenesCreated++;
|
||||
}
|
||||
}
|
||||
}
|
||||
return new ApplyResult(arcsCreated, chaptersCreated, scenesCreated);
|
||||
}
|
||||
|
||||
/** Compte les nœuds déjà présents (existingId non vide) d'une liste. */
|
||||
private static <T> int countExisting(List<T> list, java.util.function.Function<T, String> idOf) {
|
||||
if (list == null) return 0;
|
||||
int n = 0;
|
||||
for (T t : list) {
|
||||
if (!isBlank(idOf.apply(t))) n++;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
/** "HUB" (insensible à la casse) → {@link ArcType#HUB} ; tout le reste → LINEAR. */
|
||||
private static ArcType parseArcType(String type) {
|
||||
return "HUB".equalsIgnoreCase(type == null ? "" : type.trim()) ? ArcType.HUB : ArcType.LINEAR;
|
||||
}
|
||||
|
||||
/** Convertit les pièces proposées en {@link Room} (ID généré, ordre = index). */
|
||||
private static List<Room> toRooms(List<RoomProposal> proposals) {
|
||||
List<Room> rooms = new ArrayList<>();
|
||||
if (proposals == null) return rooms;
|
||||
int order = 0;
|
||||
for (RoomProposal r : proposals) {
|
||||
if (isBlank(r.name())) continue;
|
||||
rooms.add(Room.builder()
|
||||
.id(UUID.randomUUID().toString())
|
||||
.name(r.name().trim())
|
||||
.description(nullIfBlank(r.description()))
|
||||
.enemies(nullIfBlank(r.enemies()))
|
||||
.loot(nullIfBlank(r.loot()))
|
||||
.order(order++)
|
||||
.build());
|
||||
}
|
||||
return rooms;
|
||||
}
|
||||
|
||||
private static <T> java.util.List<T> safe(java.util.List<T> list) {
|
||||
return list == null ? java.util.List.of() : list;
|
||||
}
|
||||
|
||||
private static boolean isBlank(String s) {
|
||||
return s == null || s.isBlank();
|
||||
}
|
||||
|
||||
private static String nullIfBlank(String s) {
|
||||
return isBlank(s) ? null : s.trim();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.loremind.application.campaigncontext;
|
||||
|
||||
import com.loremind.domain.campaigncontext.Arc;
|
||||
import com.loremind.domain.campaigncontext.Chapter;
|
||||
import com.loremind.domain.campaigncontext.Prerequisite;
|
||||
import com.loremind.domain.campaigncontext.ports.ArcRepository;
|
||||
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.TreeSet;
|
||||
|
||||
/**
|
||||
* Service applicatif : énumère les noms de faits ({@link Prerequisite.FlagSet})
|
||||
* référencés par les chapitres d'une Campagne.
|
||||
*
|
||||
* <p>Modèle "déclaration implicite" : il n'existe pas de table de faits déclarés
|
||||
* globalement. Un fait existe dès qu'au moins une quête le référence dans ses
|
||||
* prérequis. Ce service expose la liste dédupliquée pour les UIs (toggle dans
|
||||
* la Partie, autocomplete dans l'éditeur de prérequis).</p>
|
||||
*/
|
||||
@Service
|
||||
public class CampaignReferencedFlagsService {
|
||||
|
||||
private final ArcRepository arcRepository;
|
||||
private final ChapterRepository chapterRepository;
|
||||
|
||||
public CampaignReferencedFlagsService(ArcRepository arcRepository,
|
||||
ChapterRepository chapterRepository) {
|
||||
this.arcRepository = arcRepository;
|
||||
this.chapterRepository = chapterRepository;
|
||||
}
|
||||
|
||||
/** Retourne la liste triée alphabétiquement des noms de faits référencés. */
|
||||
public List<String> listForCampaign(String campaignId) {
|
||||
TreeSet<String> unique = new TreeSet<>();
|
||||
for (Arc arc : arcRepository.findByCampaignId(campaignId)) {
|
||||
for (Chapter chapter : chapterRepository.findByArcId(arc.getId())) {
|
||||
if (chapter.getPrerequisites() == null) continue;
|
||||
for (Prerequisite p : chapter.getPrerequisites()) {
|
||||
if (p instanceof Prerequisite.FlagSet f && f.flagName() != null && !f.flagName().isBlank()) {
|
||||
unique.add(f.flagName());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return List.copyOf(unique);
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,15 @@
|
||||
package com.loremind.application.campaigncontext;
|
||||
|
||||
import com.loremind.application.playcontext.PlaythroughService;
|
||||
import com.loremind.domain.campaigncontext.Arc;
|
||||
import com.loremind.domain.campaigncontext.Campaign;
|
||||
import com.loremind.domain.campaigncontext.Chapter;
|
||||
import com.loremind.domain.campaigncontext.ports.ArcRepository;
|
||||
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
|
||||
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
|
||||
import com.loremind.domain.campaigncontext.ports.CharacterRepository;
|
||||
import com.loremind.domain.campaigncontext.ports.SceneRepository;
|
||||
import com.loremind.domain.playcontext.Playthrough;
|
||||
import com.loremind.domain.playcontext.ports.PlaythroughRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@@ -15,9 +17,11 @@ import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Service d'application pour le contexte Campaign.
|
||||
* Orchestre la logique métier en utilisant le Port CampaignRepository.
|
||||
* Fait partie de la couche Application de l'Architecture Hexagonale.
|
||||
* Service d'application pour le contexte Campaign (scénario).
|
||||
*
|
||||
* <p>Depuis Playthrough : les PJ et les sessions ne sont plus rattachés directement
|
||||
* à la Campagne ; ils dépendent d'un Playthrough (Partie). La cascade de suppression
|
||||
* d'une campagne englobe donc ses Playthroughs (qui à leur tour cascadent).</p>
|
||||
*/
|
||||
@Service
|
||||
public class CampaignService {
|
||||
@@ -26,35 +30,27 @@ public class CampaignService {
|
||||
private final ArcRepository arcRepository;
|
||||
private final ChapterRepository chapterRepository;
|
||||
private final SceneRepository sceneRepository;
|
||||
private final CharacterRepository characterRepository;
|
||||
private final PlaythroughRepository playthroughRepository;
|
||||
private final PlaythroughService playthroughService;
|
||||
|
||||
public CampaignService(
|
||||
CampaignRepository campaignRepository,
|
||||
ArcRepository arcRepository,
|
||||
ChapterRepository chapterRepository,
|
||||
SceneRepository sceneRepository,
|
||||
CharacterRepository characterRepository) {
|
||||
PlaythroughRepository playthroughRepository,
|
||||
PlaythroughService playthroughService) {
|
||||
this.campaignRepository = campaignRepository;
|
||||
this.arcRepository = arcRepository;
|
||||
this.chapterRepository = chapterRepository;
|
||||
this.sceneRepository = sceneRepository;
|
||||
this.characterRepository = characterRepository;
|
||||
this.playthroughRepository = playthroughRepository;
|
||||
this.playthroughService = playthroughService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parameter Object pour la création / mise à jour d'une Campaign.
|
||||
* Évite une signature à rallonge et rend les évolutions futures (theme,
|
||||
* coverImageUrl, etc.) sans casser les appelants.
|
||||
*
|
||||
* <p>{@code loreId} est nullable : une campagne peut exister sans univers associé.</p>
|
||||
*/
|
||||
public record CampaignData(String name, String description, String loreId, String gameSystemId) {}
|
||||
|
||||
/**
|
||||
* Compte des entités qui seront supprimées en cascade si la campagne est effacée.
|
||||
* Utilisé par l'UI pour afficher un récapitulatif dans le dialogue de confirmation.
|
||||
*/
|
||||
public record DeletionImpact(int arcs, int chapters, int scenes, int characters) {}
|
||||
public record DeletionImpact(int arcs, int chapters, int scenes, int playthroughs) {}
|
||||
|
||||
public Campaign createCampaign(CampaignData data) {
|
||||
Campaign campaign = Campaign.builder()
|
||||
@@ -64,7 +60,12 @@ public class CampaignService {
|
||||
.gameSystemId(normalizeId(data.gameSystemId()))
|
||||
.arcsCount(0)
|
||||
.build();
|
||||
return campaignRepository.save(campaign);
|
||||
Campaign saved = campaignRepository.save(campaign);
|
||||
|
||||
// Une campagne sans Partie n'a pas de sens jouable : on crée d'office
|
||||
// une "Partie principale" pour que l'utilisateur puisse jouer immédiatement.
|
||||
playthroughService.create(saved.getId(), "Partie principale", null);
|
||||
return saved;
|
||||
}
|
||||
|
||||
public Optional<Campaign> getCampaignById(String id) {
|
||||
@@ -89,19 +90,10 @@ public class CampaignService {
|
||||
return campaignRepository.save(campaign);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise un ID entrant : une chaîne vide/blanche est traitée comme "pas de lien".
|
||||
* Utile car les payloads JSON peuvent envoyer "" au lieu de null.
|
||||
*/
|
||||
private String normalizeId(String id) {
|
||||
return (id == null || id.isBlank()) ? null : id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcule l'impact d'une suppression en cascade : nombre d'arcs, chapitres,
|
||||
* scènes et personnages qui disparaîtront avec la campagne. Utilisé par l'UI
|
||||
* pour afficher "X arcs, Y chapitres, Z scènes seront supprimés".
|
||||
*/
|
||||
public DeletionImpact getDeletionImpact(String id) {
|
||||
List<Arc> arcs = arcRepository.findByCampaignId(id);
|
||||
int chapterTotal = 0;
|
||||
@@ -113,22 +105,21 @@ public class CampaignService {
|
||||
sceneTotal += sceneRepository.findByChapterId(chapter.getId()).size();
|
||||
}
|
||||
}
|
||||
int characterTotal = characterRepository.findByCampaignId(id).size();
|
||||
return new DeletionImpact(arcs.size(), chapterTotal, sceneTotal, characterTotal);
|
||||
int playthroughTotal = playthroughRepository.findByCampaignId(id).size();
|
||||
return new DeletionImpact(arcs.size(), chapterTotal, sceneTotal, playthroughTotal);
|
||||
}
|
||||
|
||||
/**
|
||||
* Supprime la campagne et toutes ses entités dépendantes (arcs → chapitres →
|
||||
* scènes, plus les personnages). L'opération est transactionnelle : soit
|
||||
* tout disparaît, soit rien ne change. Les FKs applicatives n'ayant pas
|
||||
* de contrainte CASCADE au niveau DB, on orchestre la cascade ici.
|
||||
*/
|
||||
@Transactional
|
||||
public void deleteCampaign(String id) {
|
||||
List<Arc> arcs = arcRepository.findByCampaignId(id);
|
||||
for (Arc arc : arcs) {
|
||||
List<Chapter> chapters = chapterRepository.findByArcId(arc.getId());
|
||||
for (Chapter chapter : chapters) {
|
||||
// 1. Cascade des Playthroughs (qui cascadent eux-mêmes sur PJ/sessions/valeurs flags/progressions).
|
||||
for (Playthrough p : playthroughRepository.findByCampaignId(id)) {
|
||||
playthroughService.delete(p.getId());
|
||||
}
|
||||
// 2. Cascade du scénario : arcs → chapitres → scènes
|
||||
// (Pas de déclarations globales de faits : ils existent implicitement via les
|
||||
// prérequis FLAG_SET des chapitres, qui partent avec les chapitres.)
|
||||
for (Arc arc : arcRepository.findByCampaignId(id)) {
|
||||
for (Chapter chapter : chapterRepository.findByArcId(arc.getId())) {
|
||||
for (var scene : sceneRepository.findByChapterId(chapter.getId())) {
|
||||
sceneRepository.deleteById(scene.getId());
|
||||
}
|
||||
@@ -136,9 +127,6 @@ public class CampaignService {
|
||||
}
|
||||
arcRepository.deleteById(arc.getId());
|
||||
}
|
||||
for (var character : characterRepository.findByCampaignId(id)) {
|
||||
characterRepository.deleteById(character.getId());
|
||||
}
|
||||
campaignRepository.deleteById(id);
|
||||
}
|
||||
|
||||
|
||||
@@ -44,6 +44,16 @@ public class ChapterService {
|
||||
return chapterRepository.save(chapter);
|
||||
}
|
||||
|
||||
/**
|
||||
* Création à partir d'un Chapter complet (utilisé par le controller pour faire passer
|
||||
* les nouveaux champs comme progressionStatus / prerequisites sans démultiplier les
|
||||
* paramètres). L'id est forcé à null pour laisser la DB le générer.
|
||||
*/
|
||||
public Chapter createChapter(Chapter input) {
|
||||
input.setId(null);
|
||||
return chapterRepository.save(input);
|
||||
}
|
||||
|
||||
public Optional<Chapter> getChapterById(String id) {
|
||||
return chapterRepository.findById(id);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
package com.loremind.application.campaigncontext;
|
||||
|
||||
import com.loremind.domain.campaigncontext.Chapter;
|
||||
import com.loremind.domain.campaigncontext.PrerequisiteEvaluator;
|
||||
import com.loremind.domain.campaigncontext.ProgressionStatus;
|
||||
import com.loremind.domain.campaigncontext.QuestStatus;
|
||||
import com.loremind.domain.playcontext.QuestProgression;
|
||||
import com.loremind.domain.playcontext.ports.PlaythroughFlagRepository;
|
||||
import com.loremind.domain.playcontext.ports.PlaythroughRepository;
|
||||
import com.loremind.domain.playcontext.ports.QuestProgressionRepository;
|
||||
import com.loremind.domain.playcontext.ports.SessionRepository;
|
||||
import com.loremind.infrastructure.web.dto.campaigncontext.ChapterDTO;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Service applicatif : enrichit des ChapterDTO avec leur {@link QuestStatus} effectif,
|
||||
* relatif à un Playthrough donné.
|
||||
*
|
||||
* <p>Depuis l'introduction de Playthrough : la progression et les flags vivent au niveau
|
||||
* de la Partie, plus de la Campagne. L'enrichissement nécessite donc un playthroughId.</p>
|
||||
*/
|
||||
@Service
|
||||
public class ChapterStatusEnricher {
|
||||
|
||||
private final PlaythroughRepository playthroughRepository;
|
||||
private final QuestProgressionRepository progressionRepository;
|
||||
private final PlaythroughFlagRepository flagRepository;
|
||||
private final SessionRepository sessionRepository;
|
||||
private final PrerequisiteEvaluator evaluator = new PrerequisiteEvaluator();
|
||||
|
||||
public ChapterStatusEnricher(PlaythroughRepository playthroughRepository,
|
||||
QuestProgressionRepository progressionRepository,
|
||||
PlaythroughFlagRepository flagRepository,
|
||||
SessionRepository sessionRepository) {
|
||||
this.playthroughRepository = playthroughRepository;
|
||||
this.progressionRepository = progressionRepository;
|
||||
this.flagRepository = flagRepository;
|
||||
this.sessionRepository = sessionRepository;
|
||||
}
|
||||
|
||||
/** Contexte d'évaluation + map chapterId -> ProgressionStatus pour ce Playthrough. */
|
||||
public record PlaythroughEvalSnapshot(
|
||||
PrerequisiteEvaluator.EvaluationContext ctx,
|
||||
Map<String, ProgressionStatus> progressionByChapterId
|
||||
) {}
|
||||
|
||||
/** Construit le snapshot d'évaluation pour un Playthrough. */
|
||||
public PlaythroughEvalSnapshot buildSnapshot(String playthroughId) {
|
||||
if (playthroughId == null || playthroughRepository.findById(playthroughId).isEmpty()) {
|
||||
return new PlaythroughEvalSnapshot(
|
||||
new PrerequisiteEvaluator.EvaluationContext(Collections.emptySet(), 0, Collections.emptyMap()),
|
||||
Collections.emptyMap()
|
||||
);
|
||||
}
|
||||
Map<String, Boolean> flags = flagRepository.findByPlaythroughId(playthroughId);
|
||||
Set<String> completedQuestIds = progressionRepository.findCompletedChapterIdsByPlaythroughId(playthroughId);
|
||||
int sessionCount = sessionRepository.findByPlaythroughId(playthroughId).size();
|
||||
|
||||
Map<String, ProgressionStatus> progressionMap = new HashMap<>();
|
||||
for (QuestProgression qp : progressionRepository.findByPlaythroughId(playthroughId)) {
|
||||
progressionMap.put(qp.getChapterId(), qp.getStatus());
|
||||
}
|
||||
|
||||
return new PlaythroughEvalSnapshot(
|
||||
new PrerequisiteEvaluator.EvaluationContext(completedQuestIds, sessionCount, flags),
|
||||
progressionMap
|
||||
);
|
||||
}
|
||||
|
||||
/** Calcule le statut effectif d'un seul chapitre relatif à un Playthrough. */
|
||||
public QuestStatus computeFor(Chapter chapter, String playthroughId) {
|
||||
PlaythroughEvalSnapshot snap = buildSnapshot(playthroughId);
|
||||
ProgressionStatus progression = snap.progressionByChapterId()
|
||||
.getOrDefault(chapter.getId(), ProgressionStatus.NOT_STARTED);
|
||||
return evaluator.computeStatus(progression, chapter.getPrerequisites(), snap.ctx());
|
||||
}
|
||||
|
||||
/**
|
||||
* Injecte le {@code effectiveStatus} et le {@code progressionStatus} dans une liste de DTOs.
|
||||
* Un seul build du snapshot pour toute la liste (optimal pour les vues qui listent un arc).
|
||||
*/
|
||||
public void enrich(List<ChapterDTO> dtos, List<Chapter> domain, String playthroughId) {
|
||||
if (dtos == null || dtos.isEmpty()) return;
|
||||
PlaythroughEvalSnapshot snap = buildSnapshot(playthroughId);
|
||||
Map<String, Chapter> byId = domain.stream()
|
||||
.collect(Collectors.toMap(Chapter::getId, c -> c));
|
||||
for (ChapterDTO dto : dtos) {
|
||||
Chapter c = byId.get(dto.getId());
|
||||
if (c == null) continue;
|
||||
ProgressionStatus progression = snap.progressionByChapterId()
|
||||
.getOrDefault(c.getId(), ProgressionStatus.NOT_STARTED);
|
||||
QuestStatus status = evaluator.computeStatus(progression, c.getPrerequisites(), snap.ctx());
|
||||
dto.setProgressionStatus(progression.name());
|
||||
dto.setEffectiveStatus(status.name());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Service d'application pour les fiches de personnages (PJ).
|
||||
* Les PJ appartiennent désormais à un Playthrough (Partie), pas à la Campagne.
|
||||
*/
|
||||
@Service
|
||||
public class CharacterService {
|
||||
@@ -21,11 +22,6 @@ public class CharacterService {
|
||||
this.characterRepository = characterRepository;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parameter Object pour la création / mise à jour d'un Character.
|
||||
* `order` est fourni par le controller ; si absent, le service le calcule.
|
||||
* Les maps {@code values}/{@code imageValues} peuvent etre null (interpretees vides).
|
||||
*/
|
||||
public record CharacterData(
|
||||
String name,
|
||||
String portraitImageId,
|
||||
@@ -33,14 +29,14 @@ public class CharacterService {
|
||||
Map<String, String> values,
|
||||
Map<String, List<String>> imageValues,
|
||||
Map<String, Map<String, String>> keyValueValues,
|
||||
String campaignId,
|
||||
String playthroughId,
|
||||
Integer order
|
||||
) {}
|
||||
|
||||
public Character createCharacter(CharacterData data) {
|
||||
int order = data.order() != null
|
||||
? data.order()
|
||||
: nextOrderFor(data.campaignId());
|
||||
: nextOrderFor(data.playthroughId());
|
||||
Character character = Character.builder()
|
||||
.name(data.name())
|
||||
.portraitImageId(data.portraitImageId())
|
||||
@@ -48,7 +44,7 @@ public class CharacterService {
|
||||
.values(data.values() != null ? new HashMap<>(data.values()) : new HashMap<>())
|
||||
.imageValues(data.imageValues() != null ? new HashMap<>(data.imageValues()) : new HashMap<>())
|
||||
.keyValueValues(data.keyValueValues() != null ? new HashMap<>(data.keyValueValues()) : new HashMap<>())
|
||||
.campaignId(data.campaignId())
|
||||
.playthroughId(data.playthroughId())
|
||||
.order(order)
|
||||
.build();
|
||||
return characterRepository.save(character);
|
||||
@@ -58,8 +54,8 @@ public class CharacterService {
|
||||
return characterRepository.findById(id);
|
||||
}
|
||||
|
||||
public List<Character> getCharactersByCampaignId(String campaignId) {
|
||||
return characterRepository.findByCampaignId(campaignId);
|
||||
public List<Character> getCharactersByPlaythroughId(String playthroughId) {
|
||||
return characterRepository.findByPlaythroughId(playthroughId);
|
||||
}
|
||||
|
||||
public Character updateCharacter(String id, CharacterData data) {
|
||||
@@ -74,7 +70,7 @@ public class CharacterService {
|
||||
if (data.order() != null) {
|
||||
existing.setOrder(data.order());
|
||||
}
|
||||
// campaignId n'est pas modifiable après création (cross-campagne move hors scope MVP).
|
||||
// playthroughId immuable après création.
|
||||
return characterRepository.save(existing);
|
||||
}
|
||||
|
||||
@@ -82,9 +78,8 @@ public class CharacterService {
|
||||
characterRepository.deleteById(id);
|
||||
}
|
||||
|
||||
/** Renvoie la prochaine position libre — append en fin de liste. */
|
||||
private int nextOrderFor(String campaignId) {
|
||||
return characterRepository.findByCampaignId(campaignId).stream()
|
||||
private int nextOrderFor(String playthroughId) {
|
||||
return characterRepository.findByPlaythroughId(playthroughId).stream()
|
||||
.mapToInt(Character::getOrder)
|
||||
.max()
|
||||
.orElse(-1) + 1;
|
||||
|
||||
@@ -6,6 +6,7 @@ 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;
|
||||
@@ -40,6 +41,19 @@ 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);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
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;
|
||||
|
||||
@@ -12,9 +14,34 @@ import java.util.Optional;
|
||||
public class GameSystemService {
|
||||
|
||||
private final GameSystemRepository gameSystemRepository;
|
||||
private final RulesPdfImporter rulesPdfImporter;
|
||||
|
||||
public GameSystemService(GameSystemRepository gameSystemRepository) {
|
||||
public GameSystemService(GameSystemRepository gameSystemRepository,
|
||||
RulesPdfImporter rulesPdfImporter) {
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -18,6 +18,8 @@ import com.loremind.domain.generationcontext.CampaignStructuralContext.BranchHin
|
||||
import com.loremind.domain.generationcontext.CampaignStructuralContext.ChapterSummary;
|
||||
import com.loremind.domain.generationcontext.CampaignStructuralContext.CharacterSummary;
|
||||
import com.loremind.domain.generationcontext.CampaignStructuralContext.NpcSummary;
|
||||
import com.loremind.domain.generationcontext.CampaignStructuralContext.RoomBranchHint;
|
||||
import com.loremind.domain.generationcontext.CampaignStructuralContext.RoomSummary;
|
||||
import com.loremind.domain.generationcontext.CampaignStructuralContext.SceneSummary;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@@ -66,11 +68,18 @@ public class CampaignStructuralContextBuilder {
|
||||
private static final int CHARACTER_SNIPPET_MAX_LEN = 160;
|
||||
|
||||
/**
|
||||
* Construit la carte narrative d'une Campagne (arcs → chapitres → scènes,
|
||||
* nom + description courte à chaque niveau).
|
||||
* @throws IllegalArgumentException si la Campagne est introuvable
|
||||
* Construit la carte narrative d'une Campagne. Sans playthroughId, les PJ
|
||||
* sont omis (ils sont propres à une Partie).
|
||||
*/
|
||||
public CampaignStructuralContext build(String campaignId) {
|
||||
return build(campaignId, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Variante avec playthroughId : injecte les PJ de la Partie indiquée.
|
||||
* Les PNJ restent campagne-scope (donnée de scénario).
|
||||
*/
|
||||
public CampaignStructuralContext build(String campaignId, String playthroughId) {
|
||||
Campaign campaign = campaignRepository.findById(campaignId)
|
||||
.orElseThrow(() -> new IllegalArgumentException(
|
||||
"Campagne non trouvée avec l'ID: " + campaignId));
|
||||
@@ -80,10 +89,12 @@ public class CampaignStructuralContextBuilder {
|
||||
.map(this::toArcSummary)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
List<CharacterSummary> characters = characterRepository.findByCampaignId(campaignId).stream()
|
||||
.sorted(Comparator.comparingInt(Character::getOrder))
|
||||
.map(this::toCharacterSummary)
|
||||
.collect(Collectors.toList());
|
||||
List<CharacterSummary> characters = (playthroughId == null || playthroughId.isBlank())
|
||||
? List.of()
|
||||
: characterRepository.findByPlaythroughId(playthroughId).stream()
|
||||
.sorted(Comparator.comparingInt(Character::getOrder))
|
||||
.map(this::toCharacterSummary)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
List<NpcSummary> npcs = npcRepository.findByCampaignId(campaignId).stream()
|
||||
.sorted(Comparator.comparingInt(Npc::getOrder))
|
||||
@@ -175,11 +186,42 @@ public class CampaignStructuralContextBuilder {
|
||||
b.condition()))
|
||||
.collect(Collectors.toList());
|
||||
|
||||
List<RoomSummary> rooms = toRoomSummaries(scene);
|
||||
|
||||
return new SceneSummary(
|
||||
scene.getName(),
|
||||
scene.getDescription(),
|
||||
countImages(scene.getIllustrationImageIds()),
|
||||
hints);
|
||||
hints,
|
||||
rooms);
|
||||
}
|
||||
|
||||
/**
|
||||
* Projette les pièces d'une scène en RoomSummary pour le contexte IA.
|
||||
* Pas de notes MJ, pas de loot ni pièges : le prompt reste lisible. L'IA
|
||||
* connaît la structure du lieu (nom des pièces, ennemis, sorties) — c'est
|
||||
* suffisant pour proposer de la narration ou anticiper les choix.
|
||||
*/
|
||||
private List<RoomSummary> toRoomSummaries(Scene scene) {
|
||||
if (scene.getRooms() == null || scene.getRooms().isEmpty()) return List.of();
|
||||
Map<String, String> nameById = scene.getRooms().stream()
|
||||
.collect(Collectors.toMap(
|
||||
com.loremind.domain.campaigncontext.Room::getId,
|
||||
com.loremind.domain.campaigncontext.Room::getName,
|
||||
(a, b) -> a));
|
||||
return scene.getRooms().stream()
|
||||
.map(r -> {
|
||||
List<RoomBranchHint> hints = r.getBranches() == null
|
||||
? List.of()
|
||||
: r.getBranches().stream()
|
||||
.map(b -> new RoomBranchHint(
|
||||
b.label(),
|
||||
nameById.getOrDefault(b.targetRoomId(), "(pièce inconnue)"),
|
||||
b.condition()))
|
||||
.collect(Collectors.toList());
|
||||
return new RoomSummary(r.getName(), r.getFloor(), r.getDescription(), r.getEnemies(), hints);
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/** Helper defensif : compte les illustrations attachees (null-safe). */
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
package com.loremind.application.generationcontext;
|
||||
|
||||
import com.loremind.domain.campaigncontext.Arc;
|
||||
import com.loremind.domain.campaigncontext.ArcType;
|
||||
import com.loremind.domain.campaigncontext.Chapter;
|
||||
import com.loremind.domain.campaigncontext.PrerequisiteEvaluator;
|
||||
import com.loremind.domain.campaigncontext.ProgressionStatus;
|
||||
import com.loremind.domain.campaigncontext.QuestStatus;
|
||||
import com.loremind.domain.campaigncontext.ports.ArcRepository;
|
||||
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
|
||||
import com.loremind.domain.generationcontext.SessionContext;
|
||||
import com.loremind.domain.generationcontext.SessionContext.JournalEntrySummary;
|
||||
import com.loremind.domain.generationcontext.SessionContext.QuestSummary;
|
||||
import com.loremind.domain.playcontext.EntryType;
|
||||
import com.loremind.domain.playcontext.Playthrough;
|
||||
import com.loremind.domain.playcontext.QuestProgression;
|
||||
import com.loremind.domain.playcontext.Session;
|
||||
import com.loremind.domain.playcontext.SessionEntry;
|
||||
import com.loremind.domain.playcontext.ports.PlaythroughFlagRepository;
|
||||
import com.loremind.domain.playcontext.ports.PlaythroughRepository;
|
||||
import com.loremind.domain.playcontext.ports.QuestProgressionRepository;
|
||||
import com.loremind.domain.playcontext.ports.SessionEntryRepository;
|
||||
import com.loremind.domain.playcontext.ports.SessionRepository;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Construit le SessionContext injecté dans le prompt IA pendant une partie.
|
||||
*
|
||||
* <p>Depuis la refonte Playthrough : la session connaît son playthroughId ; la progression
|
||||
* et les flags viennent du Playthrough (pas plus de la Campagne ni du Chapter).</p>
|
||||
*/
|
||||
@Component
|
||||
public class SessionStructuralContextBuilder {
|
||||
|
||||
private static final int MAX_CURRENT_ENTRIES = 80;
|
||||
private static final int MAX_PREVIOUS_EVENTS = 60;
|
||||
|
||||
private final SessionRepository sessionRepository;
|
||||
private final SessionEntryRepository entryRepository;
|
||||
private final PlaythroughRepository playthroughRepository;
|
||||
private final ArcRepository arcRepository;
|
||||
private final ChapterRepository chapterRepository;
|
||||
private final PlaythroughFlagRepository playthroughFlagRepository;
|
||||
private final QuestProgressionRepository questProgressionRepository;
|
||||
private final PrerequisiteEvaluator prerequisiteEvaluator = new PrerequisiteEvaluator();
|
||||
|
||||
public SessionStructuralContextBuilder(SessionRepository sessionRepository,
|
||||
SessionEntryRepository entryRepository,
|
||||
PlaythroughRepository playthroughRepository,
|
||||
ArcRepository arcRepository,
|
||||
ChapterRepository chapterRepository,
|
||||
PlaythroughFlagRepository playthroughFlagRepository,
|
||||
QuestProgressionRepository questProgressionRepository) {
|
||||
this.sessionRepository = sessionRepository;
|
||||
this.entryRepository = entryRepository;
|
||||
this.playthroughRepository = playthroughRepository;
|
||||
this.arcRepository = arcRepository;
|
||||
this.chapterRepository = chapterRepository;
|
||||
this.playthroughFlagRepository = playthroughFlagRepository;
|
||||
this.questProgressionRepository = questProgressionRepository;
|
||||
}
|
||||
|
||||
public Optional<SessionContext> buildOptional(String sessionId) {
|
||||
return sessionRepository.findById(sessionId).map(this::toContext);
|
||||
}
|
||||
|
||||
public SessionContext build(String sessionId) {
|
||||
Session session = sessionRepository.findById(sessionId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Session introuvable : " + sessionId));
|
||||
return toContext(session);
|
||||
}
|
||||
|
||||
private SessionContext toContext(Session session) {
|
||||
List<JournalEntrySummary> currentEntries = loadCurrentEntries(session);
|
||||
List<JournalEntrySummary> previousEvents = loadPreviousEvents(session);
|
||||
HubStatus hub = computeHubStatus(session.getPlaythroughId());
|
||||
|
||||
return new SessionContext(
|
||||
session.getName(),
|
||||
session.isActive(),
|
||||
session.getStartedAt(),
|
||||
currentEntries,
|
||||
previousEvents,
|
||||
hub.available(),
|
||||
hub.inProgress(),
|
||||
hub.lockedTitles(),
|
||||
hub.activeFlags());
|
||||
}
|
||||
|
||||
private List<JournalEntrySummary> loadCurrentEntries(Session session) {
|
||||
List<SessionEntry> allEntries = entryRepository.findBySessionId(session.getId());
|
||||
List<SessionEntry> kept = allEntries.size() <= MAX_CURRENT_ENTRIES
|
||||
? allEntries
|
||||
: allEntries.subList(allEntries.size() - MAX_CURRENT_ENTRIES, allEntries.size());
|
||||
|
||||
return kept.stream()
|
||||
.map(e -> toSummary(e, null))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* EVENTs des sessions précédentes du MÊME Playthrough (même table).
|
||||
* On ne mélange jamais les EVENTs de tables différentes.
|
||||
*/
|
||||
private List<JournalEntrySummary> loadPreviousEvents(Session current) {
|
||||
if (current.getPlaythroughId() == null) return List.of();
|
||||
List<Session> siblingSessions = sessionRepository.findByPlaythroughId(current.getPlaythroughId());
|
||||
List<JournalEntrySummary> events = new ArrayList<>();
|
||||
|
||||
for (Session past : siblingSessions) {
|
||||
if (past.getId().equals(current.getId())) continue;
|
||||
for (SessionEntry entry : entryRepository.findBySessionId(past.getId())) {
|
||||
if (entry.getType() == EntryType.EVENT) {
|
||||
events.add(toSummary(entry, past.getName()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
events.sort(Comparator.comparing(
|
||||
JournalEntrySummary::occurredAt,
|
||||
Comparator.nullsLast(Comparator.naturalOrder())));
|
||||
|
||||
if (events.size() > MAX_PREVIOUS_EVENTS) {
|
||||
return events.subList(events.size() - MAX_PREVIOUS_EVENTS, events.size());
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
private JournalEntrySummary toSummary(SessionEntry entry, String sourceSessionName) {
|
||||
return new JournalEntrySummary(
|
||||
entry.getType() != null ? entry.getType().name() : "NOTE",
|
||||
entry.getContent(),
|
||||
entry.getOccurredAt(),
|
||||
sourceSessionName);
|
||||
}
|
||||
|
||||
/** Agrégat interne des données Hub à injecter dans le SessionContext. */
|
||||
private record HubStatus(
|
||||
List<QuestSummary> available,
|
||||
List<QuestSummary> inProgress,
|
||||
List<String> lockedTitles,
|
||||
List<String> activeFlags
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Calcule l'état des quêtes Hub du Playthrough courant :
|
||||
* - AVAILABLE / IN_PROGRESS → résumé complet
|
||||
* - LOCKED → titre uniquement (anti-spoiler)
|
||||
* - COMPLETED → omis (déjà raconté par les EVENTs)
|
||||
*/
|
||||
private HubStatus computeHubStatus(String playthroughId) {
|
||||
if (playthroughId == null) {
|
||||
return new HubStatus(List.of(), List.of(), List.of(), List.of());
|
||||
}
|
||||
Optional<Playthrough> maybePlaythrough = playthroughRepository.findById(playthroughId);
|
||||
if (maybePlaythrough.isEmpty()) {
|
||||
return new HubStatus(List.of(), List.of(), List.of(), List.of());
|
||||
}
|
||||
String campaignId = maybePlaythrough.get().getCampaignId();
|
||||
|
||||
Map<String, Boolean> flags = playthroughFlagRepository.findByPlaythroughId(playthroughId);
|
||||
List<String> activeFlags = buildActiveFlags(flags);
|
||||
|
||||
List<Arc> arcs = arcRepository.findByCampaignId(campaignId);
|
||||
Map<String, Arc> arcsById = arcs.stream()
|
||||
.filter(a -> a.getId() != null)
|
||||
.collect(Collectors.toMap(Arc::getId, a -> a));
|
||||
|
||||
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<>();
|
||||
for (QuestProgression qp : questProgressionRepository.findByPlaythroughId(playthroughId)) {
|
||||
progressionByChapter.put(qp.getChapterId(), qp.getStatus());
|
||||
}
|
||||
|
||||
// IDs des chapitres COMPLETED dans la campagne (pour les prérequis QuestCompleted)
|
||||
var completedIds = questProgressionRepository.findCompletedChapterIdsByPlaythroughId(playthroughId);
|
||||
|
||||
int sessionCount = sessionRepository.findByPlaythroughId(playthroughId).size();
|
||||
PrerequisiteEvaluator.EvaluationContext ctx =
|
||||
new PrerequisiteEvaluator.EvaluationContext(completedIds, sessionCount, flags);
|
||||
|
||||
List<QuestSummary> available = new ArrayList<>();
|
||||
List<QuestSummary> inProgress = new ArrayList<>();
|
||||
List<String> lockedTitles = new ArrayList<>();
|
||||
|
||||
for (Arc arc : arcs) {
|
||||
if (arc.getType() != ArcType.HUB) continue;
|
||||
for (Chapter c : chapterRepository.findByArcId(arc.getId())) {
|
||||
ProgressionStatus prog = progressionByChapter.getOrDefault(c.getId(), ProgressionStatus.NOT_STARTED);
|
||||
QuestStatus status = prerequisiteEvaluator.computeStatus(prog, c.getPrerequisites(), ctx);
|
||||
Arc parent = arcsById.get(c.getArcId());
|
||||
String arcName = parent != null ? parent.getName() : null;
|
||||
switch (status) {
|
||||
case AVAILABLE:
|
||||
available.add(new QuestSummary(c.getName(), arcName, c.getDescription()));
|
||||
break;
|
||||
case IN_PROGRESS:
|
||||
inProgress.add(new QuestSummary(c.getName(), arcName, c.getDescription()));
|
||||
break;
|
||||
case LOCKED:
|
||||
lockedTitles.add(c.getName());
|
||||
break;
|
||||
case COMPLETED:
|
||||
// Omis (déjà dans le journal des EVENTs).
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new HubStatus(available, inProgress, lockedTitles, activeFlags);
|
||||
}
|
||||
|
||||
private List<String> buildActiveFlags(Map<String, Boolean> flags) {
|
||||
return flags.entrySet().stream()
|
||||
.filter(Map.Entry::getValue)
|
||||
.map(Map.Entry::getKey)
|
||||
.sorted()
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package com.loremind.application.generationcontext;
|
||||
|
||||
import com.loremind.application.gamesystemcontext.GameSystemContextBuilder;
|
||||
import com.loremind.domain.campaigncontext.Campaign;
|
||||
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
|
||||
import com.loremind.domain.gamesystemcontext.GenerationIntent;
|
||||
import com.loremind.domain.generationcontext.CampaignStructuralContext;
|
||||
import com.loremind.domain.generationcontext.ChatMessage;
|
||||
import com.loremind.domain.generationcontext.ChatRequest;
|
||||
import com.loremind.domain.generationcontext.ChatUsage;
|
||||
import com.loremind.domain.generationcontext.GameSystemContext;
|
||||
import com.loremind.domain.generationcontext.LoreStructuralContext;
|
||||
import com.loremind.domain.generationcontext.SessionContext;
|
||||
import com.loremind.domain.generationcontext.ports.AiChatProvider;
|
||||
import com.loremind.domain.playcontext.Playthrough;
|
||||
import com.loremind.domain.playcontext.Session;
|
||||
import com.loremind.domain.playcontext.ports.PlaythroughRepository;
|
||||
import com.loremind.domain.playcontext.ports.SessionRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
* Use case applicatif : chat IA pendant une Session de jeu.
|
||||
* <p>
|
||||
* Orchestre la composition des contextes :
|
||||
* 1. Charge la Session puis la Campagne associée (weak reference).
|
||||
* 2. Construit le CampaignStructuralContext (carte narrative + PJ/PNJ).
|
||||
* 3. Construit le LoreStructuralContext si la campagne est liée à un Lore.
|
||||
* 4. Construit le GameSystemContext si elle a un système de JDR.
|
||||
* 5. Construit le SessionContext (journal horodaté, statut).
|
||||
* 6. Délègue au port {@link AiChatProvider} pour le streaming.
|
||||
* </p>
|
||||
*
|
||||
* <p>La conversation est éphémère (pas de persistance) : pendant une partie,
|
||||
* l'utilité est d'avoir une assistance immédiate, pas de garder un historique.
|
||||
* Le journal de session joue déjà ce rôle de mémoire persistante.</p>
|
||||
*/
|
||||
@Service
|
||||
public class StreamChatForSessionUseCase {
|
||||
|
||||
private final SessionRepository sessionRepository;
|
||||
private final PlaythroughRepository playthroughRepository;
|
||||
private final CampaignRepository campaignRepository;
|
||||
private final CampaignStructuralContextBuilder campaignContextBuilder;
|
||||
private final LoreStructuralContextBuilder loreContextBuilder;
|
||||
private final GameSystemContextBuilder gameSystemContextBuilder;
|
||||
private final SessionStructuralContextBuilder sessionContextBuilder;
|
||||
private final AiChatProvider aiChatProvider;
|
||||
|
||||
public StreamChatForSessionUseCase(
|
||||
SessionRepository sessionRepository,
|
||||
PlaythroughRepository playthroughRepository,
|
||||
CampaignRepository campaignRepository,
|
||||
CampaignStructuralContextBuilder campaignContextBuilder,
|
||||
LoreStructuralContextBuilder loreContextBuilder,
|
||||
GameSystemContextBuilder gameSystemContextBuilder,
|
||||
SessionStructuralContextBuilder sessionContextBuilder,
|
||||
AiChatProvider aiChatProvider) {
|
||||
this.sessionRepository = sessionRepository;
|
||||
this.playthroughRepository = playthroughRepository;
|
||||
this.campaignRepository = campaignRepository;
|
||||
this.campaignContextBuilder = campaignContextBuilder;
|
||||
this.loreContextBuilder = loreContextBuilder;
|
||||
this.gameSystemContextBuilder = gameSystemContextBuilder;
|
||||
this.sessionContextBuilder = sessionContextBuilder;
|
||||
this.aiChatProvider = aiChatProvider;
|
||||
}
|
||||
|
||||
public void execute(
|
||||
String sessionId,
|
||||
List<ChatMessage> messages,
|
||||
Consumer<ChatUsage> onUsage,
|
||||
Consumer<String> onToken,
|
||||
Runnable onComplete,
|
||||
Consumer<Throwable> onError) {
|
||||
|
||||
Session session = sessionRepository.findById(sessionId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Session introuvable : " + sessionId));
|
||||
|
||||
// Chaîne de résolution : Session → Playthrough → Campaign.
|
||||
Playthrough playthrough = playthroughRepository.findById(session.getPlaythroughId())
|
||||
.orElseThrow(() -> new IllegalArgumentException(
|
||||
"Partie associée à la session introuvable : " + session.getPlaythroughId()));
|
||||
|
||||
Campaign campaign = campaignRepository.findById(playthrough.getCampaignId())
|
||||
.orElseThrow(() -> new IllegalArgumentException(
|
||||
"Campagne associée à la partie introuvable : " + playthrough.getCampaignId()));
|
||||
|
||||
// Le campaign context inclut les PJ de CE Playthrough (les PJ sont par-table).
|
||||
CampaignStructuralContext campaignContext = campaignContextBuilder.build(campaign.getId(), playthrough.getId());
|
||||
LoreStructuralContext loreContext = loadLoreContextOrNull(campaign);
|
||||
GameSystemContext gameSystemContext = loadGameSystemContextOrNull(campaign);
|
||||
SessionContext sessionContext = sessionContextBuilder.build(sessionId);
|
||||
|
||||
ChatRequest request = ChatRequest.builder()
|
||||
.messages(messages)
|
||||
.loreContext(loreContext)
|
||||
.campaignContext(campaignContext)
|
||||
.gameSystemContext(gameSystemContext)
|
||||
.sessionContext(sessionContext)
|
||||
.build();
|
||||
|
||||
aiChatProvider.streamChat(request, onUsage, onToken, onComplete, onError);
|
||||
}
|
||||
|
||||
private LoreStructuralContext loadLoreContextOrNull(Campaign campaign) {
|
||||
if (!campaign.isLinkedToLore()) return null;
|
||||
return loreContextBuilder.buildOptional(campaign.getLoreId()).orElse(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pendant une session active, on injecte les sections les plus utiles en partie
|
||||
* (combats, PNJ, mécaniques) — intent SCENE est le plus proche de ce besoin.
|
||||
*/
|
||||
private GameSystemContext loadGameSystemContextOrNull(Campaign campaign) {
|
||||
if (!campaign.isLinkedToGameSystem()) return null;
|
||||
return gameSystemContextBuilder.buildOptional(campaign.getGameSystemId(), GenerationIntent.SCENE)
|
||||
.orElse(null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package com.loremind.application.licensing;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.time.Instant;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Orchestre la bascule de canal stable <-> beta via le sidecar `switcher`.
|
||||
*
|
||||
* <p>Le sidecar tourne en permanence et watch un fichier {@code command.json}
|
||||
* dans un volume partage. Quand on depose une commande, il :
|
||||
* <ol>
|
||||
* <li>Sed la ligne IMAGE_NAMESPACE du .env</li>
|
||||
* <li>Lance docker compose pull + up -d</li>
|
||||
* <li>Ecrit son resultat dans {@code result.json}</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>Le Core n'a PAS acces au socket Docker — il delegue tout au sidecar
|
||||
* via fichiers, ce qui evite que la compromission du Core ne donne RCE
|
||||
* sur l'hote. Le sidecar valide strictement le contenu de la commande
|
||||
* (channel ∈ {stable, beta} uniquement).
|
||||
*
|
||||
* <p>Le canal actuel se deduit du prefixe d'image courant (recupere via
|
||||
* la variable d'env {@code IMAGE_NAMESPACE} ou {@code UPDATE_CHECK_IMAGES}) :
|
||||
* presence de "loremind-beta-" => canal beta, sinon stable.
|
||||
*/
|
||||
@Service
|
||||
public class ChannelSwitcherService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(ChannelSwitcherService.class);
|
||||
|
||||
public enum Channel { STABLE, BETA }
|
||||
|
||||
public enum SwitchStatus { IN_PROGRESS, SUCCESS, ERROR }
|
||||
|
||||
/** Snapshot du dernier resultat de switch ecrit par le sidecar. */
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public record SwitchResult(
|
||||
String id,
|
||||
SwitchStatus status,
|
||||
Channel channel,
|
||||
String message,
|
||||
Instant completedAt) {}
|
||||
|
||||
private final Path switcherDataPath;
|
||||
private final String imageNamespace;
|
||||
private final ObjectMapper json = new ObjectMapper();
|
||||
|
||||
public ChannelSwitcherService(
|
||||
@Value("${SWITCHER_DATA_PATH:/shared/switcher}") String switcherDataPath,
|
||||
// On lit IMAGE_NAMESPACE en priorite, puis UPDATE_CHECK_IMAGES en fallback
|
||||
// (la deuxieme est toujours injectee par compose, contrairement a la premiere
|
||||
// qui peut etre absente dans les .env legacy).
|
||||
@Value("${IMAGE_NAMESPACE:${UPDATE_CHECK_IMAGES:}}") String imageNamespaceRaw) {
|
||||
this.switcherDataPath = Path.of(switcherDataPath);
|
||||
this.imageNamespace = imageNamespaceRaw != null ? imageNamespaceRaw : "";
|
||||
log.info("ChannelSwitcherService initialized: dataPath={} imageNamespace={}",
|
||||
switcherDataPath, this.imageNamespace);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detection du canal courant a partir du prefixe d'image charge au demarrage.
|
||||
* Pas de magie : si le namespace contient "beta-" on est en beta, sinon stable.
|
||||
*/
|
||||
public Channel getCurrentChannel() {
|
||||
return imageNamespace.contains("loremind-beta-") ? Channel.BETA : Channel.STABLE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Indique si le sidecar est disponible (volume partage accessible).
|
||||
* Si non, on degrade en lecture seule (l'UI affichera l'ancien message
|
||||
* avec instructions manuelles).
|
||||
*/
|
||||
public boolean isSwitcherAvailable() {
|
||||
return Files.isDirectory(switcherDataPath) && Files.isWritable(switcherDataPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Depose une commande de switch dans le volume partage. Renvoie l'ID
|
||||
* de la commande, que le client peut utiliser pour poller le status.
|
||||
*
|
||||
* @throws IllegalStateException si le sidecar n'est pas disponible
|
||||
* @throws IOException si l'ecriture du fichier echoue
|
||||
*/
|
||||
public String requestSwitch(Channel target) throws IOException {
|
||||
if (!isSwitcherAvailable()) {
|
||||
throw new IllegalStateException("Switcher sidecar not available (volume mount missing)");
|
||||
}
|
||||
String id = UUID.randomUUID().toString();
|
||||
Map<String, Object> command = new LinkedHashMap<>();
|
||||
command.put("id", id);
|
||||
command.put("channel", target.name().toLowerCase());
|
||||
command.put("requestedAt", Instant.now().toString());
|
||||
|
||||
Path commandFile = switcherDataPath.resolve("command.json");
|
||||
Path tmp = Files.createTempFile(switcherDataPath, "command-", ".tmp");
|
||||
try {
|
||||
json.writerWithDefaultPrettyPrinter().writeValue(tmp.toFile(), command);
|
||||
// Atomic move : evite que le sidecar lise un fichier partiellement ecrit.
|
||||
Files.move(tmp, commandFile, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
|
||||
} finally {
|
||||
// Cleanup au cas ou move aurait echoue avant le rename.
|
||||
Files.deleteIfExists(tmp);
|
||||
}
|
||||
log.info("Switch command written: id={} channel={}", id, target);
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lit le dernier resultat ecrit par le sidecar, s'il existe.
|
||||
* Renvoie null si aucun switch n'a encore ete tente sur cette instance.
|
||||
*/
|
||||
public SwitchResult getLastResult() {
|
||||
Path resultFile = switcherDataPath.resolve("result.json");
|
||||
if (!Files.exists(resultFile)) return null;
|
||||
try {
|
||||
return json.readValue(resultFile.toFile(), SwitchResult.class);
|
||||
} catch (IOException e) {
|
||||
log.warn("Cannot parse switcher result.json: {}", e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package com.loremind.application.playcontext;
|
||||
|
||||
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
|
||||
import com.loremind.domain.campaigncontext.ports.CharacterRepository;
|
||||
import com.loremind.domain.playcontext.Playthrough;
|
||||
import com.loremind.domain.playcontext.Session;
|
||||
import com.loremind.domain.playcontext.ports.PlaythroughFlagRepository;
|
||||
import com.loremind.domain.playcontext.ports.PlaythroughRepository;
|
||||
import com.loremind.domain.playcontext.ports.QuestProgressionRepository;
|
||||
import com.loremind.domain.playcontext.ports.SessionRepository;
|
||||
import org.springframework.beans.BeanUtils;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Service applicatif pour le cycle de vie d'un Playthrough (Partie).
|
||||
*
|
||||
* <p>Cascade de suppression : un Playthrough supprime ses flags, ses progressions,
|
||||
* ses PJ et ses sessions (avec leurs entrées de journal).</p>
|
||||
*/
|
||||
@Service
|
||||
public class PlaythroughService {
|
||||
|
||||
private final PlaythroughRepository playthroughRepository;
|
||||
private final CampaignRepository campaignRepository;
|
||||
private final PlaythroughFlagRepository flagRepository;
|
||||
private final QuestProgressionRepository progressionRepository;
|
||||
private final CharacterRepository characterRepository;
|
||||
private final SessionRepository sessionRepository;
|
||||
private final SessionService sessionService;
|
||||
|
||||
public PlaythroughService(PlaythroughRepository playthroughRepository,
|
||||
CampaignRepository campaignRepository,
|
||||
PlaythroughFlagRepository flagRepository,
|
||||
QuestProgressionRepository progressionRepository,
|
||||
CharacterRepository characterRepository,
|
||||
SessionRepository sessionRepository,
|
||||
SessionService sessionService) {
|
||||
this.playthroughRepository = playthroughRepository;
|
||||
this.campaignRepository = campaignRepository;
|
||||
this.flagRepository = flagRepository;
|
||||
this.progressionRepository = progressionRepository;
|
||||
this.characterRepository = characterRepository;
|
||||
this.sessionRepository = sessionRepository;
|
||||
this.sessionService = sessionService;
|
||||
}
|
||||
|
||||
/** Compte des entités qui seront supprimées en cascade avec la Partie. */
|
||||
public record DeletionImpact(int sessions, int characters, int flags, int progressions) {}
|
||||
|
||||
public Playthrough create(String campaignId, String name, String description) {
|
||||
if (campaignId == null || campaignId.isBlank()) {
|
||||
throw new IllegalArgumentException("campaignId requis.");
|
||||
}
|
||||
if (!campaignRepository.existsById(campaignId)) {
|
||||
throw new IllegalArgumentException("Campagne introuvable : " + campaignId);
|
||||
}
|
||||
Playthrough p = Playthrough.builder()
|
||||
.campaignId(campaignId)
|
||||
.name((name == null || name.isBlank()) ? "Partie principale" : name.trim())
|
||||
.description(description)
|
||||
.build();
|
||||
return playthroughRepository.save(p);
|
||||
}
|
||||
|
||||
public Playthrough update(String id, Playthrough updated) {
|
||||
Playthrough existing = playthroughRepository.findById(id)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Partie introuvable : " + id));
|
||||
BeanUtils.copyProperties(updated, existing, "id", "campaignId", "createdAt");
|
||||
return playthroughRepository.save(existing);
|
||||
}
|
||||
|
||||
public Optional<Playthrough> getById(String id) {
|
||||
return playthroughRepository.findById(id);
|
||||
}
|
||||
|
||||
public List<Playthrough> getByCampaignId(String campaignId) {
|
||||
return playthroughRepository.findByCampaignId(campaignId);
|
||||
}
|
||||
|
||||
public DeletionImpact getDeletionImpact(String id) {
|
||||
int sessions = sessionRepository.findByPlaythroughId(id).size();
|
||||
int characters = characterRepository.findByPlaythroughId(id).size();
|
||||
int flags = flagRepository.findByPlaythroughId(id).size();
|
||||
int progressions = progressionRepository.findByPlaythroughId(id).size();
|
||||
return new DeletionImpact(sessions, characters, flags, progressions);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void delete(String id) {
|
||||
if (!playthroughRepository.existsById(id)) {
|
||||
throw new IllegalArgumentException("Partie introuvable : " + id);
|
||||
}
|
||||
// Cascade : sessions (et leurs entries), PJ, flags, progressions
|
||||
for (Session s : sessionRepository.findByPlaythroughId(id)) {
|
||||
sessionService.deleteSession(s.getId());
|
||||
}
|
||||
characterRepository.findByPlaythroughId(id).forEach(c -> characterRepository.deleteById(c.getId()));
|
||||
flagRepository.deleteAllByPlaythroughId(id);
|
||||
progressionRepository.deleteAllByPlaythroughId(id);
|
||||
playthroughRepository.deleteById(id);
|
||||
}
|
||||
|
||||
public boolean exists(String id) {
|
||||
return playthroughRepository.existsById(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package com.loremind.application.playcontext;
|
||||
|
||||
import com.loremind.domain.playcontext.EntryType;
|
||||
import com.loremind.domain.playcontext.SessionEntry;
|
||||
import com.loremind.domain.playcontext.ports.SessionEntryRepository;
|
||||
import com.loremind.domain.playcontext.ports.SessionRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Service d'application pour le journal d'une Session.
|
||||
* Gère le cycle CRUD des entrées (note, évènement, jet, action joueur).
|
||||
*/
|
||||
@Service
|
||||
public class SessionEntryService {
|
||||
|
||||
private final SessionEntryRepository entryRepository;
|
||||
private final SessionRepository sessionRepository;
|
||||
|
||||
public SessionEntryService(SessionEntryRepository entryRepository,
|
||||
SessionRepository sessionRepository) {
|
||||
this.entryRepository = entryRepository;
|
||||
this.sessionRepository = sessionRepository;
|
||||
}
|
||||
|
||||
/** Données fournies par l'API pour créer ou éditer une entrée. */
|
||||
public record EntryData(EntryType type, String content, LocalDateTime occurredAt) {}
|
||||
|
||||
public SessionEntry createEntry(String sessionId, EntryData data) {
|
||||
if (sessionId == null || sessionId.isBlank()) {
|
||||
throw new IllegalArgumentException("sessionId est requis.");
|
||||
}
|
||||
if (!sessionRepository.existsById(sessionId)) {
|
||||
throw new IllegalArgumentException("Session introuvable : " + sessionId);
|
||||
}
|
||||
validateContent(data.content());
|
||||
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
SessionEntry entry = SessionEntry.builder()
|
||||
.sessionId(sessionId)
|
||||
.type(data.type() != null ? data.type() : EntryType.NOTE)
|
||||
.content(data.content().trim())
|
||||
.occurredAt(data.occurredAt() != null ? data.occurredAt() : now)
|
||||
.build();
|
||||
return entryRepository.save(entry);
|
||||
}
|
||||
|
||||
public SessionEntry updateEntry(String id, EntryData data) {
|
||||
validateContent(data.content());
|
||||
SessionEntry existing = entryRepository.findById(id)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Entrée introuvable : " + id));
|
||||
if (data.type() != null) existing.setType(data.type());
|
||||
existing.setContent(data.content().trim());
|
||||
if (data.occurredAt() != null) existing.setOccurredAt(data.occurredAt());
|
||||
return entryRepository.save(existing);
|
||||
}
|
||||
|
||||
public Optional<SessionEntry> getById(String id) {
|
||||
return entryRepository.findById(id);
|
||||
}
|
||||
|
||||
public List<SessionEntry> getBySessionId(String sessionId) {
|
||||
return entryRepository.findBySessionId(sessionId);
|
||||
}
|
||||
|
||||
public void deleteEntry(String id) {
|
||||
if (!entryRepository.existsById(id)) {
|
||||
throw new IllegalArgumentException("Entrée introuvable : " + id);
|
||||
}
|
||||
entryRepository.deleteById(id);
|
||||
}
|
||||
|
||||
private void validateContent(String content) {
|
||||
if (content == null || content.isBlank()) {
|
||||
throw new IllegalArgumentException("Le contenu d'une entrée ne peut pas être vide.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package com.loremind.application.playcontext;
|
||||
|
||||
import com.loremind.domain.playcontext.Session;
|
||||
import com.loremind.domain.playcontext.ports.PlaythroughRepository;
|
||||
import com.loremind.domain.playcontext.ports.SessionEntryRepository;
|
||||
import com.loremind.domain.playcontext.ports.SessionRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Service d'application pour le Play Context.
|
||||
* Orchestre le cycle de vie d'une Session (lancement, fin, renommage).
|
||||
*
|
||||
* <p>Règle métier : une seule Session peut être active (endedAt null) à la fois.</p>
|
||||
* <p>Depuis Playthrough : une Session appartient à un Playthrough (pas directement à une Campaign).</p>
|
||||
*/
|
||||
@Service
|
||||
public class SessionService {
|
||||
|
||||
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd");
|
||||
|
||||
private final SessionRepository sessionRepository;
|
||||
private final SessionEntryRepository entryRepository;
|
||||
private final PlaythroughRepository playthroughRepository;
|
||||
|
||||
public SessionService(SessionRepository sessionRepository,
|
||||
SessionEntryRepository entryRepository,
|
||||
PlaythroughRepository playthroughRepository) {
|
||||
this.sessionRepository = sessionRepository;
|
||||
this.entryRepository = entryRepository;
|
||||
this.playthroughRepository = playthroughRepository;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lance une nouvelle session sur le Playthrough donné.
|
||||
* Échoue si une session est déjà active ou si le Playthrough n'existe pas.
|
||||
*/
|
||||
public Session startSession(String playthroughId) {
|
||||
if (playthroughId == null || playthroughId.isBlank()) {
|
||||
throw new IllegalArgumentException("playthroughId est requis pour démarrer une session.");
|
||||
}
|
||||
if (!playthroughRepository.existsById(playthroughId)) {
|
||||
throw new IllegalArgumentException("Partie introuvable : " + playthroughId);
|
||||
}
|
||||
// Règle métier : une seule session active par Partie (pas de verrou global cross-Partie).
|
||||
sessionRepository.findActiveByPlaythroughId(playthroughId).ifPresent(s -> {
|
||||
throw new IllegalStateException(
|
||||
"Une session est déjà en cours pour cette Partie (id=" + s.getId() +
|
||||
"). Termine-la avant d'en lancer une nouvelle.");
|
||||
});
|
||||
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
Session session = Session.builder()
|
||||
.name(generateDefaultName(now))
|
||||
.playthroughId(playthroughId)
|
||||
.startedAt(now)
|
||||
.build();
|
||||
return sessionRepository.save(session);
|
||||
}
|
||||
|
||||
public Session endSession(String id) {
|
||||
Session session = sessionRepository.findById(id)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Session introuvable : " + id));
|
||||
if (!session.isActive()) {
|
||||
throw new IllegalStateException("Cette session est déjà terminée.");
|
||||
}
|
||||
session.setEndedAt(LocalDateTime.now());
|
||||
return sessionRepository.save(session);
|
||||
}
|
||||
|
||||
public Session renameSession(String id, String newName) {
|
||||
if (newName == null || newName.isBlank()) {
|
||||
throw new IllegalArgumentException("Le nom de la session ne peut pas être vide.");
|
||||
}
|
||||
Session session = sessionRepository.findById(id)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Session introuvable : " + id));
|
||||
session.setName(newName.trim());
|
||||
return sessionRepository.save(session);
|
||||
}
|
||||
|
||||
public Optional<Session> getById(String id) {
|
||||
return sessionRepository.findById(id);
|
||||
}
|
||||
|
||||
public Optional<Session> getActive() {
|
||||
return sessionRepository.findActive();
|
||||
}
|
||||
|
||||
public Optional<Session> getActiveByPlaythrough(String playthroughId) {
|
||||
return sessionRepository.findActiveByPlaythroughId(playthroughId);
|
||||
}
|
||||
|
||||
public List<Session> getAll() {
|
||||
return sessionRepository.findAll();
|
||||
}
|
||||
|
||||
public List<Session> getByPlaythroughId(String playthroughId) {
|
||||
return sessionRepository.findByPlaythroughId(playthroughId);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void deleteSession(String id) {
|
||||
if (!sessionRepository.existsById(id)) {
|
||||
throw new IllegalArgumentException("Session introuvable : " + id);
|
||||
}
|
||||
entryRepository.deleteBySessionId(id);
|
||||
sessionRepository.deleteById(id);
|
||||
}
|
||||
|
||||
private String generateDefaultName(LocalDateTime startedAt) {
|
||||
return "Session du " + startedAt.format(DATE_FORMATTER);
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,14 @@ public class Arc {
|
||||
private String campaignId; // Référence vers la Campaign parente
|
||||
private int order; // Ordre de l'arc dans la campagne
|
||||
|
||||
/**
|
||||
* Type structurel de l'arc. Détermine son rendu UI et la sémantique de ses chapitres
|
||||
* (séquence narrative LINEAR vs. quêtes parallèles d'un HUB).
|
||||
* Défaut LINEAR pour rétro-compatibilité avec les arcs existants.
|
||||
*/
|
||||
@Builder.Default
|
||||
private ArcType type = ArcType.LINEAR;
|
||||
|
||||
/** Cle d'icone choisie par l'utilisateur (cf. CAMPAIGN_ICON_OPTIONS cote front). */
|
||||
private String icon;
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.loremind.domain.campaigncontext;
|
||||
|
||||
/**
|
||||
* Type structurel d'un Arc.
|
||||
* - LINEAR : narration séquentielle classique (chapitres joués dans l'ordre).
|
||||
* - HUB : narration non linéaire ; les chapitres sont des "quêtes" satellites
|
||||
* potentiellement parallèles, soumises à des prérequis pour être débloquées.
|
||||
*
|
||||
* Value Object du domaine (Bounded Context : Campaign).
|
||||
*/
|
||||
public enum ArcType {
|
||||
LINEAR,
|
||||
HUB
|
||||
}
|
||||
@@ -6,8 +6,12 @@ import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* Entité de domaine représentant une Campaign.
|
||||
* Conteneur global pour organiser la narration d'une campagne.
|
||||
* Entité pure du domaine, sans dépendance technique.
|
||||
* Conteneur du SCÉNARIO (générique, ré-utilisable par plusieurs tables).
|
||||
*
|
||||
* <p>Toute donnée dynamique propre à une table jouée (progression des quêtes,
|
||||
* flags narratifs, sessions, PJ) vit dans un {@link com.loremind.domain.playcontext.Playthrough}.</p>
|
||||
*
|
||||
* <p>Entité pure du domaine, sans dépendance technique.</p>
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@@ -21,17 +25,13 @@ public class Campaign {
|
||||
private int arcsCount;
|
||||
|
||||
/**
|
||||
* Référence faible (weak reference) vers un Lore.
|
||||
* Nullable : une campagne peut exister sans univers associé (one-shot, test, pitch libre).
|
||||
* Ce n'est qu'un ID : le Campaign Context ne dépend PAS du Lore Context
|
||||
* (respect des Bounded Contexts en DDD).
|
||||
* Référence faible vers un Lore. Nullable.
|
||||
* Ce n'est qu'un ID : le Campaign Context ne dépend PAS du Lore Context.
|
||||
*/
|
||||
private String loreId;
|
||||
|
||||
/**
|
||||
* Référence faible (weak reference) vers un GameSystem.
|
||||
* Nullable : une campagne peut être "générique" (pas de système de JDR déclaré).
|
||||
* Weak reference pour respecter la séparation des Bounded Contexts.
|
||||
* Référence faible vers un GameSystem. Nullable.
|
||||
*/
|
||||
private String gameSystemId;
|
||||
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.loremind.domain.campaigncontext;
|
||||
|
||||
/**
|
||||
* Évènement d'avancement émis pendant l'import streamé d'un PDF de campagne.
|
||||
* <p>
|
||||
* {@code total} = nombre de morceaux à traiter (0 pendant l'extraction).
|
||||
* {@code current} = morceaux traités. Les compteurs arc/chapitre/scène donnent
|
||||
* un aperçu de l'arbre trouvé jusqu'ici (affichage « au fil de l'eau »).
|
||||
*/
|
||||
public record CampaignImportProgress(
|
||||
int current,
|
||||
int total,
|
||||
int pageCount,
|
||||
int ocrPageCount,
|
||||
int arcCount,
|
||||
int chapterCount,
|
||||
int sceneCount) {
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.loremind.domain.campaigncontext;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Proposition d'arborescence narrative extraite d'un PDF de campagne.
|
||||
* <p>
|
||||
* PROPOSITION non persistée : l'UI laisse l'utilisateur réviser/éditer l'arbre
|
||||
* avant la création effective des arcs/chapitres/scènes. Records purs (domaine).
|
||||
*/
|
||||
public record CampaignImportProposal(List<ArcProposal> arcs) {
|
||||
|
||||
/**
|
||||
* {@code existingId} (nullable) : si présent, le nœud existe DÉJÀ dans la
|
||||
* campagne (rempli côté UI lors de la revue pré-chargée) → l'apply ne le
|
||||
* recrée pas, il l'utilise comme parent des nouveaux enfants. Null = à créer.
|
||||
*/
|
||||
|
||||
/** {@code type} = "LINEAR" ou "HUB" (mappé sur {@link ArcType} à l'apply). */
|
||||
public record ArcProposal(
|
||||
String name, String description, String type,
|
||||
List<ChapterProposal> chapters, String existingId) {
|
||||
}
|
||||
|
||||
public record ChapterProposal(
|
||||
String name, String description, List<SceneProposal> scenes, String existingId) {
|
||||
}
|
||||
|
||||
/**
|
||||
* {@code rooms} non vide => lieu explorable (donjon). {@code playerNarration}
|
||||
* = encadré « à lire aux joueurs », {@code gmNotes} = secrets/développement MJ.
|
||||
*/
|
||||
public record SceneProposal(
|
||||
String name, String description, String playerNarration, String gmNotes,
|
||||
List<RoomProposal> rooms, String existingId) {
|
||||
}
|
||||
|
||||
public record RoomProposal(String name, String description, String enemies, String loot) {
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,14 @@ public class Chapter {
|
||||
private String arcId; // Référence vers l'Arc parent
|
||||
private int order; // Ordre du chapitre dans l'arc
|
||||
|
||||
/**
|
||||
* Conditions de déblocage (combinées en ET). Vide => quête immédiatement AVAILABLE.
|
||||
* Pertinent surtout pour les chapitres d'un Arc HUB ; ignoré pour LINEAR.
|
||||
* Donnée de SCÉNARIO — partagée par toutes les Parties de la campagne.
|
||||
*/
|
||||
@Builder.Default
|
||||
private List<Prerequisite> prerequisites = new ArrayList<>();
|
||||
|
||||
/** Cle d'icone choisie par l'utilisateur (cf. CAMPAIGN_ICON_OPTIONS cote front). */
|
||||
private String icon;
|
||||
|
||||
|
||||
@@ -59,10 +59,14 @@ public class Character {
|
||||
*/
|
||||
private Map<String, Map<String, String>> keyValueValues;
|
||||
|
||||
/** Référence vers la Campaign parente. */
|
||||
private String campaignId;
|
||||
/**
|
||||
* Référence vers le Playthrough (= la partie / table) auquel ce PJ appartient.
|
||||
* Les PJ sont propres à une table jouée, pas au scénario générique de la campagne.
|
||||
* Weak reference cross-context.
|
||||
*/
|
||||
private String playthroughId;
|
||||
|
||||
/** Ordre d'affichage dans la liste des PJ de la campagne. */
|
||||
/** Ordre d'affichage dans la liste des PJ de la Partie. */
|
||||
private int order;
|
||||
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.loremind.domain.campaigncontext;
|
||||
|
||||
/**
|
||||
* Condition de déblocage d'une quête (Chapter dans un Arc HUB).
|
||||
*
|
||||
* Sealed : la liste des types est CLOSE et connue à la compilation. Pour ajouter
|
||||
* un nouveau type (ex : NpcMet), il faudra l'ajouter ici ET dans
|
||||
* {@link PrerequisiteEvaluator}.
|
||||
*
|
||||
* Sémantique MVP : une quête a une LISTE de prérequis, tous combinés en ET logique
|
||||
* (pas de OR pour le moment).
|
||||
*/
|
||||
public sealed interface Prerequisite
|
||||
permits Prerequisite.QuestCompleted,
|
||||
Prerequisite.SessionReached,
|
||||
Prerequisite.FlagSet {
|
||||
|
||||
/** La quête référencée par {@code questId} doit être en COMPLETED. */
|
||||
record QuestCompleted(String questId) implements Prerequisite {}
|
||||
|
||||
/** Le compteur de sessions de la campagne doit avoir atteint {@code minSessionNumber}. */
|
||||
record SessionReached(int minSessionNumber) implements Prerequisite {}
|
||||
|
||||
/** Le flag campagne nommé {@code flagName} doit être à true. */
|
||||
record FlagSet(String flagName) implements Prerequisite {}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package com.loremind.domain.campaigncontext;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Service de domaine (pur, sans effet de bord) : évalue les prérequis d'une quête
|
||||
* et en dérive le {@link QuestStatus} effectif.
|
||||
*
|
||||
* NB Java 17 : on utilise instanceof pattern matching (finalisé en Java 16) plutôt que
|
||||
* switch pattern matching (preview en 17, final en 21). La perte de l'exhaustivité
|
||||
* compile-time est compensée par le throw final qui fait crasher tout nouvel
|
||||
* implémentant non câblé.
|
||||
*/
|
||||
public final class PrerequisiteEvaluator {
|
||||
|
||||
/**
|
||||
* Contexte minimal nécessaire à l'évaluation. On ne passe pas la Campaign entière
|
||||
* pour ne pas créer de couplage fort ; juste les faits nécessaires.
|
||||
*/
|
||||
public record EvaluationContext(
|
||||
Set<String> completedQuestIds,
|
||||
int currentSessionCount,
|
||||
Map<String, Boolean> campaignFlags
|
||||
) {}
|
||||
|
||||
/** True si TOUS les prérequis sont satisfaits (ET logique). Vide => true. */
|
||||
public boolean areAllSatisfied(List<Prerequisite> prerequisites, EvaluationContext ctx) {
|
||||
if (prerequisites == null || prerequisites.isEmpty()) return true;
|
||||
return prerequisites.stream().allMatch(p -> isSatisfied(p, ctx));
|
||||
}
|
||||
|
||||
/** Évalue un seul prérequis. */
|
||||
public boolean isSatisfied(Prerequisite prereq, EvaluationContext ctx) {
|
||||
if (prereq instanceof Prerequisite.QuestCompleted q) {
|
||||
return ctx.completedQuestIds().contains(q.questId());
|
||||
}
|
||||
if (prereq instanceof Prerequisite.SessionReached s) {
|
||||
return ctx.currentSessionCount() >= s.minSessionNumber();
|
||||
}
|
||||
if (prereq instanceof Prerequisite.FlagSet f) {
|
||||
return Boolean.TRUE.equals(ctx.campaignFlags().get(f.flagName()));
|
||||
}
|
||||
throw new IllegalStateException("Prerequisite non géré : " + prereq.getClass().getName());
|
||||
}
|
||||
|
||||
/** Dérive le statut effectif à partir de la progression manuelle + des prérequis. */
|
||||
public QuestStatus computeStatus(
|
||||
ProgressionStatus progression,
|
||||
List<Prerequisite> prerequisites,
|
||||
EvaluationContext ctx
|
||||
) {
|
||||
switch (progression) {
|
||||
case COMPLETED: return QuestStatus.COMPLETED;
|
||||
case IN_PROGRESS: return QuestStatus.IN_PROGRESS;
|
||||
case NOT_STARTED:
|
||||
return areAllSatisfied(prerequisites, ctx)
|
||||
? QuestStatus.AVAILABLE
|
||||
: QuestStatus.LOCKED;
|
||||
default:
|
||||
throw new IllegalStateException("ProgressionStatus non géré : " + progression);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.loremind.domain.campaigncontext;
|
||||
|
||||
/**
|
||||
* Statut de progression d'une quête (= Chapter dans un Arc HUB), piloté manuellement par le MJ.
|
||||
*
|
||||
* NOT_STARTED : pas encore commencée. Peut être visible (AVAILABLE) ou cachée (LOCKED)
|
||||
* selon les prérequis — voir {@link QuestStatus}.
|
||||
* IN_PROGRESS : démarrée par le MJ via le bouton "Démarrer cette quête".
|
||||
* COMPLETED : marquée terminée par le MJ.
|
||||
*
|
||||
* NB : un Chapter d'Arc LINEAR conserve NOT_STARTED par défaut sans impact visible.
|
||||
*/
|
||||
public enum ProgressionStatus {
|
||||
NOT_STARTED,
|
||||
IN_PROGRESS,
|
||||
COMPLETED
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.loremind.domain.campaigncontext;
|
||||
|
||||
/**
|
||||
* Statut effectif d'une quête tel qu'affiché dans la vue Hub.
|
||||
* DÉRIVÉ — jamais persisté. Calculé par {@link PrerequisiteEvaluator} à partir
|
||||
* de la {@link ProgressionStatus} persistée et de l'évaluation des prérequis.
|
||||
*
|
||||
* Table de vérité :
|
||||
* NOT_STARTED + prérequis non remplis -> LOCKED
|
||||
* NOT_STARTED + prérequis remplis -> AVAILABLE
|
||||
* IN_PROGRESS -> IN_PROGRESS
|
||||
* COMPLETED -> COMPLETED
|
||||
*/
|
||||
public enum QuestStatus {
|
||||
LOCKED,
|
||||
AVAILABLE,
|
||||
IN_PROGRESS,
|
||||
COMPLETED
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.loremind.domain.campaigncontext;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Pièce d'un lieu explorable (donjon, crypte…) attaché à une Scene.
|
||||
*
|
||||
* <p>Une Scene devient « explorable » dès qu'elle a au moins une Room. Tant
|
||||
* qu'elle n'en a pas, elle se comporte comme un beat narratif classique.</p>
|
||||
*
|
||||
* <p>Pas un record Java parce que la liste {@code branches} est mutable côté
|
||||
* builder ; on garde la classe Lombok pour la cohérence avec le reste du
|
||||
* domaine (Arc, Chapter, Scene). L'ID est généré côté front (UUID) au moment
|
||||
* de la création — pas d'auto-increment DB puisque c'est un Value Object
|
||||
* sérialisé en JSONB sur Scene.</p>
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class Room {
|
||||
|
||||
/** ID stable (UUID généré côté client). Sert de cible aux {@link RoomBranch}. */
|
||||
private String id;
|
||||
|
||||
/** Nom de la pièce (« Antichambre », « Salle du trône »). */
|
||||
private String name;
|
||||
|
||||
/** Narration / description lue ou résumée aux joueurs en entrant. */
|
||||
private String description;
|
||||
|
||||
/** Énemis, créatures, boss éventuels (markdown libre). */
|
||||
private String enemies;
|
||||
|
||||
/** Loot / récompenses présentes dans la pièce. */
|
||||
private String loot;
|
||||
|
||||
/** Pièges / dangers environnementaux. */
|
||||
private String traps;
|
||||
|
||||
/** Notes privées du MJ (cachées des joueurs). */
|
||||
private String gmNotes;
|
||||
|
||||
/** Étage / niveau de la pièce. 0 = rez-de-chaussée. Nullable = pas d'étage défini. */
|
||||
private Integer floor;
|
||||
|
||||
/** Ordre d'affichage dans la liste (au sein d'un même étage le cas échéant). */
|
||||
private int order;
|
||||
|
||||
/** IDs d'images d'illustration / ambiance. */
|
||||
@Builder.Default
|
||||
private List<String> illustrationImageIds = new ArrayList<>();
|
||||
|
||||
/** ID de l'image « plan » de la pièce (1 image dédiée, schéma tactique). */
|
||||
private String mapImageId;
|
||||
|
||||
/**
|
||||
* Sorties vers d'autres pièces. {@link RoomBranch#targetRoomId()} doit pointer
|
||||
* vers une Room de la même Scene.
|
||||
*/
|
||||
@Builder.Default
|
||||
private List<RoomBranch> branches = new ArrayList<>();
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.loremind.domain.campaigncontext;
|
||||
|
||||
/**
|
||||
* Sortie d'une pièce vers une autre pièce de la même Scene explorable.
|
||||
* Équivalent inter-pièces de {@link SceneBranch}.
|
||||
*
|
||||
* <p>Record Java immuable, sérialisé via Jackson dans la liste JSONB
|
||||
* {@code rooms} de la Scene.</p>
|
||||
*
|
||||
* <p>Règle métier : {@code targetRoomId} doit pointer vers une Room de la
|
||||
* MÊME Scene (validation côté service).</p>
|
||||
*
|
||||
* @param label Libellé visible (« Porte nord », « Trappe au sol »).
|
||||
* @param targetRoomId ID stable de la Room de destination (UUID Room.id).
|
||||
* @param condition Condition optionnelle (« si les PJ ont la clé en argent »).
|
||||
*/
|
||||
public record RoomBranch(String label, String targetRoomId, String condition) {
|
||||
|
||||
public static RoomBranch of(String label, String targetRoomId) {
|
||||
return new RoomBranch(label, targetRoomId, null);
|
||||
}
|
||||
}
|
||||
@@ -72,6 +72,15 @@ public class Scene {
|
||||
@Builder.Default
|
||||
private List<SceneBranch> branches = new ArrayList<>();
|
||||
|
||||
/**
|
||||
* Pièces du lieu explorable représenté par cette scène (donjon, crypte, manoir…).
|
||||
* Vide => scène classique « beat narratif » (comportement inchangé).
|
||||
* Non vide => la scène devient explorable, l'UI affiche un layout dédié pièce-par-pièce.
|
||||
* Sérialisé en JSONB.
|
||||
*/
|
||||
@Builder.Default
|
||||
private List<Room> rooms = new ArrayList<>();
|
||||
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.loremind.domain.campaigncontext.ports;
|
||||
|
||||
/**
|
||||
* Erreur de domaine : l'import d'un PDF de campagne a échoué (PDF illisible,
|
||||
* Brain injoignable, LLM en erreur...).
|
||||
*/
|
||||
public class CampaignImportException extends RuntimeException {
|
||||
|
||||
public CampaignImportException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public CampaignImportException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.loremind.domain.campaigncontext.ports;
|
||||
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
* Port de sortie : produit des CONSEILS d'adaptation d'un PDF à une campagne
|
||||
* existante, streamés token par token. Délègue au Brain (LLM + extraction PDF).
|
||||
* <p>
|
||||
* Contrairement à {@link CampaignPdfImporter} (qui structure pour créer), ici la
|
||||
* sortie est du texte libre (markdown) : l'utilisateur applique à la main.
|
||||
*/
|
||||
public interface CampaignPdfAdvisor {
|
||||
|
||||
/**
|
||||
* @param pdfBytes contenu du PDF à adapter.
|
||||
* @param filename nom d'origine (diagnostic ; peut être null).
|
||||
* @param brief description de la campagne existante (structure + PNJ + lore).
|
||||
* @param messagesJson JSON de l'échange conversationnel ([{role, content}, …]) ;
|
||||
* "[]" au 1er tour. Permet à l'utilisateur de répondre/corriger.
|
||||
* @param onToken invoqué à chaque fragment de texte généré.
|
||||
* @param onComplete invoqué à la fin normale du flux.
|
||||
* @param onError invoqué en cas d'échec (PDF illisible, Brain/LLM en erreur).
|
||||
*/
|
||||
void adviseStreaming(
|
||||
byte[] pdfBytes,
|
||||
String filename,
|
||||
String brief,
|
||||
String messagesJson,
|
||||
Consumer<String> onToken,
|
||||
Runnable onComplete,
|
||||
Consumer<Throwable> onError);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.loremind.domain.campaigncontext.ports;
|
||||
|
||||
import com.loremind.domain.campaigncontext.CampaignImportProgress;
|
||||
import com.loremind.domain.campaigncontext.CampaignImportProposal;
|
||||
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
* Port de sortie : extrait et structure un PDF de campagne en arbre
|
||||
* arc → chapitre → scène. L'implémentation délègue au Brain Python.
|
||||
*/
|
||||
public interface CampaignPdfImporter {
|
||||
|
||||
/**
|
||||
* Variante streamée : l'import peut durer plusieurs minutes, on remonte
|
||||
* l'avancement au fil de l'eau, puis la proposition finale.
|
||||
*
|
||||
* @param onProgress invoqué à chaque étape (extraction, puis par morceau).
|
||||
* @param onDone invoqué une fois avec l'arbre proposé (non persisté).
|
||||
* @param onError invoqué si l'extraction/structuration échoue.
|
||||
*/
|
||||
void importCampaignStreaming(
|
||||
byte[] pdfBytes,
|
||||
String filename,
|
||||
Consumer<CampaignImportProgress> onProgress,
|
||||
Consumer<CampaignImportProposal> onDone,
|
||||
Consumer<Throwable> onError);
|
||||
}
|
||||
@@ -14,7 +14,7 @@ public interface CharacterRepository {
|
||||
|
||||
Optional<Character> findById(String id);
|
||||
|
||||
List<Character> findByCampaignId(String campaignId);
|
||||
List<Character> findByPlaythroughId(String playthroughId);
|
||||
|
||||
void deleteById(String id);
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.loremind.domain.gamesystemcontext;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Évènement d'avancement émis pendant l'import streamé d'un PDF de règles.
|
||||
* <p>
|
||||
* {@code total} = nombre de morceaux à traiter (0 tant que l'extraction n'est
|
||||
* pas finie). {@code current} = morceaux déjà traités. {@code newSectionTitles}
|
||||
* = titres de sections nouvellement trouvés/complétés par le dernier morceau
|
||||
* (pour un affichage « au fil de l'eau »).
|
||||
*/
|
||||
public record RulesImportProgress(
|
||||
int current,
|
||||
int total,
|
||||
int pageCount,
|
||||
int ocrPageCount,
|
||||
List<String> newSectionTitles) {
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
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) {
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
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);
|
||||
}
|
||||
@@ -69,12 +69,13 @@ public record CampaignStructuralContext(
|
||||
List<SceneSummary> scenes) {
|
||||
}
|
||||
|
||||
/** Résumé d'une scène : nom + description courte + branches narratives. */
|
||||
/** Résumé d'une scène : nom + description courte + branches + pièces explorables. */
|
||||
public record SceneSummary(
|
||||
String name,
|
||||
String description,
|
||||
int illustrationCount,
|
||||
List<BranchHint> branches) {
|
||||
List<BranchHint> branches,
|
||||
List<RoomSummary> rooms) {
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -86,4 +87,27 @@ public record CampaignStructuralContext(
|
||||
*/
|
||||
public record BranchHint(String label, String targetSceneName, String condition) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Pièce d'un lieu explorable (donjon, crypte). Projection volontairement plate
|
||||
* pour le prompt IA : pas de notes MJ (jamais leakées dans le contexte campagne),
|
||||
* la narration et les ennemis suffisent à camper la pièce.
|
||||
*
|
||||
* @param name Nom de la pièce.
|
||||
* @param floor Étage (nullable).
|
||||
* @param description Narration courte.
|
||||
* @param enemies Ennemis (texte libre).
|
||||
* @param branches Sorties vers d'autres pièces (noms résolus).
|
||||
*/
|
||||
public record RoomSummary(
|
||||
String name,
|
||||
Integer floor,
|
||||
String description,
|
||||
String enemies,
|
||||
List<RoomBranchHint> branches) {
|
||||
}
|
||||
|
||||
/** Indice d'une sortie entre pièces ; {@code targetRoomName} déjà résolu. */
|
||||
public record RoomBranchHint(String label, String targetRoomName, String condition) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,7 +36,8 @@ public record ChatRequest(
|
||||
PageContext pageContext,
|
||||
CampaignStructuralContext campaignContext,
|
||||
NarrativeEntityContext narrativeEntity,
|
||||
GameSystemContext gameSystemContext) {
|
||||
GameSystemContext gameSystemContext,
|
||||
SessionContext sessionContext) {
|
||||
|
||||
public static Builder builder() {
|
||||
return new Builder();
|
||||
@@ -50,6 +51,7 @@ public record ChatRequest(
|
||||
private CampaignStructuralContext campaignContext;
|
||||
private NarrativeEntityContext narrativeEntity;
|
||||
private GameSystemContext gameSystemContext;
|
||||
private SessionContext sessionContext;
|
||||
|
||||
private Builder() {}
|
||||
|
||||
@@ -83,9 +85,14 @@ public record ChatRequest(
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder sessionContext(SessionContext sessionContext) {
|
||||
this.sessionContext = sessionContext;
|
||||
return this;
|
||||
}
|
||||
|
||||
public ChatRequest build() {
|
||||
return new ChatRequest(messages, loreContext, pageContext,
|
||||
campaignContext, narrativeEntity, gameSystemContext);
|
||||
campaignContext, narrativeEntity, gameSystemContext, sessionContext);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package com.loremind.domain.generationcontext;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Contexte structurel d'une Session de jeu — injecté dans le system prompt
|
||||
* de l'IA pour qu'elle ait conscience de la partie en cours et de son journal.
|
||||
*
|
||||
* <p>Pendant qu'une session se joue, l'IA reçoit en plus du Lore/Campagne/GameSystem :
|
||||
* le nom de la session, son statut, un résumé chronologique du journal,
|
||||
* et — depuis l'ajout du mode Hub — l'état des quêtes ouvertes de la campagne et
|
||||
* les flags actifs.</p>
|
||||
*
|
||||
* <p>Value Object du Generation Context — record Java immutable.</p>
|
||||
*
|
||||
* @param sessionName Nom de la session courante telle qu'affichée au MJ.
|
||||
* @param active True si la session est en cours, false si terminée.
|
||||
* @param startedAt Horodatage de démarrage de la session courante.
|
||||
* @param entries Entrées du journal de la session courante (cap côté builder).
|
||||
* @param previousEvents Évènements marquants des sessions précédentes (continuité narrative).
|
||||
* @param availableQuests Quêtes Hub actuellement débloquées et non démarrées.
|
||||
* @param inProgressQuests Quêtes Hub en cours.
|
||||
* @param lockedQuestTitles Titres des quêtes Hub verrouillées — uniquement le titre
|
||||
* pour signaler leur existence sans spoiler.
|
||||
* @param activeFlags Noms des flags de campagne à true.
|
||||
*/
|
||||
public record SessionContext(
|
||||
String sessionName,
|
||||
boolean active,
|
||||
LocalDateTime startedAt,
|
||||
List<JournalEntrySummary> entries,
|
||||
List<JournalEntrySummary> previousEvents,
|
||||
List<QuestSummary> availableQuests,
|
||||
List<QuestSummary> inProgressQuests,
|
||||
List<String> lockedQuestTitles,
|
||||
List<String> activeFlags) {
|
||||
|
||||
/**
|
||||
* Résumé d'une entrée de journal — type + contenu + horodatage + (optionnel) session source.
|
||||
* {@code sourceSessionName} renseigné uniquement pour les évènements issus de sessions
|
||||
* précédentes, pour aider l'IA à les ancrer temporellement.
|
||||
*/
|
||||
public record JournalEntrySummary(
|
||||
String type,
|
||||
String content,
|
||||
LocalDateTime occurredAt,
|
||||
String sourceSessionName) {}
|
||||
|
||||
/**
|
||||
* Résumé d'une quête (= Chapter dans un Arc HUB) telle qu'exposée à l'IA.
|
||||
* On omet volontairement les notes MJ : pas de fuite côté prompt.
|
||||
*/
|
||||
public record QuestSummary(
|
||||
String name,
|
||||
String arcName,
|
||||
String description) {}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.loremind.domain.playcontext;
|
||||
|
||||
/**
|
||||
* Type d'entrée du journal de session.
|
||||
* Permet à l'UI de catégoriser visuellement la timeline (icône, couleur).
|
||||
*/
|
||||
public enum EntryType {
|
||||
/** Note libre du MJ (défaut). */
|
||||
NOTE,
|
||||
/** Moment marquant du scénario (combat gagné, décision majeure...). */
|
||||
EVENT,
|
||||
/** Jet de dés / test de caractéristique. */
|
||||
DICE_ROLL,
|
||||
/** Action déclarée par un joueur. */
|
||||
PLAYER_ACTION
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.loremind.domain.playcontext;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* Instance jouée d'une Campagne par une table donnée.
|
||||
*
|
||||
* <p>Sépare clairement le SCÉNARIO (Campaign : arcs, chapitres, prérequis) de
|
||||
* l'ÉTAT DE JEU d'une table précise (progression des quêtes, flags narratifs,
|
||||
* sessions tenues, PJ). Permet à plusieurs tables de jouer la même campagne
|
||||
* indépendamment.</p>
|
||||
*
|
||||
* <p>Fait partie du Play Context. Référence la Campagne par weak reference
|
||||
* (campaignId) pour respecter les Bounded Contexts.</p>
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
public class Playthrough {
|
||||
|
||||
private String id;
|
||||
|
||||
/** Weak reference vers la Campagne (le scénario joué). */
|
||||
private String campaignId;
|
||||
|
||||
/** Nom donné par le MJ à cette partie (ex. : "Table du vendredi"). */
|
||||
private String name;
|
||||
|
||||
/** Notes libres sur la partie / la table — facultatif. */
|
||||
private String description;
|
||||
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.loremind.domain.playcontext;
|
||||
|
||||
import com.loremind.domain.campaigncontext.ProgressionStatus;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* État de progression d'une quête (Chapter) pour un Playthrough donné.
|
||||
*
|
||||
* <p>Remplace l'ancien champ {@code Chapter.progressionStatus} qui mélangeait
|
||||
* le scénario et l'état de jeu : ici, la progression est exclusivement
|
||||
* propre à une instance jouée (Playthrough).</p>
|
||||
*
|
||||
* <p>Référence le Chapter par weak reference (chapterId) pour respecter les
|
||||
* Bounded Contexts. Le type {@link ProgressionStatus} reste défini dans
|
||||
* Campaign Context (c'est un Value Object générique, partageable).</p>
|
||||
*
|
||||
* <p>Sémantique : l'absence de ligne dans le repo équivaut à NOT_STARTED.
|
||||
* On ne persiste donc que les transitions explicites IN_PROGRESS / COMPLETED.</p>
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
public class QuestProgression {
|
||||
|
||||
private String id;
|
||||
private String playthroughId;
|
||||
private String chapterId;
|
||||
private ProgressionStatus status;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.loremind.domain.playcontext;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* Entité de domaine représentant une Session de jeu (une soirée).
|
||||
*
|
||||
* <p>Une Session appartient à un {@link Playthrough} (une instance jouée d'une
|
||||
* campagne par une table). Un Playthrough a typiquement plusieurs sessions
|
||||
* dans le temps ; la progression et les flags persistent entre elles via le
|
||||
* Playthrough parent.</p>
|
||||
*
|
||||
* <p>{@code endedAt == null} signifie que la session est en cours.
|
||||
* Une seule session peut être en cours dans l'application à la fois.</p>
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
public class Session {
|
||||
|
||||
private String id;
|
||||
private String name;
|
||||
|
||||
/** Weak reference vers le Playthrough parent. */
|
||||
private String playthroughId;
|
||||
|
||||
private LocalDateTime startedAt;
|
||||
|
||||
/** Null = session en cours ; renseigné = session terminée. */
|
||||
private LocalDateTime endedAt;
|
||||
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
public boolean isActive() {
|
||||
return this.endedAt == null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.loremind.domain.playcontext;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* Entrée du journal d'une Session.
|
||||
* Représente un évènement horodaté capturé pendant ou après une partie :
|
||||
* note libre du MJ, évènement marquant, jet de dés, action de joueur.
|
||||
*
|
||||
* <p>Fait partie du Play Context. Référence la Session par weak reference
|
||||
* (sessionId) — l'orchestration en cascade est gérée par le service applicatif.</p>
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
public class SessionEntry {
|
||||
|
||||
private String id;
|
||||
|
||||
/** Weak reference vers Session (intra-contexte mais reste découplée). */
|
||||
private String sessionId;
|
||||
|
||||
private EntryType type;
|
||||
|
||||
/** Contenu texte brut saisi par le MJ. */
|
||||
private String content;
|
||||
|
||||
/**
|
||||
* Horodatage métier de l'évènement.
|
||||
* Distinct de {@code createdAt} : utile si le MJ rédige a posteriori
|
||||
* une note rétroactive sur quelque chose qui s'est passé plus tôt.
|
||||
*/
|
||||
private LocalDateTime occurredAt;
|
||||
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.loremind.domain.playcontext.ports;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Port de sortie pour les flags narratifs d'un Playthrough.
|
||||
*
|
||||
* <p>Anciennement {@code CampaignFlagRepository} : les flags suivent maintenant
|
||||
* une partie (Playthrough), pas un scénario (Campaign).</p>
|
||||
*/
|
||||
public interface PlaythroughFlagRepository {
|
||||
|
||||
Map<String, Boolean> findByPlaythroughId(String playthroughId);
|
||||
|
||||
void setFlag(String playthroughId, String name, boolean value);
|
||||
|
||||
void deleteFlag(String playthroughId, String name);
|
||||
|
||||
void deleteAllByPlaythroughId(String playthroughId);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.loremind.domain.playcontext.ports;
|
||||
|
||||
import com.loremind.domain.playcontext.Playthrough;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Port de sortie pour la persistance des Playthroughs (parties jouées).
|
||||
*/
|
||||
public interface PlaythroughRepository {
|
||||
|
||||
Playthrough save(Playthrough playthrough);
|
||||
|
||||
Optional<Playthrough> findById(String id);
|
||||
|
||||
List<Playthrough> findByCampaignId(String campaignId);
|
||||
|
||||
List<Playthrough> findAll();
|
||||
|
||||
void deleteById(String id);
|
||||
|
||||
boolean existsById(String id);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.loremind.domain.playcontext.ports;
|
||||
|
||||
import com.loremind.domain.campaigncontext.ProgressionStatus;
|
||||
import com.loremind.domain.playcontext.QuestProgression;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Port de sortie pour la persistance des progressions de quêtes d'un Playthrough.
|
||||
*
|
||||
* <p>Modèle "absence = NOT_STARTED" : on ne stocke que les transitions
|
||||
* explicites IN_PROGRESS / COMPLETED.</p>
|
||||
*/
|
||||
public interface QuestProgressionRepository {
|
||||
|
||||
/** Liste toutes les progressions explicites d'un Playthrough. */
|
||||
List<QuestProgression> findByPlaythroughId(String playthroughId);
|
||||
|
||||
/** Set des IDs de chapitres en COMPLETED pour un Playthrough donné (fast path éval). */
|
||||
Set<String> findCompletedChapterIdsByPlaythroughId(String playthroughId);
|
||||
|
||||
/**
|
||||
* Crée ou met à jour le statut d'une quête pour un Playthrough.
|
||||
* Si {@code status == NOT_STARTED}, la ligne est supprimée (sémantique "absence").
|
||||
*/
|
||||
void setStatus(String playthroughId, String chapterId, ProgressionStatus status);
|
||||
|
||||
/** Supprime toutes les progressions d'un Playthrough (cascade applicative). */
|
||||
void deleteAllByPlaythroughId(String playthroughId);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.loremind.domain.playcontext.ports;
|
||||
|
||||
import com.loremind.domain.playcontext.SessionEntry;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Port de sortie pour la persistance des entrées de journal de session.
|
||||
*/
|
||||
public interface SessionEntryRepository {
|
||||
|
||||
SessionEntry save(SessionEntry entry);
|
||||
|
||||
Optional<SessionEntry> findById(String id);
|
||||
|
||||
/** Renvoie les entrées d'une session, triées par occurredAt croissant (chronologique). */
|
||||
List<SessionEntry> findBySessionId(String sessionId);
|
||||
|
||||
void deleteById(String id);
|
||||
|
||||
/** Supprime toutes les entrées d'une session — utilisé pour la cascade à la suppression. */
|
||||
void deleteBySessionId(String sessionId);
|
||||
|
||||
boolean existsById(String id);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.loremind.domain.playcontext.ports;
|
||||
|
||||
import com.loremind.domain.playcontext.Session;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Port de sortie pour la persistance des Sessions.
|
||||
*/
|
||||
public interface SessionRepository {
|
||||
|
||||
Session save(Session session);
|
||||
|
||||
Optional<Session> findById(String id);
|
||||
|
||||
List<Session> findAll();
|
||||
|
||||
List<Session> findByPlaythroughId(String playthroughId);
|
||||
|
||||
/** Retourne UNE session active dans l'app (sémantique « legacy » — multi-actives possibles). */
|
||||
Optional<Session> findActive();
|
||||
|
||||
/** Retourne la session active du Playthrough donné, s'il y en a une. */
|
||||
Optional<Session> findActiveByPlaythroughId(String playthroughId);
|
||||
|
||||
void deleteById(String id);
|
||||
|
||||
boolean existsById(String id);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
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]) {
|
||||
onError.accept(new CampaignImportException(
|
||||
"Erreur lors du streaming d'import depuis le Brain.", 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;
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,9 @@ import com.loremind.domain.generationcontext.LoreStructuralContext;
|
||||
import com.loremind.domain.generationcontext.LoreStructuralContext.PageSummary;
|
||||
import com.loremind.domain.generationcontext.NarrativeEntityContext;
|
||||
import com.loremind.domain.generationcontext.PageContext;
|
||||
import com.loremind.domain.generationcontext.SessionContext;
|
||||
import com.loremind.domain.generationcontext.SessionContext.JournalEntrySummary;
|
||||
import com.loremind.domain.generationcontext.SessionContext.QuestSummary;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
@@ -58,9 +61,72 @@ public class BrainChatPayloadBuilder {
|
||||
if (request.gameSystemContext() != null) {
|
||||
root.put("game_system_context", gameSystemContextToMap(request.gameSystemContext()));
|
||||
}
|
||||
if (request.sessionContext() != null) {
|
||||
root.put("session_context", sessionContextToMap(request.sessionContext()));
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
private Map<String, Object> sessionContextToMap(SessionContext sc) {
|
||||
Map<String, Object> map = new LinkedHashMap<>();
|
||||
map.put("session_name", sc.sessionName());
|
||||
map.put("active", sc.active());
|
||||
if (sc.startedAt() != null) {
|
||||
map.put("started_at", sc.startedAt().toString());
|
||||
}
|
||||
map.put("entries", sc.entries() != null
|
||||
? sc.entries().stream().map(this::journalEntryToMap).collect(Collectors.toList())
|
||||
: List.of());
|
||||
// Évènements des sessions précédentes : omis si vide (campagne sur sa 1re session).
|
||||
if (sc.previousEvents() != null && !sc.previousEvents().isEmpty()) {
|
||||
map.put("previous_events", sc.previousEvents().stream()
|
||||
.map(this::journalEntryToMap)
|
||||
.collect(Collectors.toList()));
|
||||
}
|
||||
// État Hub (quêtes / flags). Toutes les listes sont omises si vides pour ne pas
|
||||
// saturer le prompt sur les campagnes sans Hub.
|
||||
if (sc.availableQuests() != null && !sc.availableQuests().isEmpty()) {
|
||||
map.put("available_quests", sc.availableQuests().stream()
|
||||
.map(this::questSummaryToMap)
|
||||
.collect(Collectors.toList()));
|
||||
}
|
||||
if (sc.inProgressQuests() != null && !sc.inProgressQuests().isEmpty()) {
|
||||
map.put("in_progress_quests", sc.inProgressQuests().stream()
|
||||
.map(this::questSummaryToMap)
|
||||
.collect(Collectors.toList()));
|
||||
}
|
||||
if (sc.lockedQuestTitles() != null && !sc.lockedQuestTitles().isEmpty()) {
|
||||
map.put("locked_quest_titles", sc.lockedQuestTitles());
|
||||
}
|
||||
if (sc.activeFlags() != null && !sc.activeFlags().isEmpty()) {
|
||||
map.put("active_flags", sc.activeFlags());
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
private Map<String, Object> questSummaryToMap(QuestSummary q) {
|
||||
Map<String, Object> map = new LinkedHashMap<>();
|
||||
map.put("name", q.name());
|
||||
map.put("arc_name", q.arcName());
|
||||
if (q.description() != null && !q.description().isBlank()) {
|
||||
map.put("description", q.description());
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
private Map<String, Object> journalEntryToMap(JournalEntrySummary e) {
|
||||
Map<String, Object> map = new LinkedHashMap<>();
|
||||
map.put("type", e.type());
|
||||
map.put("content", e.content());
|
||||
if (e.occurredAt() != null) {
|
||||
map.put("occurred_at", e.occurredAt().toString());
|
||||
}
|
||||
if (e.sourceSessionName() != null && !e.sourceSessionName().isBlank()) {
|
||||
map.put("source_session_name", e.sourceSessionName());
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
private Map<String, Object> gameSystemContextToMap(GameSystemContext gs) {
|
||||
Map<String, Object> map = new LinkedHashMap<>();
|
||||
map.put("system_name", gs.systemName());
|
||||
@@ -215,6 +281,12 @@ public class BrainChatPayloadBuilder {
|
||||
.map(this::branchHintToMap)
|
||||
.collect(Collectors.toList()));
|
||||
}
|
||||
// Pièces du lieu explorable : omises si scène classique.
|
||||
if (s.rooms() != null && !s.rooms().isEmpty()) {
|
||||
map.put("rooms", s.rooms().stream()
|
||||
.map(this::roomSummaryToMap)
|
||||
.collect(Collectors.toList()));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -228,6 +300,24 @@ public class BrainChatPayloadBuilder {
|
||||
return map;
|
||||
}
|
||||
|
||||
private Map<String, Object> roomSummaryToMap(com.loremind.domain.generationcontext.CampaignStructuralContext.RoomSummary r) {
|
||||
Map<String, Object> map = new LinkedHashMap<>();
|
||||
map.put("name", r.name());
|
||||
if (r.floor() != null) map.put("floor", r.floor());
|
||||
if (r.description() != null && !r.description().isBlank()) map.put("description", r.description());
|
||||
if (r.enemies() != null && !r.enemies().isBlank()) map.put("enemies", r.enemies());
|
||||
if (r.branches() != null && !r.branches().isEmpty()) {
|
||||
map.put("branches", r.branches().stream().map(b -> {
|
||||
Map<String, Object> bm = new LinkedHashMap<>();
|
||||
bm.put("label", b.label());
|
||||
bm.put("target_room_name", b.targetRoomName());
|
||||
if (b.condition() != null && !b.condition().isBlank()) bm.put("condition", b.condition());
|
||||
return bm;
|
||||
}).collect(Collectors.toList()));
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
private Map<String, Object> narrativeEntityToMap(NarrativeEntityContext ne) {
|
||||
Map<String, Object> map = new LinkedHashMap<>();
|
||||
map.put("entity_type", ne.entityType());
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
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]) {
|
||||
onError.accept(new RulesImportException(
|
||||
"Erreur lors du streaming d'import depuis le Brain.", 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
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;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ 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;
|
||||
@@ -23,6 +24,7 @@ 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,
|
||||
@@ -39,6 +41,29 @@ 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
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
package com.loremind.infrastructure.persistence.converter;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.loremind.domain.campaigncontext.Prerequisite;
|
||||
import jakarta.persistence.AttributeConverter;
|
||||
import jakarta.persistence.Converter;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Convertit une List<Prerequisite> (sealed type du domaine) en JSON stocké en base.
|
||||
* <p>
|
||||
* On utilise un discriminant {@code kind} explicite pour éviter de polluer le domaine
|
||||
* avec des annotations Jackson (@JsonTypeInfo / @JsonSubTypes). Le format on-disk est
|
||||
* stable et indépendant des noms de classes Java.
|
||||
* <p>
|
||||
* Format JSON :
|
||||
* [
|
||||
* {"kind": "QUEST_COMPLETED", "questId": "42"},
|
||||
* {"kind": "SESSION_REACHED", "minSessionNumber": 3},
|
||||
* {"kind": "FLAG_SET", "flagName": "forgeron_rencontre"}
|
||||
* ]
|
||||
*/
|
||||
@Converter
|
||||
public class PrerequisiteListJsonConverter implements AttributeConverter<List<Prerequisite>, String> {
|
||||
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
|
||||
private static final String KIND = "kind";
|
||||
private static final String KIND_QUEST_COMPLETED = "QUEST_COMPLETED";
|
||||
private static final String KIND_SESSION_REACHED = "SESSION_REACHED";
|
||||
private static final String KIND_FLAG_SET = "FLAG_SET";
|
||||
|
||||
@Override
|
||||
public String convertToDatabaseColumn(List<Prerequisite> attribute) {
|
||||
if (attribute == null || attribute.isEmpty()) return "[]";
|
||||
try {
|
||||
List<Map<String, Object>> raw = new ArrayList<>(attribute.size());
|
||||
for (Prerequisite p : attribute) {
|
||||
raw.add(toMap(p));
|
||||
}
|
||||
return MAPPER.writeValueAsString(raw);
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("Erreur sérialisation List<Prerequisite> → JSON", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Prerequisite> convertToEntityAttribute(String dbData) {
|
||||
if (dbData == null || dbData.isBlank()) return new ArrayList<>();
|
||||
try {
|
||||
List<Map<String, Object>> raw = MAPPER.readValue(dbData, new TypeReference<>() {});
|
||||
List<Prerequisite> result = new ArrayList<>(raw.size());
|
||||
for (Map<String, Object> m : raw) {
|
||||
result.add(fromMap(m));
|
||||
}
|
||||
return result;
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("Erreur désérialisation JSON → List<Prerequisite>", e);
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, Object> toMap(Prerequisite p) {
|
||||
Map<String, Object> m = new HashMap<>();
|
||||
if (p instanceof Prerequisite.QuestCompleted q) {
|
||||
m.put(KIND, KIND_QUEST_COMPLETED);
|
||||
m.put("questId", q.questId());
|
||||
} else if (p instanceof Prerequisite.SessionReached s) {
|
||||
m.put(KIND, KIND_SESSION_REACHED);
|
||||
m.put("minSessionNumber", s.minSessionNumber());
|
||||
} else if (p instanceof Prerequisite.FlagSet f) {
|
||||
m.put(KIND, KIND_FLAG_SET);
|
||||
m.put("flagName", f.flagName());
|
||||
} else {
|
||||
throw new IllegalStateException("Prerequisite non géré : " + p.getClass().getName());
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
private Prerequisite fromMap(Map<String, Object> m) {
|
||||
String kind = String.valueOf(m.get(KIND));
|
||||
switch (kind) {
|
||||
case KIND_QUEST_COMPLETED:
|
||||
return new Prerequisite.QuestCompleted(String.valueOf(m.get("questId")));
|
||||
case KIND_SESSION_REACHED:
|
||||
Object n = m.get("minSessionNumber");
|
||||
return new Prerequisite.SessionReached(((Number) n).intValue());
|
||||
case KIND_FLAG_SET:
|
||||
return new Prerequisite.FlagSet(String.valueOf(m.get("flagName")));
|
||||
default:
|
||||
throw new IllegalStateException("Kind Prerequisite inconnu : " + kind);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.loremind.infrastructure.persistence.converter;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.loremind.domain.campaigncontext.Room;
|
||||
import jakarta.persistence.AttributeConverter;
|
||||
import jakarta.persistence.Converter;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Convertit la liste de pièces explorables d'une Scene en JSON pour la persistance.
|
||||
*
|
||||
* <p>Une Room contient elle-même une liste de {@link com.loremind.domain.campaigncontext.RoomBranch}
|
||||
* (record Java) ; Jackson 2.12+ sait sérialiser/désérialiser les records nativement
|
||||
* via le constructeur canonique, donc rien de spécial à faire pour les branches.</p>
|
||||
*/
|
||||
@Converter
|
||||
public class RoomListJsonConverter implements AttributeConverter<List<Room>, String> {
|
||||
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
|
||||
@Override
|
||||
public String convertToDatabaseColumn(List<Room> attribute) {
|
||||
if (attribute == null || attribute.isEmpty()) return "[]";
|
||||
try {
|
||||
return MAPPER.writeValueAsString(attribute);
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("Erreur sérialisation List<Room> → JSON", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Room> convertToEntityAttribute(String dbData) {
|
||||
if (dbData == null || dbData.isBlank()) return Collections.emptyList();
|
||||
try {
|
||||
return MAPPER.readValue(dbData, new TypeReference<>() {});
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("Erreur désérialisation JSON → List<Room>", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.loremind.infrastructure.persistence.entity;
|
||||
|
||||
import com.loremind.domain.campaigncontext.ArcType;
|
||||
import com.loremind.infrastructure.persistence.converter.StringListJsonConverter;
|
||||
import jakarta.persistence.*;
|
||||
import lombok.AllArgsConstructor;
|
||||
@@ -37,6 +38,15 @@ public class ArcJpaEntity {
|
||||
@Column(name = "\"order\"", nullable = false)
|
||||
private int order;
|
||||
|
||||
/**
|
||||
* Type structurel de l'arc (LINEAR par défaut).
|
||||
* Stocké en STRING pour rester lisible en DB et résistant aux refactos d'ordre.
|
||||
*/
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false, columnDefinition = "VARCHAR(16) DEFAULT 'LINEAR'")
|
||||
@Builder.Default
|
||||
private ArcType type = ArcType.LINEAR;
|
||||
|
||||
@Column
|
||||
private String icon;
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.loremind.infrastructure.persistence.entity;
|
||||
|
||||
import com.loremind.domain.campaigncontext.Prerequisite;
|
||||
import com.loremind.infrastructure.persistence.converter.PrerequisiteListJsonConverter;
|
||||
import com.loremind.infrastructure.persistence.converter.StringListJsonConverter;
|
||||
import jakarta.persistence.*;
|
||||
import lombok.AllArgsConstructor;
|
||||
@@ -37,6 +39,16 @@ public class ChapterJpaEntity {
|
||||
@Column(name = "\"order\"", nullable = false)
|
||||
private int order;
|
||||
|
||||
/**
|
||||
* Conditions de déblocage (combinées en ET). Sérialisées en JSON dans une colonne TEXT
|
||||
* via {@link PrerequisiteListJsonConverter} (sealed type côté domaine).
|
||||
* Donnée de SCÉNARIO — l'état réel est sur Playthrough.QuestProgression.
|
||||
*/
|
||||
@Column(name = "prerequisites", columnDefinition = "TEXT")
|
||||
@Convert(converter = PrerequisiteListJsonConverter.class)
|
||||
@Builder.Default
|
||||
private List<Prerequisite> prerequisites = new ArrayList<>();
|
||||
|
||||
@Column
|
||||
private String icon;
|
||||
|
||||
|
||||
@@ -59,9 +59,17 @@ public class CharacterJpaEntity {
|
||||
@Column(name = "key_value_values", columnDefinition = "TEXT")
|
||||
private Map<String, Map<String, String>> keyValueValues;
|
||||
|
||||
@Column(name = "campaign_id", nullable = false)
|
||||
/**
|
||||
* Ancienne référence directe vers la Campaign. Conservée nullable pour la
|
||||
* migration ; le code utilise playthrough_id désormais.
|
||||
*/
|
||||
@Column(name = "campaign_id")
|
||||
private Long campaignId;
|
||||
|
||||
/** ID du Playthrough (partie) auquel ce PJ appartient. Weak ref cross-context. */
|
||||
@Column(name = "playthrough_id")
|
||||
private Long playthroughId;
|
||||
|
||||
@Column(name = "\"order\"", nullable = false)
|
||||
private int order;
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.loremind.infrastructure.persistence.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* Entité JPA pour les flags narratifs d'un Playthrough.
|
||||
*
|
||||
* <p>Remplace l'ancienne {@code CampaignFlagJpaEntity} : les flags suivent
|
||||
* désormais la partie (table jouée), pas le scénario.</p>
|
||||
*/
|
||||
@Entity
|
||||
@Table(
|
||||
name = "playthrough_flag",
|
||||
uniqueConstraints = @UniqueConstraint(
|
||||
name = "uk_playthrough_flag_name",
|
||||
columnNames = {"playthrough_id", "name"}
|
||||
),
|
||||
indexes = @Index(name = "ix_playthrough_flag_playthrough", columnList = "playthrough_id")
|
||||
)
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class PlaythroughFlagJpaEntity {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "playthrough_id", nullable = false)
|
||||
private Long playthroughId;
|
||||
|
||||
@Column(nullable = false, length = 128)
|
||||
private String name;
|
||||
|
||||
@Column(nullable = false)
|
||||
private boolean value;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.loremind.infrastructure.persistence.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* Entité JPA pour la table {@code playthroughs} — instances jouées d'une Campagne.
|
||||
*/
|
||||
@Entity
|
||||
@Table(
|
||||
name = "playthroughs",
|
||||
indexes = @Index(name = "ix_playthrough_campaign", columnList = "campaign_id")
|
||||
)
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class PlaythroughJpaEntity {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "campaign_id", nullable = false)
|
||||
private Long campaignId;
|
||||
|
||||
@Column(nullable = false)
|
||||
private String name;
|
||||
|
||||
@Column(columnDefinition = "TEXT")
|
||||
private String description;
|
||||
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
createdAt = LocalDateTime.now();
|
||||
updatedAt = LocalDateTime.now();
|
||||
}
|
||||
|
||||
@PreUpdate
|
||||
protected void onUpdate() {
|
||||
updatedAt = LocalDateTime.now();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.loremind.infrastructure.persistence.entity;
|
||||
|
||||
import com.loremind.domain.campaigncontext.ProgressionStatus;
|
||||
import jakarta.persistence.*;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* Entité JPA pour la progression d'une quête (Chapter) au sein d'un Playthrough.
|
||||
*
|
||||
* <p>Contrainte d'unicité sur (playthrough_id, chapter_id) : une seule ligne par
|
||||
* couple quête × partie. L'absence de ligne = NOT_STARTED.</p>
|
||||
*/
|
||||
@Entity
|
||||
@Table(
|
||||
name = "quest_progression",
|
||||
uniqueConstraints = @UniqueConstraint(
|
||||
name = "uk_quest_progression_unique",
|
||||
columnNames = {"playthrough_id", "chapter_id"}
|
||||
),
|
||||
indexes = @Index(name = "ix_quest_progression_playthrough", columnList = "playthrough_id")
|
||||
)
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class QuestProgressionJpaEntity {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "playthrough_id", nullable = false)
|
||||
private Long playthroughId;
|
||||
|
||||
@Column(name = "chapter_id", nullable = false)
|
||||
private Long chapterId;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false, length = 16)
|
||||
private ProgressionStatus status;
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
package com.loremind.infrastructure.persistence.entity;
|
||||
|
||||
import com.loremind.domain.campaigncontext.Room;
|
||||
import com.loremind.domain.campaigncontext.SceneBranch;
|
||||
import com.loremind.infrastructure.persistence.converter.RoomListJsonConverter;
|
||||
import com.loremind.infrastructure.persistence.converter.SceneBranchListJsonConverter;
|
||||
import com.loremind.infrastructure.persistence.converter.StringListJsonConverter;
|
||||
import jakarta.persistence.*;
|
||||
@@ -95,6 +97,15 @@ public class SceneJpaEntity {
|
||||
@Builder.Default
|
||||
private List<SceneBranch> branches = new ArrayList<>();
|
||||
|
||||
/**
|
||||
* Pièces explorables de cette scène (donjon, crypte…). Vide = scène classique.
|
||||
* Sérialisé en JSON dans une colonne TEXT.
|
||||
*/
|
||||
@Column(name = "rooms", columnDefinition = "TEXT")
|
||||
@Convert(converter = RoomListJsonConverter.class)
|
||||
@Builder.Default
|
||||
private List<Room> rooms = new ArrayList<>();
|
||||
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.loremind.infrastructure.persistence.entity;
|
||||
|
||||
import com.loremind.domain.playcontext.EntryType;
|
||||
import jakarta.persistence.*;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* Entité JPA pour la persistance des entrées de journal de session.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "session_entries", indexes = {
|
||||
@Index(name = "idx_session_entries_session_id", columnList = "session_id")
|
||||
})
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class SessionEntryJpaEntity {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
/** Weak reference — pas de FK DB pour rester cohérent avec le reste du projet. */
|
||||
@Column(name = "session_id", nullable = false)
|
||||
private String sessionId;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false, length = 32)
|
||||
private EntryType type;
|
||||
|
||||
@Column(columnDefinition = "TEXT", nullable = false)
|
||||
private String content;
|
||||
|
||||
@Column(name = "occurred_at", nullable = false)
|
||||
private LocalDateTime occurredAt;
|
||||
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
createdAt = now;
|
||||
updatedAt = now;
|
||||
}
|
||||
|
||||
@PreUpdate
|
||||
protected void onUpdate() {
|
||||
updatedAt = LocalDateTime.now();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.loremind.infrastructure.persistence.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* Entité JPA pour la persistance des Sessions en PostgreSQL.
|
||||
* Adaptateur d'infrastructure — n'est PAS dans le domaine.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "sessions")
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class SessionJpaEntity {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(nullable = false)
|
||||
private String name;
|
||||
|
||||
/**
|
||||
* Ancienne référence directe vers la Campaign. Conservée (nullable) pour la
|
||||
* rétrocompatibilité de la migration, mais plus utilisée par le code.
|
||||
* À supprimer manuellement quand toutes les sessions auront leur playthrough_id.
|
||||
*/
|
||||
@Column(name = "campaign_id")
|
||||
private String campaignId;
|
||||
|
||||
/**
|
||||
* ID du Playthrough (partie) auquel cette session appartient.
|
||||
* Weak reference inter-contexte.
|
||||
*/
|
||||
@Column(name = "playthrough_id")
|
||||
private Long playthroughId;
|
||||
|
||||
@Column(name = "started_at", nullable = false)
|
||||
private LocalDateTime startedAt;
|
||||
|
||||
/** Null = session en cours. */
|
||||
@Column(name = "ended_at")
|
||||
private LocalDateTime endedAt;
|
||||
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
createdAt = now;
|
||||
updatedAt = now;
|
||||
}
|
||||
|
||||
@PreUpdate
|
||||
protected void onUpdate() {
|
||||
updatedAt = LocalDateTime.now();
|
||||
}
|
||||
}
|
||||
@@ -9,5 +9,5 @@ import java.util.List;
|
||||
@Repository
|
||||
public interface CharacterJpaRepository extends JpaRepository<CharacterJpaEntity, Long> {
|
||||
|
||||
List<CharacterJpaEntity> findByCampaignIdOrderByOrderAsc(Long campaignId);
|
||||
List<CharacterJpaEntity> findByPlaythroughIdOrderByOrderAsc(Long playthroughId);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.loremind.infrastructure.persistence.jpa;
|
||||
|
||||
import com.loremind.infrastructure.persistence.entity.PlaythroughFlagJpaEntity;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
@Repository
|
||||
public interface PlaythroughFlagJpaRepository extends JpaRepository<PlaythroughFlagJpaEntity, Long> {
|
||||
|
||||
List<PlaythroughFlagJpaEntity> findByPlaythroughId(Long playthroughId);
|
||||
|
||||
Optional<PlaythroughFlagJpaEntity> findByPlaythroughIdAndName(Long playthroughId, String name);
|
||||
|
||||
@Modifying
|
||||
@Transactional
|
||||
@Query("DELETE FROM PlaythroughFlagJpaEntity f WHERE f.playthroughId = :playthroughId")
|
||||
void deleteByPlaythroughId(@Param("playthroughId") Long playthroughId);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.loremind.infrastructure.persistence.jpa;
|
||||
|
||||
import com.loremind.infrastructure.persistence.entity.PlaythroughJpaEntity;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Repository
|
||||
public interface PlaythroughJpaRepository extends JpaRepository<PlaythroughJpaEntity, Long> {
|
||||
|
||||
List<PlaythroughJpaEntity> findByCampaignId(Long campaignId);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.loremind.infrastructure.persistence.jpa;
|
||||
|
||||
import com.loremind.infrastructure.persistence.entity.QuestProgressionJpaEntity;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
@Repository
|
||||
public interface QuestProgressionJpaRepository extends JpaRepository<QuestProgressionJpaEntity, Long> {
|
||||
|
||||
List<QuestProgressionJpaEntity> findByPlaythroughId(Long playthroughId);
|
||||
|
||||
Optional<QuestProgressionJpaEntity> findByPlaythroughIdAndChapterId(Long playthroughId, Long chapterId);
|
||||
|
||||
@Modifying
|
||||
@Transactional
|
||||
@Query("DELETE FROM QuestProgressionJpaEntity q WHERE q.playthroughId = :playthroughId")
|
||||
void deleteByPlaythroughId(@Param("playthroughId") Long playthroughId);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.loremind.infrastructure.persistence.jpa;
|
||||
|
||||
import com.loremind.infrastructure.persistence.entity.SessionEntryJpaEntity;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Repository
|
||||
public interface SessionEntryJpaRepository extends JpaRepository<SessionEntryJpaEntity, Long> {
|
||||
|
||||
List<SessionEntryJpaEntity> findBySessionIdOrderByOccurredAtAsc(String sessionId);
|
||||
|
||||
void deleteBySessionId(String sessionId);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.loremind.infrastructure.persistence.jpa;
|
||||
|
||||
import com.loremind.infrastructure.persistence.entity.SessionJpaEntity;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Repository Spring Data JPA pour SessionJpaEntity.
|
||||
*/
|
||||
@Repository
|
||||
public interface SessionJpaRepository extends JpaRepository<SessionJpaEntity, Long> {
|
||||
|
||||
List<SessionJpaEntity> findByPlaythroughIdOrderByStartedAtDesc(Long playthroughId);
|
||||
|
||||
Optional<SessionJpaEntity> findFirstByEndedAtIsNull();
|
||||
|
||||
Optional<SessionJpaEntity> findFirstByPlaythroughIdAndEndedAtIsNull(Long playthroughId);
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
package com.loremind.infrastructure.persistence.migration;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.boot.ApplicationRunner;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.dao.DataAccessException;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Migration one-shot des données existantes vers le modèle Playthrough.
|
||||
*
|
||||
* <p>Idempotente : si une campagne a déjà au moins un Playthrough, on ne crée pas
|
||||
* de Partie par défaut pour elle. Les opérations sur les sessions / characters /
|
||||
* flags / progression sont toutes filtrées sur l'absence de {@code playthrough_id}
|
||||
* pour ne rien dupliquer.</p>
|
||||
*
|
||||
* <p>Étapes :
|
||||
* 1. Crée un Playthrough "Partie principale" pour chaque Campaign qui n'en a pas
|
||||
* 2. Renseigne {@code sessions.playthrough_id} depuis l'ancien {@code campaign_id}
|
||||
* 3. Renseigne {@code characters.playthrough_id} depuis l'ancien {@code campaign_id}
|
||||
* 4. Copie {@code campaign_flag} → {@code playthrough_flag} (si tables présentes)
|
||||
* 5. Copie {@code chapters.progression_status} (≠ NOT_STARTED) → {@code quest_progression}
|
||||
*
|
||||
* <p>S'exécute au démarrage, après Hibernate ddl-auto=update qui aura créé les nouvelles tables.</p>
|
||||
*/
|
||||
@Configuration
|
||||
public class PlaythroughMigrationRunner {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(PlaythroughMigrationRunner.class);
|
||||
|
||||
private static final String DEFAULT_PLAYTHROUGH_NAME = "Partie principale";
|
||||
|
||||
@Bean
|
||||
public ApplicationRunner runPlaythroughMigration(JdbcTemplate jdbc) {
|
||||
return args -> migrate(jdbc);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
void migrate(JdbcTemplate jdbc) {
|
||||
// Garde-fou : si la table playthroughs n'existe pas (anciens dumps), on saute.
|
||||
if (!tableExists(jdbc, "playthroughs")) {
|
||||
LOG.warn("Migration Playthrough : table 'playthroughs' absente — Hibernate ne l'a pas créée. Skip.");
|
||||
return;
|
||||
}
|
||||
|
||||
int createdPlaythroughs = createDefaultPlaythroughs(jdbc);
|
||||
int migratedSessions = migrateSessions(jdbc);
|
||||
int migratedCharacters = migrateCharacters(jdbc);
|
||||
int migratedFlags = migrateFlags(jdbc);
|
||||
int migratedProgressions = migrateProgressions(jdbc);
|
||||
// Relaxe les NOT NULL legacy : ddl-auto=update n'assouplit pas les contraintes.
|
||||
relaxLegacyNotNullConstraints(jdbc);
|
||||
|
||||
if (createdPlaythroughs + migratedSessions + migratedCharacters + migratedFlags + migratedProgressions > 0) {
|
||||
LOG.info("Migration Playthrough : {} playthrough(s) créé(s), {} session(s), {} PJ, {} flag(s), {} progression(s)",
|
||||
createdPlaythroughs, migratedSessions, migratedCharacters, migratedFlags, migratedProgressions);
|
||||
} else {
|
||||
LOG.debug("Migration Playthrough : rien à migrer.");
|
||||
}
|
||||
}
|
||||
|
||||
private int createDefaultPlaythroughs(JdbcTemplate jdbc) {
|
||||
List<Long> campaignsWithoutPlaythrough = jdbc.queryForList(
|
||||
"SELECT c.id FROM campaigns c " +
|
||||
"WHERE NOT EXISTS (SELECT 1 FROM playthroughs p WHERE p.campaign_id = c.id)",
|
||||
Long.class
|
||||
);
|
||||
if (campaignsWithoutPlaythrough.isEmpty()) return 0;
|
||||
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
for (Long campaignId : campaignsWithoutPlaythrough) {
|
||||
jdbc.update(
|
||||
"INSERT INTO playthroughs (campaign_id, name, description, created_at, updated_at) " +
|
||||
"VALUES (?, ?, NULL, ?, ?)",
|
||||
campaignId, DEFAULT_PLAYTHROUGH_NAME, now, now
|
||||
);
|
||||
}
|
||||
return campaignsWithoutPlaythrough.size();
|
||||
}
|
||||
|
||||
private int migrateSessions(JdbcTemplate jdbc) {
|
||||
if (!columnExists(jdbc, "sessions", "campaign_id")) return 0;
|
||||
return jdbc.update(
|
||||
"UPDATE sessions s " +
|
||||
"SET playthrough_id = (SELECT p.id FROM playthroughs p WHERE p.campaign_id = CAST(s.campaign_id AS BIGINT) LIMIT 1) " +
|
||||
"WHERE s.playthrough_id IS NULL AND s.campaign_id IS NOT NULL"
|
||||
);
|
||||
}
|
||||
|
||||
private int migrateCharacters(JdbcTemplate jdbc) {
|
||||
if (!columnExists(jdbc, "characters", "campaign_id")) return 0;
|
||||
return jdbc.update(
|
||||
"UPDATE characters c " +
|
||||
"SET playthrough_id = (SELECT p.id FROM playthroughs p WHERE p.campaign_id = c.campaign_id LIMIT 1) " +
|
||||
"WHERE c.playthrough_id IS NULL AND c.campaign_id IS NOT NULL"
|
||||
);
|
||||
}
|
||||
|
||||
private int migrateFlags(JdbcTemplate jdbc) {
|
||||
if (!tableExists(jdbc, "campaign_flag") || !tableExists(jdbc, "playthrough_flag")) return 0;
|
||||
// Copie uniquement ce qui n'a pas déjà été copié — on déduplique par (playthrough_id, name)
|
||||
return jdbc.update(
|
||||
"INSERT INTO playthrough_flag (playthrough_id, name, value) " +
|
||||
"SELECT p.id, cf.name, cf.value " +
|
||||
"FROM campaign_flag cf " +
|
||||
"JOIN playthroughs p ON p.campaign_id = cf.campaign_id " +
|
||||
"WHERE NOT EXISTS (" +
|
||||
" SELECT 1 FROM playthrough_flag pf " +
|
||||
" WHERE pf.playthrough_id = p.id AND pf.name = cf.name" +
|
||||
")"
|
||||
);
|
||||
}
|
||||
|
||||
private int migrateProgressions(JdbcTemplate jdbc) {
|
||||
if (!columnExists(jdbc, "chapters", "progression_status")) return 0;
|
||||
if (!tableExists(jdbc, "quest_progression")) return 0;
|
||||
// On copie les chapitres dont la progression était != NOT_STARTED, vers la Partie principale
|
||||
// de la campagne du chapitre. Déduplique via NOT EXISTS sur (playthrough_id, chapter_id).
|
||||
return jdbc.update(
|
||||
"INSERT INTO quest_progression (playthrough_id, chapter_id, status) " +
|
||||
"SELECT p.id, ch.id, ch.progression_status " +
|
||||
"FROM chapters ch " +
|
||||
"JOIN arcs a ON a.id = ch.arc_id " +
|
||||
"JOIN playthroughs p ON p.campaign_id = a.campaign_id " +
|
||||
"WHERE ch.progression_status IS NOT NULL " +
|
||||
" AND ch.progression_status <> 'NOT_STARTED' " +
|
||||
" AND NOT EXISTS (" +
|
||||
" SELECT 1 FROM quest_progression qp " +
|
||||
" WHERE qp.playthrough_id = p.id AND qp.chapter_id = ch.id" +
|
||||
" )"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Relâche les NOT NULL hérités des colonnes désormais optionnelles :
|
||||
* sessions.campaign_id, characters.campaign_id, chapters.progression_status
|
||||
* Idempotent : PostgreSQL ne bronche pas si la colonne est déjà NULLABLE.
|
||||
*/
|
||||
private void relaxLegacyNotNullConstraints(JdbcTemplate jdbc) {
|
||||
relaxNotNull(jdbc, "sessions", "campaign_id");
|
||||
relaxNotNull(jdbc, "characters", "campaign_id");
|
||||
relaxNotNull(jdbc, "chapters", "progression_status");
|
||||
}
|
||||
|
||||
private void relaxNotNull(JdbcTemplate jdbc, String table, String column) {
|
||||
if (!columnExists(jdbc, table, column)) return;
|
||||
try {
|
||||
jdbc.execute("ALTER TABLE " + table + " ALTER COLUMN " + column + " DROP NOT NULL");
|
||||
} catch (DataAccessException ex) {
|
||||
// Déjà NULLABLE ou table en cours de migration : log et continue.
|
||||
LOG.debug("relaxNotNull({}.{}) : déjà nullable ou non-applicable", table, column);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean tableExists(JdbcTemplate jdbc, String table) {
|
||||
try {
|
||||
Integer n = jdbc.queryForObject(
|
||||
"SELECT COUNT(*) FROM information_schema.tables WHERE table_name = ?",
|
||||
Integer.class, table
|
||||
);
|
||||
return n != null && n > 0;
|
||||
} catch (DataAccessException ex) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private boolean columnExists(JdbcTemplate jdbc, String table, String column) {
|
||||
try {
|
||||
Integer n = jdbc.queryForObject(
|
||||
"SELECT COUNT(*) FROM information_schema.columns WHERE table_name = ? AND column_name = ?",
|
||||
Integer.class, table, column
|
||||
);
|
||||
return n != null && n > 0;
|
||||
} catch (DataAccessException ex) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -71,6 +71,7 @@ public class PostgresArcRepository implements ArcRepository {
|
||||
.description(jpaEntity.getDescription())
|
||||
.campaignId(jpaEntity.getCampaignId().toString())
|
||||
.order(jpaEntity.getOrder())
|
||||
.type(jpaEntity.getType())
|
||||
.icon(jpaEntity.getIcon())
|
||||
.themes(jpaEntity.getThemes())
|
||||
.stakes(jpaEntity.getStakes())
|
||||
@@ -100,6 +101,7 @@ public class PostgresArcRepository implements ArcRepository {
|
||||
.description(arc.getDescription())
|
||||
.campaignId(Long.parseLong(arc.getCampaignId()))
|
||||
.order(arc.getOrder())
|
||||
.type(arc.getType())
|
||||
.icon(arc.getIcon())
|
||||
.themes(arc.getThemes())
|
||||
.stakes(arc.getStakes())
|
||||
|
||||
@@ -12,6 +12,9 @@ import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Adaptateur d'infrastructure qui implémente le Port CampaignRepository.
|
||||
* <p>
|
||||
* Depuis l'introduction de Playthrough : ne touche plus aux flags (ils
|
||||
* appartiennent au Playthrough, pas à la Campagne).
|
||||
*/
|
||||
@Repository
|
||||
public class PostgresCampaignRepository implements CampaignRepository {
|
||||
|
||||
@@ -71,6 +71,9 @@ public class PostgresChapterRepository implements ChapterRepository {
|
||||
.description(jpaEntity.getDescription())
|
||||
.arcId(jpaEntity.getArcId().toString())
|
||||
.order(jpaEntity.getOrder())
|
||||
.prerequisites(jpaEntity.getPrerequisites() != null
|
||||
? new ArrayList<>(jpaEntity.getPrerequisites())
|
||||
: new ArrayList<>())
|
||||
.icon(jpaEntity.getIcon())
|
||||
.gmNotes(jpaEntity.getGmNotes())
|
||||
.playerObjectives(jpaEntity.getPlayerObjectives())
|
||||
@@ -97,6 +100,9 @@ public class PostgresChapterRepository implements ChapterRepository {
|
||||
.description(chapter.getDescription())
|
||||
.arcId(Long.parseLong(chapter.getArcId()))
|
||||
.order(chapter.getOrder())
|
||||
.prerequisites(chapter.getPrerequisites() != null
|
||||
? new ArrayList<>(chapter.getPrerequisites())
|
||||
: new ArrayList<>())
|
||||
.icon(chapter.getIcon())
|
||||
.gmNotes(chapter.getGmNotes())
|
||||
.playerObjectives(chapter.getPlayerObjectives())
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user