Compare commits

..

11 Commits

Author SHA1 Message Date
1e501e03a4 Mise en place du readme en anglais 2026-06-16 15:15:48 +02:00
9d4e72af26 Mise à jour du gitignore pour ne pas avoir la documentation réservée patreon dans le répertoire général 2026-06-16 14:13:47 +02:00
1fb4563557 Redécoupage des fichiers et sortie des prompts dans leurs propre fichiers pour ne pas tout mélanger ensemble.
On garde malgrès tout les promps à coté des parseurs car ils évoluent généralement ensemble.
2026-06-15 10:16:01 +02:00
84025911f8 Prise en compte du langage de l'utilisateur pour le prompt de réponse. Si par exemple l'interface est en anglais, les IA vont favoriser l'anglais pour la réponse 2026-06-15 09:49:05 +02:00
914767f793 Montée version v0.14.0-beta
All checks were successful
Build & Push Images / build (brain) (push) Successful in 1m49s
Build & Push Images / build (core) (push) Successful in 2m12s
Build & Push Images / build-switcher (push) Successful in 16s
Build & Push Images / build (web) (push) Successful in 1m58s
2026-06-14 16:24:52 +02:00
af3a6d443c Mise en place de l'anglais comme deuxième langue pour l'application
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Build & Push Images / build-switcher (push) Has been cancelled
2026-06-14 16:24:05 +02:00
6e75326779 Amélioration de la couverture de tests pour la partie infrastructure.ai 2026-06-14 11:45:20 +02:00
d0b53bb15a Ajout, corrections et modifications de tests unitaires pour la partie infrastructure.web.controller.
Amélioration de la couverture de test
2026-06-14 11:25:29 +02:00
bbcb5ee34e Correction du test CampaignStructuralContextBuilderTest
Ajout du mock EnemyRepository manquant (dependance ajoutee au
constructeur lors du referencement des ennemis). Sans ce mock,
@InjectMocks injectait null -> NPE sur enemyRepository.findByCampaignId.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 10:11:27 +02:00
c77c0bc994 référencement des ennemis dans les lieux d'une quête ou d'un chapitre
All checks were successful
Build & Push Images / build (brain) (push) Successful in 1m33s
Build & Push Images / build (core) (push) Successful in 1m58s
Build & Push Images / build-switcher (push) Successful in 17s
Build & Push Images / build (web) (push) Successful in 1m53s
2026-06-13 11:08:16 +02:00
6035df262d Ajout de la possibilité de faire des stats blocs pour tout ce qui est ennemis / créatures adverses.
All checks were successful
Build & Push Images / build (brain) (push) Successful in 1m32s
Build & Push Images / build (core) (push) Successful in 1m52s
Build & Push Images / build-switcher (push) Successful in 20s
Build & Push Images / build (web) (push) Successful in 1m48s
Dorénavant, l'IA est capable de prendre en compte le format des quêtes, chapitres, Arc.... pour proposer des blocs plus complets.
Les ennemis sont également référençables directement dans la campagne.
Les références vers les ennemis dans la partie "donjon" est en cours d'ajout
2026-06-12 23:38:43 +02:00
349 changed files with 18188 additions and 2184 deletions

7
.gitignore vendored
View File

@@ -109,3 +109,10 @@ docker-compose.override.yml
relay/ relay/
scripts/bump-version.mjs scripts/bump-version.mjs
brain/data/notebooks/5.json brain/data/notebooks/5.json
# ============================================================================
# Documentation reservee aux patrons (repo Gitea PRIVE separe, clone
# localement). NE DOIT JAMAIS partir dans le repo LoreMind public.
# Contient le site premium (sources) + son Worker de gate dans gate/.
# ============================================================================
docusaurus/loremind-patreon/

68
README.fr.md Normal file
View File

@@ -0,0 +1,68 @@
# LoreMind
[English](README.md) · **Français**
> Application web auto-hébergeable pour MJ qui veulent centraliser leur univers, leurs campagnes et leurs personnages — avec un assistant IA contextuel.
[![Licence: AGPL v3](https://img.shields.io/badge/Licence-AGPL%20v3-blue.svg)](LICENSE)
[![Documentation](https://img.shields.io/badge/docs-loremind--docs-green)](https://loremind-docs.igmlcreation.fr/)
[![Démo](https://img.shields.io/badge/d%C3%A9mo-en%20ligne-orange)](https://loremind-demo.igmlcreation.fr/)
[![Patreon](https://img.shields.io/badge/Patreon-soutenir-red)](https://www.patreon.com/c/IGMLCreation)
[![Discord](https://img.shields.io/badge/Discord-rejoindre-5865F2)](https://discord.gg/cPpFzCjEzQ)
## Découvrir LoreMind en vidéo
[![Présentation LoreMind](https://img.youtube.com/vi/llJkmlotbB8/maxresdefault.jpg)](https://www.youtube.com/watch?v=llJkmlotbB8)
![Tableau de bord](https://raw.githubusercontent.com/IGMLcreation/loremind-docs/main/static/img/screenshots/dashboard.png)
## Ce que ça fait
LoreMind regroupe ce qu'un MJ utilise habituellement éparpillé entre plusieurs outils. L'application s'articule autour de trois modules principaux, augmentés par un assistant IA qui exploite tout votre contenu.
### 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
Toute la documentation (installation, configuration, prise en main) est sur **[loremind-docs.igmlcreation.fr](https://loremind-docs.igmlcreation.fr/)**.
## Démo en ligne
Une instance de démonstration est disponible sur **[loremind-demo.igmlcreation.fr](https://loremind-demo.igmlcreation.fr/)**.
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)
## Soutenir le projet
LoreMind est **et restera gratuit en auto-hébergement**. Le développement avance plus vite avec votre soutien :
- **[Patreon](https://www.patreon.com/c/IGMLCreation)** — accès anticipé aux features, vote sur la roadmap, devlogs exclusifs
- **[Discord](https://discord.gg/cPpFzCjEzQ)** — annonces, support, retours utilisateurs
## Licence
LoreMind est distribué sous licence **[GNU AGPL v3](LICENSE)**.
En pratique :
- Vous pouvez l'utiliser gratuitement, l'héberger, la modifier, la redistribuer.
- Si vous modifiez le code et que vous exposez l'application modifiée sur un réseau (même en SaaS privé), vous devez rendre vos modifications publiques sous la même licence.
- Les univers (Lore) et campagnes que vous créez avec LoreMind **vous appartiennent entièrement** — la licence ne couvre que le code de l'application.

View File

@@ -1,66 +1,68 @@
# LoreMind # LoreMind
> Application web auto-hébergeable pour MJ qui veulent centraliser leur univers, leurs campagnes et leurs personnages — avec un assistant IA contextuel. **English** · [Français](README.fr.md)
[![Licence: AGPL v3](https://img.shields.io/badge/Licence-AGPL%20v3-blue.svg)](LICENSE) > A self-hostable web app for game masters who want to centralize their world, campaigns and characters — with a context-aware AI assistant.
[![Documentation](https://img.shields.io/badge/docs-loremind--docs-green)](https://loremind-docs.igmlcreation.fr/)
[![Démo](https://img.shields.io/badge/d%C3%A9mo-en%20ligne-orange)](https://loremind-demo.igmlcreation.fr/)
[![Patreon](https://img.shields.io/badge/Patreon-soutenir-red)](https://www.patreon.com/c/IGMLCreation)
[![Discord](https://img.shields.io/badge/Discord-rejoindre-5865F2)](https://discord.gg/cPpFzCjEzQ)
## Découvrir LoreMind en vidéo [![License: AGPL v3](https://img.shields.io/badge/License-AGPL%20v3-blue.svg)](LICENSE)
[![Documentation](https://img.shields.io/badge/docs-loremind--docs-green)](https://loremind-docs.igmlcreation.fr/en/)
[![Demo](https://img.shields.io/badge/demo-online-orange)](https://loremind-demo.igmlcreation.fr/)
[![Patreon](https://img.shields.io/badge/Patreon-support-red)](https://www.patreon.com/c/IGMLCreation)
[![Discord](https://img.shields.io/badge/Discord-join-5865F2)](https://discord.gg/cPpFzCjEzQ)
[![Présentation LoreMind](https://img.youtube.com/vi/llJkmlotbB8/maxresdefault.jpg)](https://www.youtube.com/watch?v=llJkmlotbB8) ## See LoreMind in action
![Tableau de bord](https://raw.githubusercontent.com/IGMLcreation/loremind-docs/main/static/img/screenshots/dashboard.png) [![LoreMind overview](https://img.youtube.com/vi/llJkmlotbB8/maxresdefault.jpg)](https://www.youtube.com/watch?v=llJkmlotbB8)
## Ce que ça fait ![Dashboard](https://raw.githubusercontent.com/IGMLcreation/loremind-docs/main/static/img/screenshots/dashboard.png)
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. ## What it does
LoreMind brings together what a game master usually scatters across several tools. The app is built around three core modules, augmented by an AI assistant that draws on all of your content.
### Lore ### 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. Build your world with a tree of templated pages: locations, factions, NPCs, events, organizations... Each page type follows a configurable template, which keeps things consistent and makes navigating rich worlds easy.
### Game System ### 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. Store the rules of your game system (D&D, Nimble, homebrew...) and define the matching character sheet templates. Indexed rules can be injected into the AI's context for answers that stay true to your system.
### Campaign ### 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. Structure your campaigns as Arcs → Chapters → Scenes, with a clear split between GM-only and player-facing content. Manage PCs and NPCs through dynamic sheets based on your chosen game system's templates.
### Assistant IA ### AI Assistant
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. A context-aware assistant that pulls from your Lore, rules and campaigns to answer your questions, suggest consistent content, or improvise around an unexpected situation at the table.
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. The AI runs **locally via [Ollama](https://ollama.com/)** or via **[1min.ai](https://1min.ai/)**. More engines will be supported in the future.
## Documentation ## Documentation
Toute la documentation (installation, configuration, prise en main) est sur **[loremind-docs.igmlcreation.fr](https://loremind-docs.igmlcreation.fr/)**. The full documentation (installation, configuration, getting started) lives at **[loremind-docs.igmlcreation.fr/en](https://loremind-docs.igmlcreation.fr/en/)**.
## Démo en ligne ## Live demo
Une instance de démonstration est disponible sur **[loremind-demo.igmlcreation.fr](https://loremind-demo.igmlcreation.fr/)**. A demo instance is available at **[loremind-demo.igmlcreation.fr](https://loremind-demo.igmlcreation.fr/)**.
Quelques limites à connaître : A few limitations to be aware of:
- 10 utilisateurs maximum simultanés (instances isolées) - 10 concurrent users maximum (isolated instances)
- Session limitée à 20 minutes avant réinitialisation - Sessions limited to 20 minutes before reset
- Partie IA non incluse dans la démo (nécessite Ollama ou 1min.ai côté serveur) - The AI part is not included in the demo (requires Ollama or 1min.ai server-side)
## Soutenir le projet ## Support the project
LoreMind est **et restera gratuit en auto-hébergement**. Le développement avance plus vite avec votre soutien : LoreMind is **and will remain free when self-hosted**. Development moves faster with your support:
- **[Patreon](https://www.patreon.com/c/IGMLCreation)** — accès anticipé aux features, vote sur la roadmap, devlogs exclusifs - **[Patreon](https://www.patreon.com/c/IGMLCreation)** — early access to features, roadmap voting, exclusive devlogs
- **[Discord](https://discord.gg/cPpFzCjEzQ)** — annonces, support, retours utilisateurs - **[Discord](https://discord.gg/cPpFzCjEzQ)** — announcements, support, user feedback
## Licence ## License
LoreMind est distribué sous licence **[GNU AGPL v3](LICENSE)**. LoreMind is distributed under the **[GNU AGPL v3](LICENSE)** license.
En pratique : In practice:
- Vous pouvez l'utiliser gratuitement, l'héberger, la modifier, la redistribuer. - You can use it for free, host it, modify it, and redistribute it.
- 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. - If you modify the code and expose the modified app over a network (even as a private SaaS), you must make your changes public under the same license.
- 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. - The worlds (Lore) and campaigns you create with LoreMind **belong entirely to you**the license only covers the application's code.

View File

@@ -18,6 +18,7 @@ from app.api.chat_mapping import (
from app.api.deps import get_chat_use_case from app.api.deps import get_chat_use_case
from app.application.chat import ChatUseCase from app.application.chat import ChatUseCase
from app.core.config import get_settings from app.core.config import get_settings
from app.core.language import get_user_language
from app.domain.models import ChatMessage from app.domain.models import ChatMessage
from app.domain.ports import LLMProviderError from app.domain.ports import LLMProviderError
@@ -44,6 +45,7 @@ def _count_tokens(text: str | None) -> int:
async def chat_stream( async def chat_stream(
body: ChatStreamRequestDTO, body: ChatStreamRequestDTO,
use_case: Annotated[ChatUseCase, Depends(get_chat_use_case)], use_case: Annotated[ChatUseCase, Depends(get_chat_use_case)],
language: Annotated[str, Depends(get_user_language)],
) -> StreamingResponse: ) -> StreamingResponse:
"""Chat streamé (Server-Sent Events) avec Structural Context. """Chat streamé (Server-Sent Events) avec Structural Context.
@@ -82,6 +84,7 @@ async def chat_stream(
narrative_entity=narrative_entity, narrative_entity=narrative_entity,
game_system_context=game_system_context, game_system_context=game_system_context,
session_context=session_context, session_context=session_context,
language=language,
) )
# Dernier message = "current" (souvent user), le reste = historique accumulé. # Dernier message = "current" (souvent user), le reste = historique accumulé.
current_msg = messages[-1] if messages else None current_msg = messages[-1] if messages else None
@@ -109,6 +112,7 @@ async def chat_stream(
narrative_entity=narrative_entity, narrative_entity=narrative_entity,
game_system_context=game_system_context, game_system_context=game_system_context,
session_context=session_context, session_context=session_context,
language=language,
): ):
# json.dumps avec ensure_ascii=False pour préserver les accents # json.dumps avec ensure_ascii=False pour préserver les accents
yield f"data: {json.dumps({'token': token}, ensure_ascii=False)}\n\n" yield f"data: {json.dumps({'token': token}, ensure_ascii=False)}\n\n"

View File

@@ -6,7 +6,9 @@ from pydantic import BaseModel, Field
from app.api.deps import get_generate_page_use_case, get_llm_provider from app.api.deps import get_generate_page_use_case, get_llm_provider
from app.application.generate_page import GeneratePageUseCase from app.application.generate_page import GeneratePageUseCase
from app.application.prompts import conversation_title as title_prompts
from app.core.config import Settings, get_settings from app.core.config import Settings, get_settings
from app.core.language import get_user_language
from app.domain.models import PageGenerationContext from app.domain.models import PageGenerationContext
from app.domain.ports import LLMProvider, LLMProviderError from app.domain.ports import LLMProvider, LLMProviderError
@@ -60,6 +62,7 @@ async def generate_page(
use_case: Annotated[ use_case: Annotated[
GeneratePageUseCase, Depends(get_generate_page_use_case) GeneratePageUseCase, Depends(get_generate_page_use_case)
], ],
language: Annotated[str, Depends(get_user_language)],
) -> GeneratePageResponseDTO: ) -> GeneratePageResponseDTO:
"""Endpoint métier : contexte LoreMind → valeurs structurées par champ. """Endpoint métier : contexte LoreMind → valeurs structurées par champ.
@@ -76,7 +79,7 @@ async def generate_page(
) )
try: try:
result = await use_case.execute(context) result = await use_case.execute(context, language=language)
except LLMProviderError as exc: except LLMProviderError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc raise HTTPException(status_code=502, detail=str(exc)) from exc
@@ -101,18 +104,11 @@ class SummarizeTitleResponseDTO(BaseModel):
title: str title: str
_TITLE_SYSTEM_PROMPT = (
"Tu generes un titre court (4 a 7 mots max) qui resume le sujet de la "
"conversation ci-dessous. Reponds UNIQUEMENT par le titre, sans guillemets, "
"sans ponctuation finale, sans prefixe type 'Titre :'. Le titre doit etre "
"en francais et capturer le sujet metier (pas 'Conversation IA')."
)
@router.post("/summarize/conversation-title", response_model=SummarizeTitleResponseDTO) @router.post("/summarize/conversation-title", response_model=SummarizeTitleResponseDTO)
async def summarize_conversation_title( async def summarize_conversation_title(
body: SummarizeTitleRequestDTO, body: SummarizeTitleRequestDTO,
llm: Annotated[LLMProvider, Depends(get_llm_provider)], llm: Annotated[LLMProvider, Depends(get_llm_provider)],
language: Annotated[str, Depends(get_user_language)],
) -> SummarizeTitleResponseDTO: ) -> SummarizeTitleResponseDTO:
"""Genere un titre court a partir des premiers echanges de la conversation. """Genere un titre court a partir des premiers echanges de la conversation.
@@ -123,7 +119,7 @@ async def summarize_conversation_title(
raise HTTPException(status_code=422, detail="Au moins un message requis") raise HTTPException(status_code=422, detail="Au moins un message requis")
transcript = "\n".join(f"{m.role.upper()}: {m.content}" for m in body.messages[:6]) transcript = "\n".join(f"{m.role.upper()}: {m.content}" for m in body.messages[:6])
prompt = f"{_TITLE_SYSTEM_PROMPT}\n\nConversation :\n{transcript}\n\nTitre :" prompt = f"{title_prompts.title_system_prompt(language)}\n\nConversation :\n{transcript}\n\nTitre :"
try: try:
raw = await llm.generate(prompt) raw = await llm.generate(prompt)
except LLMProviderError as exc: except LLMProviderError as exc:
@@ -133,5 +129,5 @@ async def summarize_conversation_title(
if len(title) > 80: if len(title) > 80:
title = title[:80].rstrip() title = title[:80].rstrip()
if not title: if not title:
title = "Nouvelle conversation" title = title_prompts.TITLE_FALLBACK.get(language, title_prompts.TITLE_FALLBACK["fr"])
return SummarizeTitleResponseDTO(title=title) return SummarizeTitleResponseDTO(title=title)

View File

@@ -16,6 +16,7 @@ from app.api.deps import (
from app.application.adapt_campaign import AdaptCampaignUseCase from app.application.adapt_campaign import AdaptCampaignUseCase
from app.application.import_campaign import ImportCampaignUseCase from app.application.import_campaign import ImportCampaignUseCase
from app.application.import_rules import ImportRulesUseCase from app.application.import_rules import ImportRulesUseCase
from app.core.language import get_user_language
from app.domain.models import ChatMessage from app.domain.models import ChatMessage
from app.domain.ports import LLMProviderError, PdfExtractionError from app.domain.ports import LLMProviderError, PdfExtractionError
@@ -40,6 +41,7 @@ class RulesImportResponseDTO(BaseModel):
@router.post("/import/rules", response_model=RulesImportResponseDTO) @router.post("/import/rules", response_model=RulesImportResponseDTO)
async def import_rules( async def import_rules(
use_case: Annotated[ImportRulesUseCase, Depends(get_import_rules_use_case)], use_case: Annotated[ImportRulesUseCase, Depends(get_import_rules_use_case)],
language: Annotated[str, Depends(get_user_language)],
file: UploadFile = File(...), file: UploadFile = File(...),
) -> RulesImportResponseDTO: ) -> RulesImportResponseDTO:
"""Import d'un PDF de règles → sections markdown structurées (proposition). """Import d'un PDF de règles → sections markdown structurées (proposition).
@@ -58,7 +60,7 @@ async def import_rules(
) )
try: try:
result = await use_case.execute(content) result = await use_case.execute(content, language=language)
except PdfExtractionError as exc: except PdfExtractionError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc raise HTTPException(status_code=400, detail=str(exc)) from exc
except LLMProviderError as exc: except LLMProviderError as exc:
@@ -74,6 +76,7 @@ async def import_rules(
@router.post("/import/rules/stream") @router.post("/import/rules/stream")
async def import_rules_stream( async def import_rules_stream(
use_case: Annotated[ImportRulesUseCase, Depends(get_import_rules_use_case)], use_case: Annotated[ImportRulesUseCase, Depends(get_import_rules_use_case)],
language: Annotated[str, Depends(get_user_language)],
file: UploadFile = File(...), file: UploadFile = File(...),
) -> StreamingResponse: ) -> StreamingResponse:
"""Import streamé : émet l'avancement (SSE) puis le résultat final. """Import streamé : émet l'avancement (SSE) puis le résultat final.
@@ -93,7 +96,7 @@ async def import_rules_stream(
yield sse_event("error", {"message": upload_error}) yield sse_event("error", {"message": upload_error})
return return
try: try:
async for ev in use_case.stream(content): async for ev in use_case.stream(content, language=language):
event_type = ev.pop("type") event_type = ev.pop("type")
yield sse_event(event_type, ev) yield sse_event(event_type, ev)
except PdfExtractionError as exc: except PdfExtractionError as exc:
@@ -146,6 +149,7 @@ async def import_campaign_stream(
@router.post("/adapt/campaign/stream") @router.post("/adapt/campaign/stream")
async def adapt_campaign_stream( async def adapt_campaign_stream(
use_case: Annotated[AdaptCampaignUseCase, Depends(get_adapt_campaign_use_case)], use_case: Annotated[AdaptCampaignUseCase, Depends(get_adapt_campaign_use_case)],
language: Annotated[str, Depends(get_user_language)],
file: UploadFile = File(...), file: UploadFile = File(...),
brief: str = Form(""), brief: str = Form(""),
messages: str = Form("[]"), messages: str = Form("[]"),
@@ -173,7 +177,7 @@ async def adapt_campaign_stream(
yield sse_event("error", {"message": upload_error}) yield sse_event("error", {"message": upload_error})
return return
try: try:
async for token in use_case.stream(content, brief, convo): async for token in use_case.stream(content, brief, convo, language=language):
yield sse_event("token", {"token": token}) yield sse_event("token", {"token": token})
yield sse_event("done", {}) yield sse_event("done", {})
except PdfExtractionError as exc: except PdfExtractionError as exc:

View File

@@ -17,6 +17,7 @@ from app.application.notebook_chat import NotebookChatUseCase
from app.application.notebook_deep import NotebookDeepUseCase from app.application.notebook_deep import NotebookDeepUseCase
from app.application.notebook_rag import NotebookRagUseCase from app.application.notebook_rag import NotebookRagUseCase
from app.core.config import Settings, get_settings from app.core.config import Settings, get_settings
from app.core.language import get_user_language
from app.domain.models import ChatMessage from app.domain.models import ChatMessage
from app.domain.ports import LLMProviderError, PdfExtractionError from app.domain.ports import LLMProviderError, PdfExtractionError
from app.infrastructure import vector_store from app.infrastructure import vector_store
@@ -77,6 +78,7 @@ async def chat_notebook_stream(
body: NotebookChatRequestDTO, body: NotebookChatRequestDTO,
use_case: Annotated[NotebookChatUseCase, Depends(get_notebook_chat_use_case)], use_case: Annotated[NotebookChatUseCase, Depends(get_notebook_chat_use_case)],
settings: Annotated[Settings, Depends(get_settings)], settings: Annotated[Settings, Depends(get_settings)],
language: Annotated[str, Depends(get_user_language)],
) -> StreamingResponse: ) -> StreamingResponse:
"""Chat ANCRÉ sur les sources (RAG) : récupère les passages pertinents puis """Chat ANCRÉ sur les sources (RAG) : récupère les passages pertinents puis
streame la réponse. Évènements SSE : `token` {token}, `done` {}, `error` {message}.""" streame la réponse. Évènements SSE : `token` {token}, `done` {}, `error` {message}."""
@@ -85,7 +87,7 @@ async def chat_notebook_stream(
async def event_stream() -> AsyncIterator[str]: async def event_stream() -> AsyncIterator[str]:
try: try:
async for ev in use_case.stream(body.source_ids, messages, context=body.context, top_k=top_k): async for ev in use_case.stream(body.source_ids, messages, context=body.context, top_k=top_k, language=language):
if ev["type"] == "token": if ev["type"] == "token":
if ev.get("token"): if ev.get("token"):
yield sse_event("token", {"token": ev["token"]}) yield sse_event("token", {"token": ev["token"]})
@@ -107,6 +109,7 @@ async def chat_notebook_stream(
async def chat_notebook_deep_stream( async def chat_notebook_deep_stream(
body: NotebookChatRequestDTO, body: NotebookChatRequestDTO,
use_case: Annotated[NotebookDeepUseCase, Depends(get_notebook_deep_use_case)], use_case: Annotated[NotebookDeepUseCase, Depends(get_notebook_deep_use_case)],
language: Annotated[str, Depends(get_user_language)],
) -> StreamingResponse: ) -> StreamingResponse:
"""Analyse APPROFONDIE (map-reduce sur tout le document). Évènements SSE : """Analyse APPROFONDIE (map-reduce sur tout le document). Évènements SSE :
`progress` {current,total} pendant la lecture, puis `token` {token}, puis `done`.""" `progress` {current,total} pendant la lecture, puis `token` {token}, puis `done`."""
@@ -118,7 +121,7 @@ async def chat_notebook_deep_stream(
yield sse_event("error", {"message": "Question vide."}) yield sse_event("error", {"message": "Question vide."})
return return
try: try:
async for ev in use_case.stream(body.source_ids, messages, context=body.context): async for ev in use_case.stream(body.source_ids, messages, context=body.context, language=language):
ev_type = ev.pop("type") ev_type = ev.pop("type")
yield sse_event(ev_type, ev) yield sse_event(ev_type, ev)
except (LLMProviderError, EmbeddingError) as exc: except (LLMProviderError, EmbeddingError) as exc:

View File

@@ -8,6 +8,8 @@ from pydantic import BaseModel, Field
from app.api.deps import get_llm_provider from app.api.deps import get_llm_provider
from app.application.llm_json import load_json_object from app.application.llm_json import load_json_object
from app.application.llm_retry import generate_with_retry from app.application.llm_retry import generate_with_retry
from app.application.prompts import tables as prompts
from app.core.language import get_user_language
from app.domain.ports import LLMProvider, LLMProviderError from app.domain.ports import LLMProvider, LLMProviderError
router = APIRouter() router = APIRouter()
@@ -51,28 +53,15 @@ class GenerateTableResponseDTO(BaseModel):
async def generate_random_table( async def generate_random_table(
body: GenerateTableRequestDTO, body: GenerateTableRequestDTO,
llm: Annotated[LLMProvider, Depends(get_llm_provider)], llm: Annotated[LLMProvider, Depends(get_llm_provider)],
language: Annotated[str, Depends(get_user_language)],
) -> GenerateTableResponseDTO: ) -> GenerateTableResponseDTO:
"""Génère une table aléatoire (entrées par plage) couvrant la formule de dé.""" """Génère une table aléatoire (entrées par plage) couvrant la formule de dé."""
rng = _dice_total_range(body.dice_formula) rng = _dice_total_range(body.dice_formula)
if rng is None: if rng is None:
raise HTTPException(status_code=422, detail="Formule de dé invalide (ex. 1d20, 2d6, d100).") raise HTTPException(status_code=422, detail="Formule de dé invalide (ex. 1d20, 2d6, d100).")
lo, hi = rng lo, hi = rng
context_block = f"\nContexte de la campagne :\n{body.context.strip()}\n" if body.context.strip() else "" prompt = prompts.random_table_prompt(
prompt = ( body.description, body.dice_formula, lo, hi, body.context, language)
"Tu es un assistant de jeu de rôle. Génère une TABLE ALÉATOIRE évocatrice.\n"
f"Dé : {body.dice_formula} (résultats possibles de {lo} à {hi}).\n"
f"Sujet : {body.description.strip()}\n"
f"{context_block}\n"
"Règles IMPÉRATIVES :\n"
"- Réponds UNIQUEMENT par un objet JSON valide, sans texte autour.\n"
'- Format : {"name": "...", "description": "...", "entries": '
'[{"min_roll": N, "max_roll": M, "label": "résultat court", "detail": "1-2 phrases"}]}\n'
f"- Les plages (min_roll..max_roll) doivent COUVRIR EXACTEMENT {lo}..{hi}, "
"sans trou ni chevauchement, dans l'ordre croissant.\n"
"- Des résultats variés, cohérents avec le sujet (et le contexte s'il est fourni).\n"
"- En français. 'label' = résultat bref ; 'detail' = description/effet concret.\n"
"Renvoie maintenant le JSON."
)
try: try:
raw = await generate_with_retry(llm, prompt, output_format="json", temperature=0.7) raw = await generate_with_retry(llm, prompt, output_format="json", temperature=0.7)
except LLMProviderError as exc: except LLMProviderError as exc:
@@ -124,17 +113,11 @@ class ImproviseRollResponseDTO(BaseModel):
async def improvise_table_roll( async def improvise_table_roll(
body: ImproviseRollRequestDTO, body: ImproviseRollRequestDTO,
llm: Annotated[LLMProvider, Depends(get_llm_provider)], llm: Annotated[LLMProvider, Depends(get_llm_provider)],
language: Annotated[str, Depends(get_user_language)],
) -> ImproviseRollResponseDTO: ) -> ImproviseRollResponseDTO:
"""Brode un court récit (2-3 phrases) sur un résultat tiré, pour lancer la scène.""" """Brode un court récit (2-3 phrases) sur un résultat tiré, pour lancer la scène."""
detail = f" ({body.result_detail.strip()})" if body.result_detail.strip() else "" prompt = prompts.improvise_roll_prompt(
context_block = f"\nContexte : {body.context.strip()}" if body.context.strip() else "" body.table_name, body.result_label, body.result_detail, body.context, language)
prompt = (
"Tu es le Maître du Jeu. Les joueurs viennent de tirer sur la table "
f"« {body.table_name.strip()} » et ont obtenu : « {body.result_label.strip()} »{detail}."
f"{context_block}\n\n"
"Décris en 2-3 phrases vivantes et immédiates ce qui se passe, pour lancer la scène. "
"Pas de méta, pas d'options : juste la narration, en français."
)
try: try:
raw = await llm.generate(prompt, temperature=0.8) raw = await llm.generate(prompt, temperature=0.8)
except LLMProviderError as exc: except LLMProviderError as exc:
@@ -167,22 +150,10 @@ class GenerateCatalogResponseDTO(BaseModel):
async def generate_item_catalog( async def generate_item_catalog(
body: GenerateCatalogRequestDTO, body: GenerateCatalogRequestDTO,
llm: Annotated[LLMProvider, Depends(get_llm_provider)], llm: Annotated[LLMProvider, Depends(get_llm_provider)],
language: Annotated[str, Depends(get_user_language)],
) -> GenerateCatalogResponseDTO: ) -> GenerateCatalogResponseDTO:
"""Génère un catalogue d'objets (boutique, butin…) — nom, prix, catégorie, description.""" """Génère un catalogue d'objets (boutique, butin…) — nom, prix, catégorie, description."""
context_block = f"\nContexte de la campagne :\n{body.context.strip()}\n" if body.context.strip() else "" prompt = prompts.item_catalog_prompt(body.description, body.context, language)
prompt = (
"Tu es un assistant de jeu de rôle. Génère un CATALOGUE D'OBJETS (boutique, butin, trésor…).\n"
f"Sujet : {body.description.strip()}\n"
f"{context_block}\n"
"Règles IMPÉRATIVES :\n"
"- Réponds UNIQUEMENT par un objet JSON valide, sans texte autour.\n"
'- Format : {"name": "...", "description": "...", "items": '
'[{"name": "Objet", "price": "ex. 50 po", "category": "ex. Armes", "description": "effet/détails"}]}\n'
"- Des objets variés et cohérents avec le sujet (et le contexte s'il est fourni).\n"
"- 'price' = prix court dans la monnaie du jeu ; 'category' = regroupement (Armes, Potions…) ; "
"'description' = effet/détails en une phrase. En français.\n"
"Renvoie maintenant le JSON."
)
try: try:
raw = await generate_with_retry(llm, prompt, output_format="json", temperature=0.7) raw = await generate_with_retry(llm, prompt, output_format="json", temperature=0.7)
except LLMProviderError as exc: except LLMProviderError as exc:

View File

@@ -13,6 +13,8 @@ from __future__ import annotations
import logging import logging
from typing import AsyncIterator from typing import AsyncIterator
from app.application.prompts import adapt_campaign as prompts
from app.core.language import DEFAULT as _DEFAULT_LANG
from app.domain.models import ChatMessage from app.domain.models import ChatMessage
from app.domain.ports import LLMChatProvider, PdfExtractionError, PdfTextExtractor from app.domain.ports import LLMChatProvider, PdfExtractionError, PdfTextExtractor
@@ -21,27 +23,6 @@ logger = logging.getLogger(__name__)
# Plus créatif que l'import (tâche de structuration) : ici on conseille/adapte. # Plus créatif que l'import (tâche de structuration) : ici on conseille/adapte.
_TEMPERATURE = 0.7 _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: class AdaptCampaignUseCase:
"""Génère (en streaming) des conseils d'adaptation d'un PDF à une campagne.""" """Génère (en streaming) des conseils d'adaptation d'un PDF à une campagne."""
@@ -64,6 +45,7 @@ class AdaptCampaignUseCase:
pdf_bytes: bytes, pdf_bytes: bytes,
brief: str, brief: str,
messages: list[ChatMessage], messages: list[ChatMessage],
language: str = _DEFAULT_LANG,
) -> AsyncIterator[str]: ) -> AsyncIterator[str]:
"""Conversationnel : le PDF + la campagne sont le CONTEXTE (system prompt), """Conversationnel : le PDF + la campagne sont le CONTEXTE (system prompt),
`messages` est l'échange (demande initiale, puis feedbacks de l'utilisateur).""" `messages` est l'échange (demande initiale, puis feedbacks de l'utilisateur)."""
@@ -87,12 +69,12 @@ class AdaptCampaignUseCase:
) )
# Concaténation (pas .format) : brief/PDF peuvent contenir des { } littéraux. # Concaténation (pas .format) : brief/PDF peuvent contenir des { } littéraux.
system_prompt = ( system_prompt = (
f"{_SYSTEM_PREFIX}\n\n" f"{prompts.SYSTEM_PREFIX}\n\n"
"--- CAMPAGNE EXISTANTE DE L'UTILISATEUR ---\n" "--- CAMPAGNE EXISTANTE DE L'UTILISATEUR ---\n"
f"{brief.strip() or '(campagne encore vide)'}\n\n" f"{brief.strip() or '(campagne encore vide)'}\n\n"
"--- CONTENU DU PDF À ADAPTER ---\n" "--- CONTENU DU PDF À ADAPTER ---\n"
f"{pdf_text}{trunc_note}\n\n" f"{pdf_text}{trunc_note}\n\n"
f"{_SYSTEM_SUFFIX}\n\n" f"{prompts.system_suffix(language)}\n\n"
"Tu es en CONVERSATION : à chaque message de l'utilisateur, ajuste, corrige " "Tu es en CONVERSATION : à chaque message de l'utilisateur, ajuste, corrige "
"ou propose des alternatives en gardant tout ce contexte à l'esprit." "ou propose des alternatives en gardant tout ce contexte à l'esprit."
) )

View File

@@ -31,6 +31,8 @@ from app.domain.models import (
QuestSummary, QuestSummary,
SessionContext, SessionContext,
) )
from app.application.prompts import chat as prompts
from app.core.language import DEFAULT as _DEFAULT_LANG
from app.domain.ports import LLMChatProvider from app.domain.ports import LLMChatProvider
@@ -40,21 +42,6 @@ from app.domain.ports import LLMChatProvider
_DEFAULT_TEMPERATURE = 0.7 _DEFAULT_TEMPERATURE = 0.7
_BASE_SYSTEM = """Tu es un assistant d'écriture pour un Maître de Jeu de JDR.
Tu dialogues avec le MJ pour l'aider à enrichir son univers et ses campagnes.
Règles de ton :
- Réponds en français, ton chaleureux et créatif.
- Sois concis : listes à puces courtes plutôt que longs paragraphes.
- Propose des idées qui s'intègrent dans le contexte existant ci-dessous.
Règles de cohérence (IMPORTANT) :
- Tu PEUX et DOIS inventer des éléments originaux (personnages, lieux, objets, intrigues, créatures, scènes) — c'est ton rôle d'assistant créatif.
- Tu ne peux PAS faire référence à un élément du MJ (du Lore, des arcs, chapitres ou scènes) comme s'il existait déjà, SAUF s'il apparaît EXACTEMENT (même orthographe) dans l'une des sections de contexte ci-dessous.
- Si l'utilisateur mentionne un nom que tu ne vois pas dans le contexte, ne fais surtout pas semblant de le connaître : dis clairement "Je ne vois pas [nom] dans le contexte actuel, veux-tu qu'on le crée ?" plutôt que d'inventer des détails à son sujet.
- Évite les précisions inventées qu'on ne peut pas vérifier : dates exactes, chiffres de population, hiérarchies politiques complexes, généalogies détaillées. Préfère des formulations ouvertes que le MJ validera ("il y a longtemps", "de nombreux", "la haute noblesse")."""
class ChatUseCase: class ChatUseCase:
"""Orchestre un tour de conversation avec le LLM + contextes structurels.""" """Orchestre un tour de conversation avec le LLM + contextes structurels."""
@@ -71,16 +58,18 @@ class ChatUseCase:
narrative_entity: NarrativeEntityContext | None = None, narrative_entity: NarrativeEntityContext | None = None,
game_system_context: GameSystemContext | None = None, game_system_context: GameSystemContext | None = None,
session_context: SessionContext | None = None, session_context: SessionContext | None = None,
language: str = _DEFAULT_LANG,
) -> AsyncIterator[str]: ) -> AsyncIterator[str]:
"""Streame les tokens de la réponse assistant pour le dernier message user. """Streame les tokens de la réponse assistant pour le dernier message user.
Les contextes sont tous optionnels, mais au moins l'un des deux Les contextes sont tous optionnels, mais au moins l'un des deux
"niveaux haut" (lore_context ou campaign_context) doit être fourni "niveaux haut" (lore_context ou campaign_context) doit être fourni
pour que le prompt ait du sens. Le controller (main.py) applique pour que le prompt ait du sens. Le controller (main.py) applique
cette règle à la frontière HTTP. cette règle à la frontière HTTP. `language` pilote la langue de réponse.
""" """
system_prompt = self._build_system_prompt( system_prompt = self._build_system_prompt(
lore_context, page_context, campaign_context, narrative_entity, game_system_context, session_context lore_context, page_context, campaign_context, narrative_entity,
game_system_context, session_context, language,
) )
async for token in self._llm.stream_chat( async for token in self._llm.stream_chat(
messages, messages,
@@ -97,12 +86,14 @@ class ChatUseCase:
narrative_entity: NarrativeEntityContext | None = None, narrative_entity: NarrativeEntityContext | None = None,
game_system_context: GameSystemContext | None = None, game_system_context: GameSystemContext | None = None,
session_context: SessionContext | None = None, session_context: SessionContext | None = None,
language: str = _DEFAULT_LANG,
) -> str: ) -> str:
"""Version publique — utilisée par le controller HTTP pour compter """Version publique — utilisée par le controller HTTP pour compter
les tokens du system prompt avant de streamer (jauge de contexte). les tokens du system prompt avant de streamer (jauge de contexte).
""" """
return self._build_system_prompt( return self._build_system_prompt(
lore_context, page_context, campaign_context, narrative_entity, game_system_context, session_context lore_context, page_context, campaign_context, narrative_entity,
game_system_context, session_context, language,
) )
# --- Construction du system prompt -------------------------------------- # --- Construction du system prompt --------------------------------------
@@ -115,8 +106,9 @@ class ChatUseCase:
narrative: NarrativeEntityContext | None, narrative: NarrativeEntityContext | None,
game_system: GameSystemContext | None = None, game_system: GameSystemContext | None = None,
session: SessionContext | None = None, session: SessionContext | None = None,
language: str = _DEFAULT_LANG,
) -> str: ) -> str:
sections = [_BASE_SYSTEM] sections = [prompts.base_system(language)]
if lore is not None: if lore is not None:
sections.append(self._format_lore(lore)) sections.append(self._format_lore(lore))
if campaign is not None: if campaign is not None:

View File

@@ -8,9 +8,13 @@ permet de tester ce use case avec un FakeLLMProvider, sans Ollama qui tourne.
""" """
import json import json
from app.application.prompts import generate_page as prompts
from app.domain.models import PageGenerationContext, PageGenerationResult from app.domain.models import PageGenerationContext, PageGenerationResult
from app.domain.ports import LLMProvider, LLMProviderError from app.domain.ports import LLMProvider, LLMProviderError
# Langue de repli quand le router n'en fournit pas (appel direct / vieux client).
from app.core.language import DEFAULT as _DEFAULT_LANG
# Température basse : remplissage de champs = tâche factuelle, peu créative. # Température basse : remplissage de champs = tâche factuelle, peu créative.
# Une valeur trop haute (par défaut Ollama = 0.8) encourage l'IA à broder # Une valeur trop haute (par défaut Ollama = 0.8) encourage l'IA à broder
@@ -18,21 +22,6 @@ from app.domain.ports import LLMProvider, LLMProviderError
_DEFAULT_TEMPERATURE = 0.4 _DEFAULT_TEMPERATURE = 0.4
_SYSTEM_INSTRUCTIONS = """Tu es un assistant d'écriture pour un Maître de Jeu de JDR.
Tu vas générer le contenu d'une page appartenant à un univers fictionnel.
Règles impératives de ta réponse :
- Tu réponds UNIQUEMENT par un objet JSON valide.
- Les clés du JSON correspondent EXACTEMENT aux noms de champs demandés.
- Les valeurs sont des chaînes de texte en français, riches et évocatrices.
- Aucun markdown, aucune explication, aucun commentaire autour du JSON.
Règles de cohérence (IMPORTANT) :
- Tu PEUX inventer des détails originaux pour CETTE page : apparence, traits de caractère, anecdotes, histoire personnelle.
- Tu ne dois PAS faire référence à d'autres personnages, lieux, organisations ou événements comme s'ils existaient déjà dans l'univers, sauf si le contexte ci-dessous les mentionne explicitement.
- Si un champ appelle une précision externe (date, nom d'un roi, ville voisine, guerre passée), reste volontairement vague : "il y a de nombreuses années", "un bourg voisin", "une époque troublée". Le MJ préfère combler lui-même les blancs plutôt que trouver des faits inventés contradictoires avec son univers."""
class GeneratePageUseCase: class GeneratePageUseCase:
"""Orchestre la génération d'une page LoreMind via un LLM.""" """Orchestre la génération d'une page LoreMind via un LLM."""
@@ -42,8 +31,9 @@ class GeneratePageUseCase:
async def execute( async def execute(
self, self,
context: PageGenerationContext, context: PageGenerationContext,
language: str = _DEFAULT_LANG,
) -> PageGenerationResult: ) -> PageGenerationResult:
prompt = self._build_prompt(context) prompt = self._build_prompt(context, language)
raw = await self._llm.generate( raw = await self._llm.generate(
prompt, prompt,
output_format="json", output_format="json",
@@ -53,7 +43,7 @@ class GeneratePageUseCase:
return PageGenerationResult(values=values) return PageGenerationResult(values=values)
@staticmethod @staticmethod
def _build_prompt(context: PageGenerationContext) -> str: def _build_prompt(context: PageGenerationContext, language: str = _DEFAULT_LANG) -> str:
fields_block = "\n".join(f'- "{field}"' for field in context.template_fields) fields_block = "\n".join(f'- "{field}"' for field in context.template_fields)
lore_desc_line = ( lore_desc_line = (
f"\nDescription de l'univers : {context.lore_description}" f"\nDescription de l'univers : {context.lore_description}"
@@ -62,7 +52,7 @@ class GeneratePageUseCase:
) )
return ( return (
f"{_SYSTEM_INSTRUCTIONS}\n\n" f"{prompts.system_instructions(language)}\n\n"
f"Univers : {context.lore_name}" f"Univers : {context.lore_name}"
f"{lore_desc_line}\n" f"{lore_desc_line}\n"
f"Catégorie (dossier) : {context.folder_name}\n" f"Catégorie (dossier) : {context.folder_name}\n"

View File

@@ -21,6 +21,7 @@ from app.application.import_status import (
) )
from app.application.llm_json import load_json_object, looks_like_truncated_json from app.application.llm_json import load_json_object, looks_like_truncated_json
from app.application.llm_retry import generate_with_retry from app.application.llm_retry import generate_with_retry
from app.application.prompts import import_campaign as prompts
from app.application.streaming import with_heartbeat from app.application.streaming import with_heartbeat
# Repli anti-troncature : si la sortie d'un morceau est coupée, on le retraite en # Repli anti-troncature : si la sortie d'un morceau est coupée, on le retraite en
@@ -47,76 +48,11 @@ logger = logging.getLogger(__name__)
# Plus la valeur est haute, plus le modèle "brode" (invente du contenu absent). # Plus la valeur est haute, plus le modèle "brode" (invente du contenu absent).
_TEMPERATURE = 0.1 _TEMPERATURE = 0.1
# Nom de l'arc unique quand le livre n'est pas découpé en actes/parties.
_DEFAULT_ARC_NAME = "Aventure principale"
# Morceaux PLUS GROS que pour les règles : l'IA voit une quête/un chapitre entier # 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 # 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). # fragmenter en dizaines de scènes. Adapté aux providers à grand contexte (1min.ai).
_CHUNK_TARGET_TOKENS = 10000 _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 [].
PNJ ET CRÉATURES NOTABLES ("npcs", tableau au niveau racine) :
- Recense les PNJ NOMMÉS (alliés, marchands, antagonistes) et les créatures UNIQUES
(boss, monstre récurrent) présents dans l'extrait.
- `description` = courte fiche utile au MJ : rôle dans l'histoire, apparence,
motivations, où on le rencontre. 2 à 4 phrases, fidèles au livre.
- N'inclus PAS les monstres génériques sans nom (« 3 gobelins », « un loup »).
- Aucun PNJ nommé dans l'extrait → "npcs": [].
Format de réponse :
- Tu réponds UNIQUEMENT par un objet JSON valide, sans markdown ni commentaire autour.
- Schéma EXACT :
{{"arcs": [{{"name": "...", "description": "...", "type": "LINEAR",
"chapters": [{{"name": "...", "description": "...", "scenes": [
{{"name": "...", "description": "...", "player_narration": "...", "gm_notes": "...",
"rooms": [{{"name": "...", "description": "...", "enemies": "...", "loot": "..."}}]}}
]}}]}}
],
"npcs": [{{"name": "...", "description": "..."}}]}}
- Utilise les VRAIS titres du livre pour les noms (pas de paraphrase).
- Si le livre n'est PAS découpé en actes/parties, regroupe tout sous un seul arc nommé "{default_arc}".
- N'invente pas de contenu : tu réorganises et recopies ce qui est présent dans l'extrait.
- Si l'extrait ne contient aucune matière narrative, renvoie {{"arcs": []}}."""
# Schéma de l'arbre attendu, passé aux providers à sorties structurées (Ollama # Schéma de l'arbre attendu, passé aux providers à sorties structurées (Ollama
# contraint la grammaire : un modèle local ne PEUT plus produire de clés # contraint la grammaire : un modèle local ne PEUT plus produire de clés
# inventées, d'objets bavards type "thought" ni de texte hors JSON). Les # inventées, d'objets bavards type "thought" ni de texte hors JSON). Les
@@ -195,47 +131,12 @@ _TREE_SCHEMA: dict = {
"additionalProperties": False, "additionalProperties": False,
} }
# Bloc TOC injecté quand le PDF a des bookmarks : les morceaux étant traités
# séparément, c'est CE référentiel commun qui garantit que tous nomment les
# mêmes chapitres à l'identique → la fusion par nom du _TreeMerger recolle
# les chapitres coupés au lieu de créer des doublons.
_TOC_BLOCK = """
--- STRUCTURE OFFICIELLE DU LIVRE (table des matières du PDF) ---
{toc}
--- FIN DE LA STRUCTURE ---
IMPORTANT : pour nommer les arcs et chapitres, reprends EXACTEMENT les titres
de cette structure (caractère pour caractère). Rattache le contenu de l'extrait
au bon chapitre de la structure, même si son titre n'apparaît pas dans l'extrait."""
# Garde-fou prompt : une TOC de gros livre peut compter des centaines d'entrées # Garde-fou prompt : une TOC de gros livre peut compter des centaines d'entrées
# (sous-sous-sections). On la limite aux niveaux hauts et à un nombre raisonnable. # (sous-sous-sections). On la limite aux niveaux hauts et à un nombre raisonnable.
_TOC_MAX_LEVEL = 2 _TOC_MAX_LEVEL = 2
_TOC_MAX_ENTRIES = 80 _TOC_MAX_ENTRIES = 80
# Consolidation finale : le squelette (noms seuls) est minuscule, donc l'appel
# est quasi gratuit comparé aux MAP. Température 0 et consigne CONSERVATRICE :
# ne fusionner que les doublons évidents, jamais des entités distinctes.
_CONSOLIDATE_PROMPT = """Voici le squelette d'une arborescence arc → chapitre → scène issue d'une
fusion AUTOMATIQUE de morceaux d'un livre de campagne de jeu de rôle. La fusion par nom exact
peut avoir laissé des QUASI-DOUBLONS : le même chapitre ou la même scène sous deux libellés
légèrement différents (ex: "La Crypte" et "Crypte de Karrak", "3. Salle des gardes" et
"Salle des gardes").
{skeleton}
Identifie UNIQUEMENT les fusions ÉVIDENTES (même entité du livre sous deux noms). Sois
CONSERVATEUR : dans le doute, ne fusionne PAS. Deux lieux/évènements distincts ne doivent
JAMAIS être fusionnés.
Réponds UNIQUEMENT par un objet JSON valide :
{{"chapter_merges": [{{"into": "nom du chapitre à garder", "merge": ["nom à fusionner", ...]}}],
"scene_merges": [{{"chapter": "nom du chapitre", "into": "nom de la scène à garder",
"merge": ["nom à fusionner", ...]}}]}}
S'il n'y a RIEN à fusionner (cas le plus fréquent) : {{"chapter_merges": [], "scene_merges": []}}"""
def _format_toc(toc) -> str: def _format_toc(toc) -> str:
"""Formate la TOC du PDF en liste indentée, bornée (niveaux hauts d'abord).""" """Formate la TOC du PDF en liste indentée, bornée (niveaux hauts d'abord)."""
entries = [e for e in toc if e.level <= _TOC_MAX_LEVEL][:_TOC_MAX_ENTRIES] entries = [e for e in toc if e.level <= _TOC_MAX_LEVEL][:_TOC_MAX_ENTRIES]
@@ -626,7 +527,7 @@ class ImportCampaignUseCase:
skeleton = merger.skeleton_text() skeleton = merger.skeleton_text()
try: try:
raw = await generate_with_retry( raw = await generate_with_retry(
self._llm, _CONSOLIDATE_PROMPT.format(skeleton=skeleton), self._llm, prompts.CONSOLIDATE_PROMPT.format(skeleton=skeleton),
output_format="json", temperature=0.0) output_format="json", temperature=0.0)
except Exception as exc: # noqa: BLE001 — best-effort STRICT : une erreur ici except Exception as exc: # noqa: BLE001 — best-effort STRICT : une erreur ici
# (LLM, réseau, bug) ne doit JAMAIS faire perdre un import terminé. # (LLM, réseau, bug) ne doit JAMAIS faire perdre un import terminé.
@@ -664,9 +565,9 @@ class ImportCampaignUseCase:
"""Extrait l'arborescence + les PNJ d'un texte. Si la SORTIE est tronquée, """Extrait l'arborescence + les PNJ d'un texte. Si la SORTIE est tronquée,
retraite le texte en DEUX moitiés et concatène — le `_TreeMerger` final retraite le texte en DEUX moitiés et concatène — le `_TreeMerger` final
dédoublonne par nom (un arc/chapitre coupé entre les moitiés est recollé).""" dédoublonne par nom (un arc/chapitre coupé entre les moitiés est recollé)."""
toc_section = _TOC_BLOCK.format(toc=toc_block) if toc_block else "" toc_section = prompts.TOC_BLOCK.format(toc=toc_block) if toc_block else ""
prompt = ( prompt = (
_MAP_SYSTEM.format(default_arc=_DEFAULT_ARC_NAME) prompts.MAP_SYSTEM.format(default_arc=prompts.DEFAULT_ARC_NAME)
+ toc_section + toc_section
+ f"\n\n--- EXTRAIT {index + 1}/{total} ---\n{text}\n\n" + f"\n\n--- EXTRAIT {index + 1}/{total} ---\n{text}\n\n"
"Renvoie maintenant le JSON de l'arborescence." "Renvoie maintenant le JSON de l'arborescence."

View File

@@ -25,7 +25,9 @@ from app.application.import_status import (
) )
from app.application.llm_json import load_json_object, looks_like_truncated_json from app.application.llm_json import load_json_object, looks_like_truncated_json
from app.application.llm_retry import generate_with_retry from app.application.llm_retry import generate_with_retry
from app.application.prompts import import_rules as prompts
from app.application.streaming import with_heartbeat from app.application.streaming import with_heartbeat
from app.core.language import DEFAULT as _DEFAULT_LANG, language_name
# Repli anti-troncature : si la SORTIE d'un morceau est coupée (le modèle ne peut # Repli anti-troncature : si la SORTIE d'un morceau est coupée (le modèle ne peut
# pas tout réécrire en une réponse), on retraite ce morceau en 2 moitiés. Borné en # pas tout réécrire en une réponse), on retraite ce morceau en 2 moitiés. Borné en
@@ -57,43 +59,6 @@ _SECTIONS_SCHEMA: dict = {
"additionalProperties": {"type": "string"}, "additionalProperties": {"type": "string"},
} }
# Taxonomie canonique suggérée au modèle pour homogénéiser les titres entre
# morceaux (sinon "Combat" / "Le combat" / "Règles de combat" se dispersent).
# Le modèle reste libre d'en créer d'autres si rien ne correspond.
_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.
Format EXACT attendu — un objet JSON plat {{titre de section: contenu markdown}} :
{{"Combat": "## Initiative\\n\\nChaque participant lance 1d20...", "Magie et sorts": "## Sorts\\n\\n..."}}
Règles impératives :
- Tu réponds UNIQUEMENT par cet objet JSON, sans texte avant ni après.
- Les CLÉS sont des titres de section (texte court). Les VALEURS sont le contenu de la règle en markdown (chaîne de caractères, jamais un objet ou une liste).
- INTERDIT : des clés génériques comme "title", "content", "sections", "thought" ou "notes" ; des objets imbriqués ; tout commentaire sur ta démarche ou ton raisonnement.
- 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)."""
# --- Mode SEGMENTATION (modèles locaux) -------------------------------------- # --- Mode SEGMENTATION (modèles locaux) --------------------------------------
# Réécrire tout le texte en JSON impose une SORTIE ≈ taille de l'ENTRÉE : à # Réécrire tout le texte en JSON impose une SORTIE ≈ taille de l'ENTRÉE : à
# ~100 tokens/s en local, un livre = des dizaines de minutes et des troncatures # ~100 tokens/s en local, un livre = des dizaines de minutes et des troncatures
@@ -102,25 +67,6 @@ Règles impératives :
# qui découpons le texte original. ~50× plus rapide, fidélité parfaite du # qui découpons le texte original. ~50× plus rapide, fidélité parfaite du
# contenu (texte source intact), plus de troncature possible. # contenu (texte source intact), plus de troncature possible.
_SEGMENT_SYSTEM = """Tu analyses un EXTRAIT brut d'un livre de règles de jeu de rôle.
Ta tâche : repérer où COMMENCENT les sections thématiques. Tu ne réécris RIEN.
Format EXACT attendu :
{{"sections": [{{"titre": "Combat", "debut": "Le combat se déroule en tours de"}}, ...]}}
Règles impératives :
- "debut" = les 5 à 10 PREMIERS MOTS du passage où la section commence, COPIÉS À L'IDENTIQUE
depuis l'extrait (même orthographe, même ponctuation, même langue). JAMAIS un résumé.
- La PREMIÈRE entrée commence aux tout premiers mots de l'extrait (même si le contenu
poursuit une section entamée avant cet extrait).
- Les entrées suivent l'ordre du texte. Vise des sections LARGES (un thème), pas un titre
par paragraphe : un extrait contient typiquement 1 à 6 sections.
- Titres : EN PRIORITÉ parmi :
{canonical}
sinon un titre court et clair en français.
- Pages de garde, sommaires, crédits : n'en fais pas des sections. Si l'extrait n'est que ça,
renvoie {{"sections": []}}."""
# Schéma passé à Ollama (structured outputs) : un objet {"sections": [...]}. # Schéma passé à Ollama (structured outputs) : un objet {"sections": [...]}.
# Racine objet (pas tableau) car l'extraction côté Brain repère le premier {…}. # Racine objet (pas tableau) car l'extraction côté Brain repère le premier {…}.
_ANCHORS_SCHEMA: dict = { _ANCHORS_SCHEMA: dict = {
@@ -293,7 +239,7 @@ class ImportRulesUseCase:
self._chunk_target_tokens = chunk_target_tokens self._chunk_target_tokens = chunk_target_tokens
self._segment_only = segment_only self._segment_only = segment_only
async def execute(self, pdf_bytes: bytes) -> RulesImportResult: async def execute(self, pdf_bytes: bytes, language: str = _DEFAULT_LANG) -> RulesImportResult:
"""Variante non-streamée : traite tout puis renvoie le résultat complet.""" """Variante non-streamée : traite tout puis renvoie le résultat complet."""
doc = self._extractor.extract(pdf_bytes) doc = self._extractor.extract(pdf_bytes)
chunks = chunk_text(doc.full_text, self._chunk_target_tokens) chunks = chunk_text(doc.full_text, self._chunk_target_tokens)
@@ -303,14 +249,14 @@ class ImportRulesUseCase:
) )
merger = _SectionMerger() merger = _SectionMerger()
for i, chunk in enumerate(chunks): for i, chunk in enumerate(chunks):
merger.add(await self._map_chunk(chunk, index=i, total=len(chunks))) merger.add(await self._map_chunk(chunk, index=i, total=len(chunks), language=language))
return RulesImportResult( return RulesImportResult(
sections=merger.result(), sections=merger.result(),
page_count=doc.page_count, page_count=doc.page_count,
ocr_page_count=doc.ocr_page_count, ocr_page_count=doc.ocr_page_count,
) )
async def stream(self, pdf_bytes: bytes): async def stream(self, pdf_bytes: bytes, language: str = _DEFAULT_LANG):
"""Variante streamée : yield des évènements d'avancement au fil de l'eau. """Variante streamée : yield des évènements d'avancement au fil de l'eau.
Évènements (dicts) : {"type": "extracting"}, puis Évènements (dicts) : {"type": "extracting"}, puis
@@ -353,7 +299,7 @@ class ImportRulesUseCase:
try: try:
sections: dict[str, str] | None = None sections: dict[str, str] | None = None
async for kind, payload in with_heartbeat( async for kind, payload in with_heartbeat(
self._map_chunk(chunk, index=i, total=total), self._map_chunk(chunk, index=i, total=total, language=language),
status_queue=status_queue, status_queue=status_queue,
): ):
if kind == "heartbeat": if kind == "heartbeat":
@@ -408,20 +354,24 @@ class ImportRulesUseCase:
# --- MAP : un morceau → sections ----------------------------------------- # --- MAP : un morceau → sections -----------------------------------------
async def _map_chunk(self, chunk: str, *, index: int, total: int) -> dict[str, str]: async def _map_chunk(self, chunk: str, *, index: int, total: int,
return await self._extract_sections(chunk, index=index, total=total, depth=0) language: str = _DEFAULT_LANG) -> dict[str, str]:
return await self._extract_sections(
chunk, index=index, total=total, depth=0, language=language)
async def _extract_sections( async def _extract_sections(
self, text: str, *, index: int, total: int, depth: int self, text: str, *, index: int, total: int, depth: int,
language: str = _DEFAULT_LANG,
) -> dict[str, str]: ) -> dict[str, str]:
"""Extrait les sections d'un texte. Si la SORTIE est tronquée, retraite le """Extrait les sections d'un texte. Si la SORTIE est tronquée, retraite le
texte en DEUX moitiés (chacune produit une réponse complète) et fusionne — texte en DEUX moitiés (chacune produit une réponse complète) et fusionne —
ainsi aucune section n'est perdue, quel que soit le plafond de sortie.""" ainsi aucune section n'est perdue, quel que soit le plafond de sortie."""
system = _SEGMENT_SYSTEM if self._segment_only else _MAP_SYSTEM system = prompts.SEGMENT_SYSTEM if self._segment_only else prompts.MAP_SYSTEM
schema = _ANCHORS_SCHEMA if self._segment_only else _SECTIONS_SCHEMA schema = _ANCHORS_SCHEMA if self._segment_only else _SECTIONS_SCHEMA
prompt = ( prompt = (
system.format( system.format(
canonical="\n".join(f" - {s}" for s in _CANONICAL_SECTIONS) canonical="\n".join(f" - {s}" for s in prompts.CANONICAL_SECTIONS),
language_name=language_name(language),
) )
+ f"\n\n--- EXTRAIT {index + 1}/{total} ---\n{text}\n\n" + f"\n\n--- EXTRAIT {index + 1}/{total} ---\n{text}\n\n"
"Renvoie maintenant le JSON des sections." "Renvoie maintenant le JSON des sections."
@@ -444,8 +394,8 @@ class ImportRulesUseCase:
notify_status( notify_status(
f"Le modèle est trop lent sur le morceau {index + 1} : " f"Le modèle est trop lent sur le morceau {index + 1} : "
"re-découpage en 2 moitiés plus digestes…") "re-découpage en 2 moitiés plus digestes…")
a = await self._extract_sections(left, index=index, total=total, depth=depth + 1) a = await self._extract_sections(left, index=index, total=total, depth=depth + 1, language=language)
b = await self._extract_sections(right, index=index, total=total, depth=depth + 1) b = await self._extract_sections(right, index=index, total=total, depth=depth + 1, language=language)
return _combine_sections(a, b) return _combine_sections(a, b)
if self._segment_only: if self._segment_only:
sections, truncated = self._parse_anchors(raw, text, index=index) sections, truncated = self._parse_anchors(raw, text, index=index)
@@ -461,8 +411,8 @@ class ImportRulesUseCase:
notify_status( notify_status(
f"Réponse du modèle coupée sur le morceau {index + 1} : " f"Réponse du modèle coupée sur le morceau {index + 1} : "
"re-découpage en 2 moitiés plus digestes…") "re-découpage en 2 moitiés plus digestes…")
a = await self._extract_sections(left, index=index, total=total, depth=depth + 1) a = await self._extract_sections(left, index=index, total=total, depth=depth + 1, language=language)
b = await self._extract_sections(right, index=index, total=total, depth=depth + 1) b = await self._extract_sections(right, index=index, total=total, depth=depth + 1, language=language)
return _combine_sections(a, b) return _combine_sections(a, b)
if truncated: if truncated:
logger.warning( logger.warning(

View File

@@ -9,54 +9,13 @@ from __future__ import annotations
from typing import AsyncIterator from typing import AsyncIterator
from app.application.notebook_rag import NotebookRagUseCase from app.application.notebook_rag import NotebookRagUseCase
from app.application.prompts import notebook as prompts
from app.application.query_rewrite import standalone_question from app.application.query_rewrite import standalone_question
from app.application.rerank import pool_size, rerank from app.application.rerank import pool_size, rerank
from app.core.language import DEFAULT as _DEFAULT_LANG, language_name
from app.domain.models import ChatMessage from app.domain.models import ChatMessage
from app.domain.ports import LLMChatProvider from app.domain.ports import LLMChatProvider
_SYSTEM_PROMPT = """Tu es un assistant de jeu de rôle qui aide à ADAPTER une source (PDF) à la CAMPAGNE de l'utilisateur.
Tu disposes de DEUX connaissances, toutes deux ci-dessous :
1) LA CAMPAGNE de l'utilisateur (sa structure arcs/chapitres/scènes, ses PNJ, son univers) ;
2) LA SOURCE (extraits pertinents du PDF).
Règles :
- Pour une question sur SA CAMPAGNE (ex. « mon chapitre 3 », « mes PNJ »), appuie-toi sur la section CAMPAGNE.
- Pour une question sur le livre, appuie-toi sur les EXTRAITS DE LA SOURCE.
- CROISE les deux pour proposer des adaptations cohérentes avec sa campagne existante.
- N'invente pas ce qui ne figure ni dans la campagne ni dans la source ; si tu ne sais pas, dis-le.
- Quand un extrait porte un numéro de page (« (p. 12) »), cite-le (« d'après la p. 12 »).
{context_block}
--- EXTRAITS PERTINENTS DE LA SOURCE ---
{sources_block}
--- FIN DES EXTRAITS ---
PROPOSITIONS D'INTÉGRATION (IMPORTANT) :
Quand l'utilisateur veut CRÉER ou ADAPTER un élément concret pour sa campagne (un PNJ,
une scène, un chapitre, un arc, une table aléatoire), termine ta réponse par un ou
plusieurs BLOCS D'ACTION — un objet JSON par bloc, dans une clôture ```loremind-action.
L'interface les transformera en boutons « Créer dans la campagne ». N'en mets que si
c'est pertinent et explicitement souhaité. Formats acceptés :
```loremind-action
{{"type": "npc", "name": "Nom", "description": "Fiche en quelques phrases."}}
```
```loremind-action
{{"type": "scene", "name": "Nom", "description": "Résumé", "content": "Déroulé détaillé."}}
```
```loremind-action
{{"type": "chapter", "name": "Nom", "description": "Résumé du chapitre."}}
```
```loremind-action
{{"type": "arc", "name": "Nom", "description": "Résumé", "arcType": "LINEAR"}}
```
```loremind-action
{{"type": "table", "name": "Nom", "diceFormula": "1d8", "entries": [{{"minRoll":1,"maxRoll":4,"label":"...","detail":"..."}}]}}
```
Réponds en français, de façon utile et concise. Mets le texte explicatif AVANT les blocs d'action."""
class NotebookChatUseCase: class NotebookChatUseCase:
def __init__( def __init__(
@@ -73,6 +32,7 @@ class NotebookChatUseCase:
messages: list[ChatMessage], messages: list[ChatMessage],
context: str = "", context: str = "",
top_k: int = 6, top_k: int = 6,
language: str = _DEFAULT_LANG,
) -> AsyncIterator[dict]: ) -> AsyncIterator[dict]:
"""Yield des évènements : {type:'sources', sources:[…]} (une fois, avant la """Yield des évènements : {type:'sources', sources:[…]} (une fois, avant la
réponse — transparence sur les passages utilisés), puis {type:'token', token}.""" réponse — transparence sur les passages utilisés), puis {type:'token', token}."""
@@ -107,8 +67,9 @@ class NotebookChatUseCase:
f"--- TA CAMPAGNE ---\n{context.strip()}\n--- FIN CAMPAGNE ---\n\n" f"--- TA CAMPAGNE ---\n{context.strip()}\n--- FIN CAMPAGNE ---\n\n"
if context.strip() else "--- TA CAMPAGNE ---\n(aucune donnée de campagne)\n--- FIN CAMPAGNE ---\n\n" if context.strip() else "--- TA CAMPAGNE ---\n(aucune donnée de campagne)\n--- FIN CAMPAGNE ---\n\n"
) )
system_prompt = _SYSTEM_PROMPT.format( system_prompt = prompts.CHAT_SYSTEM.format(
context_block=context_block, sources_block=sources_block) context_block=context_block, sources_block=sources_block,
language_name=language_name(language))
async for token in self._llm.stream_chat(messages, system_prompt=system_prompt): async for token in self._llm.stream_chat(messages, system_prompt=system_prompt):
yield {"type": "token", "token": token} yield {"type": "token", "token": token}

View File

@@ -20,7 +20,9 @@ from typing import AsyncIterator
import tiktoken import tiktoken
from app.application.llm_retry import generate_with_retry from app.application.llm_retry import generate_with_retry
from app.application.prompts import notebook as prompts
from app.application.query_rewrite import standalone_question from app.application.query_rewrite import standalone_question
from app.core.language import DEFAULT as _DEFAULT_LANG, language_name
from app.domain.models import ChatMessage from app.domain.models import ChatMessage
from app.domain.ports import LLMChatProvider, LLMProvider, LLMProviderError from app.domain.ports import LLMChatProvider, LLMProvider, LLMProviderError
from app.infrastructure import vector_store from app.infrastructure import vector_store
@@ -36,15 +38,6 @@ _MAP_TEMPERATURE = 0.2
# à la question par embedding, et seuls les lots plausiblement pertinents sont # à la question par embedding, et seuls les lots plausiblement pertinents sont
# relus. Sélection volontairement CONSERVATRICE (on préfère relire un lot de # relus. Sélection volontairement CONSERVATRICE (on préfère relire un lot de
# trop que rater une mention) ; désactivable via deep_summary_filter=False. # trop que rater une mention) ; désactivable via deep_summary_filter=False.
_SUMMARY_PROMPT = """Résume l'EXTRAIT ci-dessous en 4 à 8 puces factuelles : lieux, PNJ et
créatures nommés, objets notables, évènements, règles particulières. Pas d'analyse, pas
d'introduction — uniquement les puces, pour servir d'index de recherche.
--- EXTRAIT ---
{excerpt}
--- FIN EXTRAIT ---
Résumé :"""
# Un lot est gardé si son score est proche du meilleur (marge) OU bon dans # Un lot est gardé si son score est proche du meilleur (marge) OU bon dans
# l'absolu ; et on garde toujours au moins _MIN_KEPT lots. # l'absolu ; et on garde toujours au moins _MIN_KEPT lots.
@@ -52,38 +45,6 @@ _SELECT_MARGIN = 0.10
_SELECT_FLOOR = 0.5 _SELECT_FLOOR = 0.5
_MIN_KEPT = 3 _MIN_KEPT = 3
_MAP_PROMPT = """Voici un EXTRAIT d'un document. Extrais UNIQUEMENT les informations
pertinentes pour répondre à la question ci-dessous. Conserve les détails utiles et
indique les numéros de page (format « p. X »). Si l'extrait ne contient RIEN de
pertinent, réponds EXACTEMENT « {no_match} » et rien d'autre.
QUESTION : {question}
--- EXTRAIT ---
{excerpt}
--- FIN EXTRAIT ---
Informations pertinentes (ou « {no_match} ») :"""
_REDUCE_SYSTEM = """Tu es l'assistant-MJ d'un jeu de rôle. Tu réponds à la demande du MJ en
t'appuyant sur TROIS sources : (1) des NOTES extraites de l'ENSEMBLE du document source (vue
complète — mais POSSIBLEMENT VIDE si rien d'utile n'y figure), (2) le contexte de sa CAMPAGNE,
(3) la conversation ci-dessous.
- Si les notes contiennent des éléments utiles : exploite-les et CITE les pages (« p. X »).
- Si les notes sont VIDES ou pauvres (cas fréquent d'une demande CRÉATIVE portant sur des
éléments INVENTÉS par le MJ) : ne te bloque surtout PAS. Aide-le quand même en t'appuyant
sur sa CAMPAGNE, la CONVERSATION et ta connaissance du genre — propose des adaptations
concrètes (arcs, chapitres, scènes, PNJ), structurées et jouables.
- Sois concret et utile. N'affirme rien de FAUX sur le contenu du document.
{context_block}
--- NOTES EXTRAITES DE TOUT LE DOCUMENT ---
{notes_block}
--- FIN DES NOTES ---
Réponds en français."""
class NotebookDeepUseCase: class NotebookDeepUseCase:
def __init__( def __init__(
@@ -109,6 +70,7 @@ class NotebookDeepUseCase:
messages: list[ChatMessage], messages: list[ChatMessage],
context: str = "", context: str = "",
history_limit: int = 8, history_limit: int = 8,
language: str = _DEFAULT_LANG,
) -> AsyncIterator[dict]: ) -> AsyncIterator[dict]:
"""Yield des évènements : {type:'progress',current,total}, {type:'token',token}, """Yield des évènements : {type:'progress',current,total}, {type:'token',token},
{type:'done'}. (Les erreurs LLM des lots sont tolérées : lot ignoré.) {type:'done'}. (Les erreurs LLM des lots sont tolérées : lot ignoré.)
@@ -175,7 +137,9 @@ class NotebookDeepUseCase:
f"--- TA CAMPAGNE (structure, PNJ, univers) ---\n{context.strip()}\n--- FIN CAMPAGNE ---\n\n" f"--- TA CAMPAGNE (structure, PNJ, univers) ---\n{context.strip()}\n--- FIN CAMPAGNE ---\n\n"
if context.strip() else "" if context.strip() else ""
) )
system_prompt = _REDUCE_SYSTEM.format(context_block=context_block, notes_block=notes_block) system_prompt = prompts.REDUCE_SYSTEM.format(
context_block=context_block, notes_block=notes_block,
language_name=language_name(language))
# Historique récent pour la cohérence des relances ; on garantit que le # Historique récent pour la cohérence des relances ; on garantit que le
# dernier message est bien la question courante. # dernier message est bien la question courante.
reduce_messages = messages[-history_limit:] if messages else [ChatMessage(role="user", content=question)] reduce_messages = messages[-history_limit:] if messages else [ChatMessage(role="user", content=question)]
@@ -251,7 +215,7 @@ class NotebookDeepUseCase:
async def _summarize_batch(self, batch: list[dict]) -> str: async def _summarize_batch(self, batch: list[dict]) -> str:
excerpt = "\n\n".join(c.get("text", "").strip() for c in batch) excerpt = "\n\n".join(c.get("text", "").strip() for c in batch)
raw = await generate_with_retry( raw = await generate_with_retry(
self._llm, _SUMMARY_PROMPT.format(excerpt=excerpt), temperature=_MAP_TEMPERATURE) self._llm, prompts.SUMMARY_PROMPT.format(excerpt=excerpt), temperature=_MAP_TEMPERATURE)
return (raw or "").strip() return (raw or "").strip()
async def _map_batch(self, question: str, batch: list[dict]) -> str: async def _map_batch(self, question: str, batch: list[dict]) -> str:
@@ -260,7 +224,7 @@ class NotebookDeepUseCase:
f"(p. {c['page']}) {c['text'].strip()}" if c.get("page") else c["text"].strip() f"(p. {c['page']}) {c['text'].strip()}" if c.get("page") else c["text"].strip()
for c in batch for c in batch
) )
prompt = _MAP_PROMPT.format(no_match=_NO_MATCH, question=question, excerpt=excerpt) prompt = prompts.MAP_PROMPT.format(no_match=_NO_MATCH, question=question, excerpt=excerpt)
raw = await generate_with_retry(self._llm, prompt, temperature=_MAP_TEMPERATURE) raw = await generate_with_retry(self._llm, prompt, temperature=_MAP_TEMPERATURE)
answer = raw.strip() answer = raw.strip()
if answer and answer.upper().rstrip(".") != _NO_MATCH: if answer and answer.upper().rstrip(".") != _NO_MATCH:

View File

@@ -0,0 +1,12 @@
"""Prompts LLM, regroupés hors de la logique des use cases.
Un prompt est du code (couplé à son schéma de sortie et à son parsing), mais
le mêler à la logique d'orchestration rend les use cases illisibles. Ce package
isole le TEXTE des prompts : un module par domaine fonctionnel, miroir des
modules de `app.application` / des routers.
Convention : les use cases importent depuis ici et gardent la logique (chunking,
parsing, fusion, schémas de sortie JSON, températures, sentinelles). Les prompts
restent en français (langue de travail) — seule la langue de SORTIE est
paramétrée, cf. `app.core.language`.
"""

View File

@@ -0,0 +1,26 @@
"""Prompts des conseils d'adaptation d'un PDF à une campagne (cf. adapt_campaign.py)."""
from app.core.language import language_name
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."
)
def system_suffix(language: str) -> str:
"""Consignes de sortie, avec la langue des conseils pilotée par l'utilisateur."""
return (
f"Produis des CONSEILS D'ADAPTATION concrets, actionnables et en {language_name(language).upper()}, "
"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."
)

View File

@@ -0,0 +1,23 @@
"""Prompt système de base du chat contextuel (cf. chat.py).
Les blocs de contexte (Lore, page, campagne, session…) sont sérialisés par les
méthodes `_format_*` du use case ; seul le SYSTEM de base vit ici.
"""
from app.core.language import language_name
def base_system(language: str) -> str:
"""System prompt de base, avec la langue de réponse pilotée par l'utilisateur."""
return f"""Tu es un assistant d'écriture pour un Maître de Jeu de JDR.
Tu dialogues avec le MJ pour l'aider à enrichir son univers et ses campagnes.
Règles de ton :
- Réponds en {language_name(language)}, ton chaleureux et créatif.
- Sois concis : listes à puces courtes plutôt que longs paragraphes.
- Propose des idées qui s'intègrent dans le contexte existant ci-dessous.
Règles de cohérence (IMPORTANT) :
- Tu PEUX et DOIS inventer des éléments originaux (personnages, lieux, objets, intrigues, créatures, scènes) — c'est ton rôle d'assistant créatif.
- Tu ne peux PAS faire référence à un élément du MJ (du Lore, des arcs, chapitres ou scènes) comme s'il existait déjà, SAUF s'il apparaît EXACTEMENT (même orthographe) dans l'une des sections de contexte ci-dessous.
- Si l'utilisateur mentionne un nom que tu ne vois pas dans le contexte, ne fais surtout pas semblant de le connaître : dis clairement "Je ne vois pas [nom] dans le contexte actuel, veux-tu qu'on le crée ?" plutôt que d'inventer des détails à son sujet.
- Évite les précisions inventées qu'on ne peut pas vérifier : dates exactes, chiffres de population, hiérarchies politiques complexes, généalogies détaillées. Préfère des formulations ouvertes que le MJ validera ("il y a longtemps", "de nombreux", "la haute noblesse")."""

View File

@@ -0,0 +1,15 @@
"""Prompt & repli de l'auto-titre de conversation (cf. router generation.py)."""
from app.core.language import language_name
# Titre de repli (LLM injoignable / réponse vide), localisé selon la langue UI.
TITLE_FALLBACK = {"fr": "Nouvelle conversation", "en": "New conversation"}
def title_system_prompt(language: str) -> str:
"""Consigne d'auto-titre, avec la langue du titre pilotée par l'utilisateur."""
return (
"Tu generes un titre court (4 a 7 mots max) qui resume le sujet de la "
"conversation ci-dessous. Reponds UNIQUEMENT par le titre, sans guillemets, "
"sans ponctuation finale, sans prefixe type 'Titre :'. Le titre doit etre "
f"en {language_name(language)} et capturer le sujet metier (pas 'Conversation IA')."
)

View File

@@ -0,0 +1,19 @@
"""Consignes système de la génération de page (cf. generate_page.py)."""
from app.core.language import language_name
def system_instructions(language: str) -> str:
"""Consignes système, avec la langue des valeurs générées pilotée par l'utilisateur."""
return f"""Tu es un assistant d'écriture pour un Maître de Jeu de JDR.
Tu vas générer le contenu d'une page appartenant à un univers fictionnel.
Règles impératives de ta réponse :
- Tu réponds UNIQUEMENT par un objet JSON valide.
- Les clés du JSON correspondent EXACTEMENT aux noms de champs demandés.
- Les valeurs sont des chaînes de texte en {language_name(language)}, riches et évocatrices.
- Aucun markdown, aucune explication, aucun commentaire autour du JSON.
Règles de cohérence (IMPORTANT) :
- Tu PEUX inventer des détails originaux pour CETTE page : apparence, traits de caractère, anecdotes, histoire personnelle.
- Tu ne dois PAS faire référence à d'autres personnages, lieux, organisations ou événements comme s'ils existaient déjà dans l'univers, sauf si le contexte ci-dessous les mentionne explicitement.
- Si un champ appelle une précision externe (date, nom d'un roi, ville voisine, guerre passée), reste volontairement vague : "il y a de nombreuses années", "un bourg voisin", "une époque troublée". Le MJ préfère combler lui-même les blancs plutôt que trouver des faits inventés contradictoires avec son univers."""

View File

@@ -0,0 +1,100 @@
"""Prompts de l'import de campagne PDF (cf. import_campaign.py)."""
# Nom de l'arc unique quand le livre n'est pas découpé en actes/parties.
DEFAULT_ARC_NAME = "Aventure principale"
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 [].
PNJ ET CRÉATURES NOTABLES ("npcs", tableau au niveau racine) :
- Recense les PNJ NOMMÉS (alliés, marchands, antagonistes) et les créatures UNIQUES
(boss, monstre récurrent) présents dans l'extrait.
- `description` = courte fiche utile au MJ : rôle dans l'histoire, apparence,
motivations, où on le rencontre. 2 à 4 phrases, fidèles au livre.
- N'inclus PAS les monstres génériques sans nom (« 3 gobelins », « un loup »).
- Aucun PNJ nommé dans l'extrait → "npcs": [].
Format de réponse :
- Tu réponds UNIQUEMENT par un objet JSON valide, sans markdown ni commentaire autour.
- Schéma EXACT :
{{"arcs": [{{"name": "...", "description": "...", "type": "LINEAR",
"chapters": [{{"name": "...", "description": "...", "scenes": [
{{"name": "...", "description": "...", "player_narration": "...", "gm_notes": "...",
"rooms": [{{"name": "...", "description": "...", "enemies": "...", "loot": "..."}}]}}
]}}]}}
],
"npcs": [{{"name": "...", "description": "..."}}]}}
- Utilise les VRAIS titres du livre pour les noms (pas de paraphrase).
- Si le livre n'est PAS découpé en actes/parties, regroupe tout sous un seul arc nommé "{default_arc}".
- N'invente pas de contenu : tu réorganises et recopies ce qui est présent dans l'extrait.
- Si l'extrait ne contient aucune matière narrative, renvoie {{"arcs": []}}."""
# Bloc TOC injecté quand le PDF a des bookmarks : les morceaux étant traités
# séparément, c'est CE référentiel commun qui garantit que tous nomment les
# mêmes chapitres à l'identique → la fusion par nom du _TreeMerger recolle
# les chapitres coupés au lieu de créer des doublons.
TOC_BLOCK = """
--- STRUCTURE OFFICIELLE DU LIVRE (table des matières du PDF) ---
{toc}
--- FIN DE LA STRUCTURE ---
IMPORTANT : pour nommer les arcs et chapitres, reprends EXACTEMENT les titres
de cette structure (caractère pour caractère). Rattache le contenu de l'extrait
au bon chapitre de la structure, même si son titre n'apparaît pas dans l'extrait."""
# Consolidation finale : le squelette (noms seuls) est minuscule, donc l'appel
# est quasi gratuit comparé aux MAP. Température 0 et consigne CONSERVATRICE :
# ne fusionner que les doublons évidents, jamais des entités distinctes.
CONSOLIDATE_PROMPT = """Voici le squelette d'une arborescence arc → chapitre → scène issue d'une
fusion AUTOMATIQUE de morceaux d'un livre de campagne de jeu de rôle. La fusion par nom exact
peut avoir laissé des QUASI-DOUBLONS : le même chapitre ou la même scène sous deux libellés
légèrement différents (ex: "La Crypte" et "Crypte de Karrak", "3. Salle des gardes" et
"Salle des gardes").
{skeleton}
Identifie UNIQUEMENT les fusions ÉVIDENTES (même entité du livre sous deux noms). Sois
CONSERVATEUR : dans le doute, ne fusionne PAS. Deux lieux/évènements distincts ne doivent
JAMAIS être fusionnés.
Réponds UNIQUEMENT par un objet JSON valide :
{{"chapter_merges": [{{"into": "nom du chapitre à garder", "merge": ["nom à fusionner", ...]}}],
"scene_merges": [{{"chapter": "nom du chapitre", "into": "nom de la scène à garder",
"merge": ["nom à fusionner", ...]}}]}}
S'il n'y a RIEN à fusionner (cas le plus fréquent) : {{"chapter_merges": [], "scene_merges": []}}"""

View File

@@ -0,0 +1,62 @@
"""Prompts de l'import de règles PDF (cf. import_rules.py).
Deux modes : MAP_SYSTEM (cloud, réécrit le contenu en sections markdown) et
SEGMENT_SYSTEM (local, ne renvoie que les frontières des sections). Les deux
templates attendent `.format(canonical=..., language_name=...)`.
"""
# 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.
Format EXACT attendu — un objet JSON plat {{titre de section: contenu markdown}} :
{{"Combat": "## Initiative\\n\\nChaque participant lance 1d20...", "Magie et sorts": "## Sorts\\n\\n..."}}
Règles impératives :
- Tu réponds UNIQUEMENT par cet objet JSON, sans texte avant ni après.
- Les CLÉS sont des titres de section (texte court). Les VALEURS sont le contenu de la règle en markdown (chaîne de caractères, jamais un objet ou une liste).
- INTERDIT : des clés génériques comme "title", "content", "sections", "thought" ou "notes" ; des objets imbriqués ; tout commentaire sur ta démarche ou ton raisonnement.
- 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 {language_name}).
- 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)."""
SEGMENT_SYSTEM = """Tu analyses un EXTRAIT brut d'un livre de règles de jeu de rôle.
Ta tâche : repérer où COMMENCENT les sections thématiques. Tu ne réécris RIEN.
Format EXACT attendu :
{{"sections": [{{"titre": "Combat", "debut": "Le combat se déroule en tours de"}}, ...]}}
Règles impératives :
- "debut" = les 5 à 10 PREMIERS MOTS du passage où la section commence, COPIÉS À L'IDENTIQUE
depuis l'extrait (même orthographe, même ponctuation, même langue). JAMAIS un résumé.
- La PREMIÈRE entrée commence aux tout premiers mots de l'extrait (même si le contenu
poursuit une section entamée avant cet extrait).
- Les entrées suivent l'ordre du texte. Vise des sections LARGES (un thème), pas un titre
par paragraphe : un extrait contient typiquement 1 à 6 sections.
- Titres : EN PRIORITÉ parmi :
{canonical}
sinon un titre court et clair en {language_name}.
- Pages de garde, sommaires, crédits : n'en fais pas des sections. Si l'extrait n'est que ça,
renvoie {{"sections": []}}."""

View File

@@ -0,0 +1,134 @@
"""Prompts des notebooks (atelier RAG) : chat ancré (cf. notebook_chat.py) et
analyse approfondie map-reduce (cf. notebook_deep.py).
CHAT_SYSTEM attend `.format(context_block=..., sources_block=..., language_name=...)`.
REDUCE_SYSTEM attend `.format(context_block=..., notes_block=..., language_name=...)`.
MAP_PROMPT attend `.format(no_match=..., question=..., excerpt=...)`.
SUMMARY_PROMPT attend `.format(excerpt=...)`.
"""
# --- Chat ancré (RAG) --------------------------------------------------------
CHAT_SYSTEM = """Tu es un assistant de jeu de rôle qui aide à ADAPTER une source (PDF) à la CAMPAGNE de l'utilisateur.
Tu disposes de DEUX connaissances, toutes deux ci-dessous :
1) LA CAMPAGNE de l'utilisateur (sa structure arcs/chapitres/scènes, ses PNJ, son univers) ;
2) LA SOURCE (extraits pertinents du PDF).
Règles :
- Pour une question sur SA CAMPAGNE (ex. « mon chapitre 3 », « mes PNJ »), appuie-toi sur la section CAMPAGNE.
- Pour une question sur le livre, appuie-toi sur les EXTRAITS DE LA SOURCE.
- CROISE les deux pour proposer des adaptations cohérentes avec sa campagne existante.
- N'invente pas ce qui ne figure ni dans la campagne ni dans la source ; si tu ne sais pas, dis-le.
- Quand un extrait porte un numéro de page (« (p. 12) »), cite-le (« d'après la p. 12 »).
{context_block}
--- EXTRAITS PERTINENTS DE LA SOURCE ---
{sources_block}
--- FIN DES EXTRAITS ---
PROPOSITIONS D'INTÉGRATION (IMPORTANT) :
Quand l'utilisateur veut CRÉER ou ADAPTER un élément concret pour sa campagne (un PNJ,
une scène, un chapitre, une quête, un arc, une table aléatoire), termine ta réponse par
un ou plusieurs BLOCS D'ACTION — un objet JSON par bloc, dans une clôture
```loremind-action. L'interface les transformera en boutons « Créer dans la campagne ».
Si l'utilisateur demande PLUSIEURS éléments (« propose-moi 3 quêtes »), produis UN bloc
par élément. N'en mets pas si l'utilisateur pose une simple question.
VOCABULAIRE DE LA CAMPAGNE : une « quête » n'est PAS un type à part — c'est un CHAPITRE
rangé dans un arc de type HUB (quêtes parallèles, sans ordre imposé), tandis qu'un arc
LINEAR contient des chapitres joués en séquence. Donc :
- demande de QUÊTE → action "chapter" (l'utilisateur la placera dans son arc HUB) ;
s'il n'a aucun arc HUB dans sa campagne, propose AUSSI une action "arc" avec
"arcType": "HUB" pour les accueillir.
- demande de CHAPITRE → action "chapter" (destinée plutôt à un arc LINEAR).
RÈGLE CLÉ : remplis TOUS les champs pour lesquels tu as de la matière — pas seulement
le résumé ou les notes MJ. Chaque champ rempli atterrit au bon endroit de la fiche ;
un champ laissé vide est une fiche que l'utilisateur devra compléter à la main. Vise
2 à 5 phrases concrètes par champ narratif, tirées de la source et de la campagne.
Omets simplement un champ si tu n'as rien de précis à y mettre. Formats acceptés :
```loremind-action
{{"type": "npc", "name": "Nom",
"description": "Résumé du PNJ (rôle, apparence, motivation).",
"values": {{"<champ de la fiche PNJ>": "contenu", "<autre champ>": "contenu"}}}}
```
(`values` : utilise comme clés les CHAMPS DE LA FICHE PNJ listés dans le contexte
campagne s'ils y figurent — ex. "Histoire", "Apparence" — sinon omets `values`.)
```loremind-action
{{"type": "scene", "name": "Nom",
"description": "Résumé court de la scène.",
"location": "Lieu précis", "timing": "Quand elle survient",
"atmosphere": "Ambiance sensorielle (sons, odeurs, lumière…)",
"playerNarration": "Texte d'ambiance À LIRE AUX JOUEURS, immersif, à la 2e personne.",
"gmSecretNotes": "Secrets, vérités cachées, notes pour le MJ uniquement.",
"choicesConsequences": "Choix offerts aux joueurs et leurs conséquences.",
"combatDifficulty": "Difficulté du combat éventuel", "enemies": "Ennemis présents (effectifs, tactiques)"}}
```
```loremind-action
{{"type": "chapter", "name": "Nom",
"description": "Résumé du chapitre (ou de la quête).",
"playerObjectives": "Objectifs tels que les joueurs les perçoivent.",
"narrativeStakes": "Enjeux narratifs (ce qui se joue vraiment).",
"gmNotes": "Notes MJ : fils à tirer, points d'attention."}}
```
```loremind-action
{{"type": "arc", "name": "Nom", "description": "Résumé", "arcType": "LINEAR",
"themes": "Thèmes de l'arc", "stakes": "Enjeux",
"rewards": "Récompenses attendues", "resolution": "Issues possibles",
"gmNotes": "Notes MJ."}}
```
(`arcType` : "LINEAR" pour des chapitres en séquence, "HUB" pour un recueil de
quêtes parallèles.)
```loremind-action
{{"type": "table", "name": "Nom", "diceFormula": "1d8", "entries": [{{"minRoll":1,"maxRoll":4,"label":"...","detail":"..."}}]}}
```
Réponds en {language_name}, de façon utile et concise. Mets le texte explicatif AVANT les blocs d'action."""
# --- Analyse approfondie (map-reduce) ----------------------------------------
SUMMARY_PROMPT = """Résume l'EXTRAIT ci-dessous en 4 à 8 puces factuelles : lieux, PNJ et
créatures nommés, objets notables, évènements, règles particulières. Pas d'analyse, pas
d'introduction — uniquement les puces, pour servir d'index de recherche.
--- EXTRAIT ---
{excerpt}
--- FIN EXTRAIT ---
Résumé :"""
MAP_PROMPT = """Voici un EXTRAIT d'un document. Extrais UNIQUEMENT les informations
pertinentes pour répondre à la question ci-dessous. Conserve les détails utiles et
indique les numéros de page (format « p. X »). Si l'extrait ne contient RIEN de
pertinent, réponds EXACTEMENT « {no_match} » et rien d'autre.
QUESTION : {question}
--- EXTRAIT ---
{excerpt}
--- FIN EXTRAIT ---
Informations pertinentes (ou « {no_match} ») :"""
REDUCE_SYSTEM = """Tu es l'assistant-MJ d'un jeu de rôle. Tu réponds à la demande du MJ en
t'appuyant sur TROIS sources : (1) des NOTES extraites de l'ENSEMBLE du document source (vue
complète — mais POSSIBLEMENT VIDE si rien d'utile n'y figure), (2) le contexte de sa CAMPAGNE,
(3) la conversation ci-dessous.
- Si les notes contiennent des éléments utiles : exploite-les et CITE les pages (« p. X »).
- Si les notes sont VIDES ou pauvres (cas fréquent d'une demande CRÉATIVE portant sur des
éléments INVENTÉS par le MJ) : ne te bloque surtout PAS. Aide-le quand même en t'appuyant
sur sa CAMPAGNE, la CONVERSATION et ta connaissance du genre — propose des adaptations
concrètes (arcs, chapitres, scènes, PNJ), structurées et jouables.
- Sois concret et utile. N'affirme rien de FAUX sur le contenu du document.
{context_block}
--- NOTES EXTRAITES DE TOUT LE DOCUMENT ---
{notes_block}
--- FIN DES NOTES ---
Réponds en {language_name}."""

View File

@@ -0,0 +1,20 @@
"""Prompt de réécriture en question autonome (cf. query_rewrite.py).
Attend `.format(conversation=...)`.
"""
REWRITE_PROMPT = """Voici la fin d'une conversation entre un Maître de Jeu et son assistant.
Réécris le DERNIER message de l'utilisateur en une question AUTONOME et complète :
remplace les pronoms et références implicites (« il », « ses », « ce lieu », « et pour
les autres ? ») par ce qu'ils désignent dans la conversation.
Règles :
- Réponds UNIQUEMENT par la question réécrite, sans guillemets ni préfixe.
- Conserve la langue et l'intention d'origine. N'ajoute RIEN qui n'est pas demandé.
- Si le dernier message est déjà autonome, recopie-le tel quel.
--- CONVERSATION ---
{conversation}
--- FIN ---
Question autonome :"""

View File

@@ -0,0 +1,14 @@
"""Prompt de reranking LLM des passages RAG (cf. rerank.py).
Attend `.format(question=..., passages=..., count=...)`.
"""
RERANK_PROMPT = """Tu évalues la PERTINENCE d'extraits d'un document pour répondre à une question.
Note chaque extrait de 0 (sans rapport) à 10 (répond directement), indépendamment des autres.
QUESTION : {question}
{passages}
Réponds UNIQUEMENT par un objet JSON : {{"scores": [note_extrait_1, note_extrait_2, ...]}}
Le tableau doit contenir EXACTEMENT {count} notes, dans l'ordre des extraits."""

View File

@@ -0,0 +1,60 @@
"""Prompts des outils de table (tables aléatoires, improvisation, catalogues).
Ces prompts étaient auparavant construits en ligne dans le router `tables.py` ;
isolés ici pour garder la frontière HTTP fine. Le router calcule les plages de
dés et passe les champs bruts ; ces fonctions façonnent le texte.
"""
from app.core.language import language_name
def random_table_prompt(description: str, dice_formula: str, lo: int, hi: int,
context: str, language: str) -> str:
"""Prompt de génération d'une table aléatoire couvrant lo..hi."""
context_block = f"\nContexte de la campagne :\n{context.strip()}\n" if context.strip() else ""
return (
"Tu es un assistant de jeu de rôle. Génère une TABLE ALÉATOIRE évocatrice.\n"
f"Dé : {dice_formula} (résultats possibles de {lo} à {hi}).\n"
f"Sujet : {description.strip()}\n"
f"{context_block}\n"
"Règles IMPÉRATIVES :\n"
"- Réponds UNIQUEMENT par un objet JSON valide, sans texte autour.\n"
'- Format : {"name": "...", "description": "...", "entries": '
'[{"min_roll": N, "max_roll": M, "label": "résultat court", "detail": "1-2 phrases"}]}\n'
f"- Les plages (min_roll..max_roll) doivent COUVRIR EXACTEMENT {lo}..{hi}, "
"sans trou ni chevauchement, dans l'ordre croissant.\n"
"- Des résultats variés, cohérents avec le sujet (et le contexte s'il est fourni).\n"
f"- En {language_name(language)}. 'label' = résultat bref ; 'detail' = description/effet concret.\n"
"Renvoie maintenant le JSON."
)
def improvise_roll_prompt(table_name: str, result_label: str, result_detail: str,
context: str, language: str) -> str:
"""Prompt de narration brodée sur un résultat tiré."""
detail = f" ({result_detail.strip()})" if result_detail.strip() else ""
context_block = f"\nContexte : {context.strip()}" if context.strip() else ""
return (
"Tu es le Maître du Jeu. Les joueurs viennent de tirer sur la table "
f"« {table_name.strip()} » et ont obtenu : « {result_label.strip()} »{detail}."
f"{context_block}\n\n"
"Décris en 2-3 phrases vivantes et immédiates ce qui se passe, pour lancer la scène. "
f"Pas de méta, pas d'options : juste la narration, en {language_name(language)}."
)
def item_catalog_prompt(description: str, context: str, language: str) -> str:
"""Prompt de génération d'un catalogue d'objets (boutique, butin…)."""
context_block = f"\nContexte de la campagne :\n{context.strip()}\n" if context.strip() else ""
return (
"Tu es un assistant de jeu de rôle. Génère un CATALOGUE D'OBJETS (boutique, butin, trésor…).\n"
f"Sujet : {description.strip()}\n"
f"{context_block}\n"
"Règles IMPÉRATIVES :\n"
"- Réponds UNIQUEMENT par un objet JSON valide, sans texte autour.\n"
'- Format : {"name": "...", "description": "...", "items": '
'[{"name": "Objet", "price": "ex. 50 po", "category": "ex. Armes", "description": "effet/détails"}]}\n'
"- Des objets variés et cohérents avec le sujet (et le contexte s'il est fourni).\n"
"- 'price' = prix court dans la monnaie du jeu ; 'category' = regroupement (Armes, Potions…) ; "
f"'description' = effet/détails en une phrase. En {language_name(language)}.\n"
"Renvoie maintenant le JSON."
)

View File

@@ -11,6 +11,7 @@ from __future__ import annotations
import logging import logging
from app.application.prompts import query_rewrite as prompts
from app.domain.models import ChatMessage from app.domain.models import ChatMessage
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -23,22 +24,6 @@ _MAX_HISTORY = 6
# modèle a divagué) → on retombe sur la question brute. # modèle a divagué) → on retombe sur la question brute.
_MAX_REWRITE_CHARS = 400 _MAX_REWRITE_CHARS = 400
_REWRITE_PROMPT = """Voici la fin d'une conversation entre un Maître de Jeu et son assistant.
Réécris le DERNIER message de l'utilisateur en une question AUTONOME et complète :
remplace les pronoms et références implicites (« il », « ses », « ce lieu », « et pour
les autres ? ») par ce qu'ils désignent dans la conversation.
Règles :
- Réponds UNIQUEMENT par la question réécrite, sans guillemets ni préfixe.
- Conserve la langue et l'intention d'origine. N'ajoute RIEN qui n'est pas demandé.
- Si le dernier message est déjà autonome, recopie-le tel quel.
--- CONVERSATION ---
{conversation}
--- FIN ---
Question autonome :"""
async def standalone_question(llm, messages: list[ChatMessage]) -> str: async def standalone_question(llm, messages: list[ChatMessage]) -> str:
"""Condense `messages` en une question autonome pour la RECHERCHE. """Condense `messages` en une question autonome pour la RECHERCHE.
@@ -56,7 +41,7 @@ async def standalone_question(llm, messages: list[ChatMessage]) -> str:
conversation = "\n".join(f"{m.role.upper()}: {m.content.strip()}" for m in recent) conversation = "\n".join(f"{m.role.upper()}: {m.content.strip()}" for m in recent)
try: try:
raw = await llm.generate( raw = await llm.generate(
_REWRITE_PROMPT.format(conversation=conversation), temperature=0.0) prompts.REWRITE_PROMPT.format(conversation=conversation), temperature=0.0)
except Exception as exc: # noqa: BLE001 — la recherche dégradée vaut mieux que pas de réponse except Exception as exc: # noqa: BLE001 — la recherche dégradée vaut mieux que pas de réponse
logger.warning("Réécriture de question ignorée (échec LLM) : %s", exc) logger.warning("Réécriture de question ignorée (échec LLM) : %s", exc)
return last_user return last_user

View File

@@ -11,6 +11,7 @@ from __future__ import annotations
import logging import logging
from app.application.llm_json import load_json_object from app.application.llm_json import load_json_object
from app.application.prompts import rerank as prompts
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -23,16 +24,6 @@ POOL_MAX = 24
# prompt sans changer le jugement de pertinence. # prompt sans changer le jugement de pertinence.
_EXCERPT_CHARS = 600 _EXCERPT_CHARS = 600
_RERANK_PROMPT = """Tu évalues la PERTINENCE d'extraits d'un document pour répondre à une question.
Note chaque extrait de 0 (sans rapport) à 10 (répond directement), indépendamment des autres.
QUESTION : {question}
{passages}
Réponds UNIQUEMENT par un objet JSON : {{"scores": [note_extrait_1, note_extrait_2, ...]}}
Le tableau doit contenir EXACTEMENT {count} notes, dans l'ordre des extraits."""
def pool_size(top_k: int) -> int: def pool_size(top_k: int) -> int:
"""Taille du pool à récupérer avant reranking.""" """Taille du pool à récupérer avant reranking."""
@@ -52,7 +43,7 @@ async def rerank(llm, question: str, passages: list[dict], top_k: int) -> list[d
f"--- EXTRAIT {i + 1} ---\n{(p.get('text') or '')[:_EXCERPT_CHARS]}" f"--- EXTRAIT {i + 1} ---\n{(p.get('text') or '')[:_EXCERPT_CHARS]}"
for i, p in enumerate(passages) for i, p in enumerate(passages)
) )
prompt = _RERANK_PROMPT.format( prompt = prompts.RERANK_PROMPT.format(
question=question, passages=numbered, count=len(passages)) question=question, passages=numbered, count=len(passages))
try: try:
raw = await llm.generate(prompt, temperature=0.0) raw = await llm.generate(prompt, temperature=0.0)

View File

@@ -0,0 +1,61 @@
"""Langue de sortie de l'IA, pilotée par l'utilisateur (et non plus figée en FR).
Le Core relaie la langue choisie dans l'UI via l'entête HTTP `X-User-Language`
(`fr`/`en`). Ce module centralise :
- la normalisation du code reçu (tolérante : `en-US`, `EN`, un `Accept-Language`
brut… → `en`) avec repli sur le français ;
- la fabrique de la directive de langue injectée dans les prompts ;
- la dépendance FastAPI qui lit l'entête côté router.
Ajouter une langue = une entrée dans `NAMES`. Aucun autre branchement n'est requis.
"""
from typing import Annotated
from fastapi import Header
# Nom (en français, langue de travail des prompts) de chaque langue supportée.
# La clé est le code court ISO 639-1 utilisé par l'UI (cf. LanguageService Angular).
NAMES: dict[str, str] = {
"fr": "français",
"en": "anglais",
}
DEFAULT = "fr"
def normalize(raw: str | None) -> str:
"""Réduit un code/entête langue arbitraire à un code supporté (`fr`/`en`).
Tolère les variantes régionales (`en-GB`), la casse, et un `Accept-Language`
complet (`fr-FR,fr;q=0.9,en;q=0.8`) dont on ne garde que la 1re préférence.
Repli systématique sur `DEFAULT` si rien ne matche.
"""
if not raw:
return DEFAULT
# 1re préférence d'un éventuel Accept-Language, puis base avant le tiret régional.
primary = raw.split(",")[0].split(";")[0].strip().lower()
base = primary.split("-")[0]
return base if base in NAMES else DEFAULT
def language_name(lang: str) -> str:
"""Nom de la langue (pour insertion inline dans un prompt)."""
return NAMES.get(lang, NAMES[DEFAULT])
def instruction(lang: str) -> str:
"""Directive forte à injecter dans un prompt pour imposer la langue de sortie."""
return (
f"IMPORTANT : rédige l'INTÉGRALITÉ de ta réponse en {language_name(lang)}, "
"quelle que soit la langue du contexte ou des documents fournis."
)
def get_user_language(
x_user_language: Annotated[str | None, Header()] = None,
) -> str:
"""Dépendance FastAPI : langue de l'utilisateur lue depuis l'entête `X-User-Language`.
Absente (appel direct, vieux client) → français par défaut.
"""
return normalize(x_user_language)

View File

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

View File

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

View File

@@ -42,15 +42,17 @@ public class CampaignBriefBuilder {
if (notBlank(cc.campaignDescription())) sb.append(cc.campaignDescription()).append("\n"); if (notBlank(cc.campaignDescription())) sb.append(cc.campaignDescription()).append("\n");
sb.append("\n## Structure (arcs → chapitres → scènes)\n"); sb.append("\n## Structure (arcs → chapitres → scènes)\n");
sb.append("_Un arc HUB contient des chapitres parallèles appelés « quêtes » ; ")
.append("un arc LINEAR contient des chapitres en séquence._\n");
if (cc.arcs().isEmpty()) { if (cc.arcs().isEmpty()) {
sb.append("_(aucun arc pour le moment)_\n"); sb.append("_(aucun arc pour le moment)_\n");
} }
for (ArcSummary arc : cc.arcs()) { for (ArcSummary arc : cc.arcs()) {
sb.append("### Arc : ").append(arc.name()); sb.append(arc.hub() ? "### Arc HUB (à quêtes) : " : "### Arc : ").append(arc.name());
if (notBlank(arc.description())) sb.append("").append(arc.description()); if (notBlank(arc.description())) sb.append("").append(arc.description());
sb.append("\n"); sb.append("\n");
for (ChapterSummary ch : arc.chapters()) { for (ChapterSummary ch : arc.chapters()) {
sb.append("- Chapitre : ").append(ch.name()); sb.append(arc.hub() ? "- Quête : " : "- Chapitre : ").append(ch.name());
if (notBlank(ch.description())) sb.append("").append(ch.description()); if (notBlank(ch.description())) sb.append("").append(ch.description());
sb.append("\n"); sb.append("\n");
for (SceneSummary sc : ch.scenes()) { for (SceneSummary sc : ch.scenes()) {

View File

@@ -78,6 +78,11 @@ public class CharacterService {
characterRepository.deleteById(id); characterRepository.deleteById(id);
} }
public List<Character> searchCharacters(String query) {
if (query == null || query.isBlank()) return List.of();
return characterRepository.searchByName(query.trim());
}
private int nextOrderFor(String playthroughId) { private int nextOrderFor(String playthroughId) {
return characterRepository.findByPlaythroughId(playthroughId).stream() return characterRepository.findByPlaythroughId(playthroughId).stream()
.mapToInt(Character::getOrder) .mapToInt(Character::getOrder)

View File

@@ -0,0 +1,102 @@
package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Enemy;
import com.loremind.domain.campaigncontext.ports.EnemyRepository;
import org.springframework.stereotype.Service;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
/**
* Service d'application pour les fiches d'ennemis (bestiaire de campagne).
* Miroir de {@link NpcService} : fiche pilotée par le template ENNEMI du GameSystem.
*/
@Service
public class EnemyService {
private final EnemyRepository enemyRepository;
public EnemyService(EnemyRepository enemyRepository) {
this.enemyRepository = enemyRepository;
}
public record EnemyData(
String name,
String level,
String folder,
String portraitImageId,
String headerImageId,
Map<String, String> values,
Map<String, List<String>> imageValues,
Map<String, Map<String, String>> keyValueValues,
String campaignId,
Integer order
) {}
public Enemy createEnemy(EnemyData data) {
int order = data.order() != null ? data.order() : nextOrderFor(data.campaignId());
Enemy enemy = Enemy.builder()
.name(data.name())
.level(normalize(data.level()))
.folder(normalize(data.folder()))
.portraitImageId(data.portraitImageId())
.headerImageId(data.headerImageId())
.values(data.values() != null ? new HashMap<>(data.values()) : new HashMap<>())
.imageValues(data.imageValues() != null ? new HashMap<>(data.imageValues()) : new HashMap<>())
.keyValueValues(data.keyValueValues() != null ? new HashMap<>(data.keyValueValues()) : new HashMap<>())
.campaignId(data.campaignId())
.order(order)
.build();
return enemyRepository.save(enemy);
}
public Optional<Enemy> getEnemyById(String id) {
return enemyRepository.findById(id);
}
public List<Enemy> getEnemiesByCampaignId(String campaignId) {
return enemyRepository.findByCampaignId(campaignId);
}
public Enemy updateEnemy(String id, EnemyData data) {
Enemy existing = enemyRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Enemy non trouvé avec l'ID: " + id));
existing.setName(data.name());
existing.setLevel(normalize(data.level()));
existing.setFolder(normalize(data.folder()));
existing.setPortraitImageId(data.portraitImageId());
existing.setHeaderImageId(data.headerImageId());
existing.setValues(data.values() != null ? new HashMap<>(data.values()) : new HashMap<>());
existing.setImageValues(data.imageValues() != null ? new HashMap<>(data.imageValues()) : new HashMap<>());
existing.setKeyValueValues(data.keyValueValues() != null ? new HashMap<>(data.keyValueValues()) : new HashMap<>());
if (data.order() != null) {
existing.setOrder(data.order());
}
return enemyRepository.save(existing);
}
public void deleteEnemy(String id) {
enemyRepository.deleteById(id);
}
public List<Enemy> searchEnemies(String query) {
if (query == null || query.isBlank()) return List.of();
return enemyRepository.searchByName(query.trim());
}
/** Trim ; chaîne vide → null (= non renseigné / non classé). */
private static String normalize(String value) {
if (value == null) return null;
String trimmed = value.trim();
return trimmed.isEmpty() ? null : trimmed;
}
private int nextOrderFor(String campaignId) {
return enemyRepository.findByCampaignId(campaignId).stream()
.mapToInt(Enemy::getOrder)
.max()
.orElse(-1) + 1;
}
}

View File

@@ -82,6 +82,11 @@ public class ItemCatalogService {
repository.deleteById(id); repository.deleteById(id);
} }
public List<ItemCatalog> searchCatalogs(String query) {
if (query == null || query.isBlank()) return List.of();
return repository.searchByName(query.trim());
}
/** Génère une PROPOSITION de catalogue (non persistée) via l'IA, contextualisée campagne. */ /** Génère une PROPOSITION de catalogue (non persistée) via l'IA, contextualisée campagne. */
public ItemCatalog generateProposal(String campaignId, String description) { public ItemCatalog generateProposal(String campaignId, String description) {
ItemCatalogGenerator.GeneratedCatalog g = generator.generate(description, buildContext(campaignId)); ItemCatalogGenerator.GeneratedCatalog g = generator.generate(description, buildContext(campaignId));

View File

@@ -22,16 +22,19 @@ public class NotebookService {
private final NotebookIndexer indexer; private final NotebookIndexer indexer;
private final CampaignRepository campaignRepository; private final CampaignRepository campaignRepository;
private final CampaignBriefBuilder briefBuilder; private final CampaignBriefBuilder briefBuilder;
private final com.loremind.domain.gamesystemcontext.ports.GameSystemRepository gameSystemRepository;
public NotebookService( public NotebookService(
NotebookRepository repository, NotebookRepository repository,
NotebookIndexer indexer, NotebookIndexer indexer,
CampaignRepository campaignRepository, CampaignRepository campaignRepository,
CampaignBriefBuilder briefBuilder) { CampaignBriefBuilder briefBuilder,
com.loremind.domain.gamesystemcontext.ports.GameSystemRepository gameSystemRepository) {
this.repository = repository; this.repository = repository;
this.indexer = indexer; this.indexer = indexer;
this.campaignRepository = campaignRepository; this.campaignRepository = campaignRepository;
this.briefBuilder = briefBuilder; this.briefBuilder = briefBuilder;
this.gameSystemRepository = gameSystemRepository;
} }
// --- Notebooks --- // --- Notebooks ---
@@ -179,6 +182,25 @@ public class NotebookService {
if (campaignId == null) return ""; if (campaignId == null) return "";
Campaign campaign = campaignRepository.findById(campaignId).orElse(null); Campaign campaign = campaignRepository.findById(campaignId).orElse(null);
if (campaign == null) return ""; if (campaign == null) return "";
return briefBuilder.build(campaign); String brief = briefBuilder.build(campaign);
// Champs TEXT de la fiche PNJ du système de jeu : permet à l'IA de remplir
// `values` des actions "npc" avec les BONS noms de champs (Histoire,
// Apparence…) au lieu de tout entasser dans une description générique.
String npcFields = npcSheetFields(campaign.getGameSystemId());
return npcFields.isEmpty() ? brief : brief + "\n\n" + npcFields;
}
private String npcSheetFields(String gameSystemId) {
if (gameSystemId == null || gameSystemId.isBlank()) return "";
var gameSystem = gameSystemRepository.findById(gameSystemId).orElse(null);
if (gameSystem == null || gameSystem.getNpcTemplate() == null) return "";
var names = gameSystem.getNpcTemplate().stream()
.filter(f -> f.getType() == com.loremind.domain.shared.template.FieldType.TEXT)
.map(com.loremind.domain.shared.template.TemplateField::getName)
.filter(n -> n != null && !n.isBlank())
.toList();
if (names.isEmpty()) return "";
return "FICHE PNJ — champs texte disponibles (clés à utiliser dans `values` "
+ "d'une action npc) : " + String.join(", ", names);
} }
} }

View File

@@ -102,6 +102,11 @@ public class NpcService {
npcRepository.deleteById(id); npcRepository.deleteById(id);
} }
public List<Npc> searchNpcs(String query) {
if (query == null || query.isBlank()) return List.of();
return npcRepository.searchByName(query.trim());
}
/** Trim le dossier ; chaîne vide → null (= non classé). */ /** Trim le dossier ; chaîne vide → null (= non classé). */
private static String normalizeFolder(String folder) { private static String normalizeFolder(String folder) {
if (folder == null) return null; if (folder == null) return null;

View File

@@ -85,6 +85,11 @@ public class RandomTableService {
repository.deleteById(id); repository.deleteById(id);
} }
public List<RandomTable> searchTables(String query) {
if (query == null || query.isBlank()) return List.of();
return repository.searchByName(query.trim());
}
/** Génère une PROPOSITION de table (non persistée) via l'IA, contextualisée campagne. */ /** Génère une PROPOSITION de table (non persistée) via l'IA, contextualisée campagne. */
public RandomTable generateProposal(String campaignId, String description, String diceFormula) { public RandomTable generateProposal(String campaignId, String description, String diceFormula) {
String formula = (diceFormula == null || diceFormula.isBlank()) ? "1d20" : diceFormula; String formula = (diceFormula == null || diceFormula.isBlank()) ? "1d20" : diceFormula;

View File

@@ -57,6 +57,7 @@ public class GameSystemService {
String rulesMarkdown, String rulesMarkdown,
List<TemplateField> characterTemplate, List<TemplateField> characterTemplate,
List<TemplateField> npcTemplate, List<TemplateField> npcTemplate,
List<TemplateField> enemyTemplate,
String author, String author,
boolean isPublic boolean isPublic
) {} ) {}
@@ -71,6 +72,7 @@ public class GameSystemService {
.build(); .build();
gameSystem.replaceCharacterTemplate(data.characterTemplate()); gameSystem.replaceCharacterTemplate(data.characterTemplate());
gameSystem.replaceNpcTemplate(data.npcTemplate()); gameSystem.replaceNpcTemplate(data.npcTemplate());
gameSystem.replaceEnemyTemplate(data.enemyTemplate());
return gameSystemRepository.save(gameSystem); return gameSystemRepository.save(gameSystem);
} }
@@ -90,6 +92,7 @@ public class GameSystemService {
existing.setRulesMarkdown(data.rulesMarkdown()); existing.setRulesMarkdown(data.rulesMarkdown());
existing.replaceCharacterTemplate(data.characterTemplate()); existing.replaceCharacterTemplate(data.characterTemplate());
existing.replaceNpcTemplate(data.npcTemplate()); existing.replaceNpcTemplate(data.npcTemplate());
existing.replaceEnemyTemplate(data.enemyTemplate());
existing.setAuthor(normalize(data.author())); existing.setAuthor(normalize(data.author()));
existing.setPublic(data.isPublic()); existing.setPublic(data.isPublic());
return gameSystemRepository.save(existing); return gameSystemRepository.save(existing);

View File

@@ -1,6 +1,7 @@
package com.loremind.application.generationcontext; package com.loremind.application.generationcontext;
import com.loremind.domain.campaigncontext.Arc; import com.loremind.domain.campaigncontext.Arc;
import com.loremind.domain.campaigncontext.ArcType;
import com.loremind.domain.campaigncontext.Campaign; import com.loremind.domain.campaigncontext.Campaign;
import com.loremind.domain.campaigncontext.Chapter; import com.loremind.domain.campaigncontext.Chapter;
import com.loremind.domain.campaigncontext.Character; import com.loremind.domain.campaigncontext.Character;
@@ -10,6 +11,7 @@ import com.loremind.domain.campaigncontext.ports.ArcRepository;
import com.loremind.domain.campaigncontext.ports.CampaignRepository; import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.campaigncontext.ports.ChapterRepository; import com.loremind.domain.campaigncontext.ports.ChapterRepository;
import com.loremind.domain.campaigncontext.ports.CharacterRepository; import com.loremind.domain.campaigncontext.ports.CharacterRepository;
import com.loremind.domain.campaigncontext.ports.EnemyRepository;
import com.loremind.domain.campaigncontext.ports.NpcRepository; import com.loremind.domain.campaigncontext.ports.NpcRepository;
import com.loremind.domain.campaigncontext.ports.SceneRepository; import com.loremind.domain.campaigncontext.ports.SceneRepository;
import com.loremind.domain.generationcontext.CampaignStructuralContext; import com.loremind.domain.generationcontext.CampaignStructuralContext;
@@ -48,6 +50,7 @@ public class CampaignStructuralContextBuilder {
private final SceneRepository sceneRepository; private final SceneRepository sceneRepository;
private final CharacterRepository characterRepository; private final CharacterRepository characterRepository;
private final NpcRepository npcRepository; private final NpcRepository npcRepository;
private final EnemyRepository enemyRepository;
public CampaignStructuralContextBuilder( public CampaignStructuralContextBuilder(
CampaignRepository campaignRepository, CampaignRepository campaignRepository,
@@ -55,13 +58,15 @@ public class CampaignStructuralContextBuilder {
ChapterRepository chapterRepository, ChapterRepository chapterRepository,
SceneRepository sceneRepository, SceneRepository sceneRepository,
CharacterRepository characterRepository, CharacterRepository characterRepository,
NpcRepository npcRepository) { NpcRepository npcRepository,
EnemyRepository enemyRepository) {
this.campaignRepository = campaignRepository; this.campaignRepository = campaignRepository;
this.arcRepository = arcRepository; this.arcRepository = arcRepository;
this.chapterRepository = chapterRepository; this.chapterRepository = chapterRepository;
this.sceneRepository = sceneRepository; this.sceneRepository = sceneRepository;
this.characterRepository = characterRepository; this.characterRepository = characterRepository;
this.npcRepository = npcRepository; this.npcRepository = npcRepository;
this.enemyRepository = enemyRepository;
} }
/** Longueur max du snippet de PJ/PNJ injecté dans le contexte (coût tokens maîtrisé). */ /** Longueur max du snippet de PJ/PNJ injecté dans le contexte (coût tokens maîtrisé). */
@@ -84,9 +89,17 @@ public class CampaignStructuralContextBuilder {
.orElseThrow(() -> new IllegalArgumentException( .orElseThrow(() -> new IllegalArgumentException(
"Campagne non trouvée avec l'ID: " + campaignId)); "Campagne non trouvée avec l'ID: " + campaignId));
// Libellés du bestiaire (« Nom (niveau) ») chargés UNE fois pour résoudre
// les enemyIds des pièces sans N+1 sur le repo.
Map<String, String> enemyLabelById = enemyRepository.findByCampaignId(campaignId).stream()
.collect(Collectors.toMap(
com.loremind.domain.campaigncontext.Enemy::getId,
CampaignStructuralContextBuilder::enemyLabel,
(a, b) -> a));
List<ArcSummary> arcs = arcRepository.findByCampaignId(campaignId).stream() List<ArcSummary> arcs = arcRepository.findByCampaignId(campaignId).stream()
.sorted(Comparator.comparingInt(Arc::getOrder)) .sorted(Comparator.comparingInt(Arc::getOrder))
.map(this::toArcSummary) .map(arc -> toArcSummary(arc, enemyLabelById))
.collect(Collectors.toList()); .collect(Collectors.toList());
List<CharacterSummary> characters = (playthroughId == null || playthroughId.isBlank()) List<CharacterSummary> characters = (playthroughId == null || playthroughId.isBlank())
@@ -143,19 +156,20 @@ public class CampaignStructuralContextBuilder {
return ""; return "";
} }
private ArcSummary toArcSummary(Arc arc) { private ArcSummary toArcSummary(Arc arc, Map<String, String> enemyLabelById) {
List<ChapterSummary> chapters = chapterRepository.findByArcId(arc.getId()).stream() List<ChapterSummary> chapters = chapterRepository.findByArcId(arc.getId()).stream()
.sorted(Comparator.comparingInt(Chapter::getOrder)) .sorted(Comparator.comparingInt(Chapter::getOrder))
.map(this::toChapterSummary) .map(chapter -> toChapterSummary(chapter, enemyLabelById))
.collect(Collectors.toList()); .collect(Collectors.toList());
return new ArcSummary( return new ArcSummary(
arc.getName(), arc.getName(),
arc.getDescription(), arc.getDescription(),
arc.getType() == ArcType.HUB,
countImages(arc.getIllustrationImageIds()), countImages(arc.getIllustrationImageIds()),
chapters); chapters);
} }
private ChapterSummary toChapterSummary(Chapter chapter) { private ChapterSummary toChapterSummary(Chapter chapter, Map<String, String> enemyLabelById) {
List<Scene> scenes = sceneRepository.findByChapterId(chapter.getId()).stream() List<Scene> scenes = sceneRepository.findByChapterId(chapter.getId()).stream()
.sorted(Comparator.comparingInt(Scene::getOrder)) .sorted(Comparator.comparingInt(Scene::getOrder))
.toList(); .toList();
@@ -166,7 +180,7 @@ public class CampaignStructuralContextBuilder {
.collect(Collectors.toMap(Scene::getId, Scene::getName)); .collect(Collectors.toMap(Scene::getId, Scene::getName));
List<SceneSummary> summaries = scenes.stream() List<SceneSummary> summaries = scenes.stream()
.map(s -> toSceneSummary(s, nameById)) .map(s -> toSceneSummary(s, nameById, enemyLabelById))
.collect(Collectors.toList()); .collect(Collectors.toList());
return new ChapterSummary( return new ChapterSummary(
@@ -176,7 +190,8 @@ public class CampaignStructuralContextBuilder {
summaries); summaries);
} }
private SceneSummary toSceneSummary(Scene scene, Map<String, String> nameById) { private SceneSummary toSceneSummary(
Scene scene, Map<String, String> nameById, Map<String, String> enemyLabelById) {
List<BranchHint> hints = scene.getBranches() == null List<BranchHint> hints = scene.getBranches() == null
? List.of() ? List.of()
: scene.getBranches().stream() : scene.getBranches().stream()
@@ -186,7 +201,7 @@ public class CampaignStructuralContextBuilder {
b.condition())) b.condition()))
.collect(Collectors.toList()); .collect(Collectors.toList());
List<RoomSummary> rooms = toRoomSummaries(scene); List<RoomSummary> rooms = toRoomSummaries(scene, enemyLabelById);
return new SceneSummary( return new SceneSummary(
scene.getName(), scene.getName(),
@@ -202,7 +217,7 @@ public class CampaignStructuralContextBuilder {
* connaît la structure du lieu (nom des pièces, ennemis, sorties) — c'est * connaît la structure du lieu (nom des pièces, ennemis, sorties) — c'est
* suffisant pour proposer de la narration ou anticiper les choix. * suffisant pour proposer de la narration ou anticiper les choix.
*/ */
private List<RoomSummary> toRoomSummaries(Scene scene) { private List<RoomSummary> toRoomSummaries(Scene scene, Map<String, String> enemyLabelById) {
if (scene.getRooms() == null || scene.getRooms().isEmpty()) return List.of(); if (scene.getRooms() == null || scene.getRooms().isEmpty()) return List.of();
Map<String, String> nameById = scene.getRooms().stream() Map<String, String> nameById = scene.getRooms().stream()
.collect(Collectors.toMap( .collect(Collectors.toMap(
@@ -219,11 +234,36 @@ public class CampaignStructuralContextBuilder {
nameById.getOrDefault(b.targetRoomId(), "(pièce inconnue)"), nameById.getOrDefault(b.targetRoomId(), "(pièce inconnue)"),
b.condition())) b.condition()))
.collect(Collectors.toList()); .collect(Collectors.toList());
return new RoomSummary(r.getName(), r.getFloor(), r.getDescription(), r.getEnemies(), hints); return new RoomSummary(
r.getName(), r.getFloor(), r.getDescription(),
roomEnemiesText(r, enemyLabelById), hints);
}) })
.collect(Collectors.toList()); .collect(Collectors.toList());
} }
/**
* Texte « ennemis » d'une pièce pour le prompt : fiches du bestiaire
* référencées (libellés résolus, IDs orphelins ignorés) suivies du texte
* libre. L'un ou l'autre peut être vide.
*/
private static String roomEnemiesText(
com.loremind.domain.campaigncontext.Room room, Map<String, String> enemyLabelById) {
String linked = room.getEnemyIds() == null ? "" : room.getEnemyIds().stream()
.map(enemyLabelById::get)
.filter(l -> l != null && !l.isBlank())
.collect(Collectors.joining(", "));
String freeText = room.getEnemies() == null ? "" : room.getEnemies().strip();
if (linked.isEmpty()) return freeText;
if (freeText.isEmpty()) return linked;
return linked + "" + freeText;
}
/** Libellé court d'une fiche du bestiaire : « Nom (niveau) » ou « Nom ». */
private static String enemyLabel(com.loremind.domain.campaigncontext.Enemy enemy) {
String level = enemy.getLevel() == null ? "" : enemy.getLevel().strip();
return level.isEmpty() ? enemy.getName() : enemy.getName() + " (" + level + ")";
}
/** Helper defensif : compte les illustrations attachees (null-safe). */ /** Helper defensif : compte les illustrations attachees (null-safe). */
private static int countImages(List<String> ids) { private static int countImages(List<String> ids) {
return ids == null ? 0 : ids.size(); return ids == null ? 0 : ids.size();

View File

@@ -8,6 +8,7 @@ import com.loremind.domain.campaigncontext.Scene;
import com.loremind.domain.campaigncontext.ports.ArcRepository; import com.loremind.domain.campaigncontext.ports.ArcRepository;
import com.loremind.domain.campaigncontext.ports.ChapterRepository; import com.loremind.domain.campaigncontext.ports.ChapterRepository;
import com.loremind.domain.campaigncontext.ports.CharacterRepository; import com.loremind.domain.campaigncontext.ports.CharacterRepository;
import com.loremind.domain.campaigncontext.ports.EnemyRepository;
import com.loremind.domain.campaigncontext.ports.NpcRepository; import com.loremind.domain.campaigncontext.ports.NpcRepository;
import com.loremind.domain.campaigncontext.ports.SceneRepository; import com.loremind.domain.campaigncontext.ports.SceneRepository;
import com.loremind.domain.generationcontext.NarrativeEntityContext; import com.loremind.domain.generationcontext.NarrativeEntityContext;
@@ -32,18 +33,21 @@ public class NarrativeEntityContextBuilder {
private final SceneRepository sceneRepository; private final SceneRepository sceneRepository;
private final CharacterRepository characterRepository; private final CharacterRepository characterRepository;
private final NpcRepository npcRepository; private final NpcRepository npcRepository;
private final EnemyRepository enemyRepository;
public NarrativeEntityContextBuilder( public NarrativeEntityContextBuilder(
ArcRepository arcRepository, ArcRepository arcRepository,
ChapterRepository chapterRepository, ChapterRepository chapterRepository,
SceneRepository sceneRepository, SceneRepository sceneRepository,
CharacterRepository characterRepository, CharacterRepository characterRepository,
NpcRepository npcRepository) { NpcRepository npcRepository,
EnemyRepository enemyRepository) {
this.arcRepository = arcRepository; this.arcRepository = arcRepository;
this.chapterRepository = chapterRepository; this.chapterRepository = chapterRepository;
this.sceneRepository = sceneRepository; this.sceneRepository = sceneRepository;
this.characterRepository = characterRepository; this.characterRepository = characterRepository;
this.npcRepository = npcRepository; this.npcRepository = npcRepository;
this.enemyRepository = enemyRepository;
} }
/** /**
@@ -124,10 +128,41 @@ public class NarrativeEntityContextBuilder {
putField(fields, "choicesConsequences", s.getChoicesConsequences()); putField(fields, "choicesConsequences", s.getChoicesConsequences());
putField(fields, "combatDifficulty", s.getCombatDifficulty()); putField(fields, "combatDifficulty", s.getCombatDifficulty());
putField(fields, "enemies", s.getEnemies()); putField(fields, "enemies", s.getEnemies());
putField(fields, "linkedEnemies", resolveLinkedEnemies(s));
putField(fields, "gmSecretNotes", s.getGmSecretNotes()); putField(fields, "gmSecretNotes", s.getGmSecretNotes());
return new NarrativeEntityContext("scene", s.getName(), fields); return new NarrativeEntityContext("scene", s.getName(), fields);
} }
/**
* Résout les fiches du bestiaire référencées par la scène en une ligne par
* ennemi : « Nom (niveau) — champ: valeur ; … ». Valeurs tronquées : le
* contexte focus doit camper la rencontre, pas embarquer la fiche complète.
* Les IDs orphelins (fiche supprimée) sont ignorés silencieusement.
*/
private String resolveLinkedEnemies(Scene s) {
if (s.getEnemyIds() == null || s.getEnemyIds().isEmpty()) return "";
StringBuilder sb = new StringBuilder();
for (String enemyId : s.getEnemyIds()) {
enemyRepository.findById(enemyId).ifPresent(e -> {
if (sb.length() > 0) sb.append("\n");
sb.append("- ").append(e.getName());
if (e.getLevel() != null && !e.getLevel().isBlank()) {
sb.append(" (").append(e.getLevel().trim()).append(")");
}
String stats = e.getValues().entrySet().stream()
.filter(en -> en.getValue() != null && !en.getValue().isBlank())
.map(en -> en.getKey() + ": " + truncate(en.getValue().trim(), 100))
.collect(java.util.stream.Collectors.joining(" ; "));
if (!stats.isEmpty()) sb.append("").append(stats);
});
}
return sb.toString();
}
private static String truncate(String value, int maxLen) {
return value.length() <= maxLen ? value : value.substring(0, maxLen - 1).stripTrailing() + "";
}
private NarrativeEntityContext fromCharacter(Character c) { private NarrativeEntityContext fromCharacter(Character c) {
Map<String, String> fields = new LinkedHashMap<>(); Map<String, String> fields = new LinkedHashMap<>();
if (c.getValues() != null) { if (c.getValues() != null) {

View File

@@ -0,0 +1,70 @@
package com.loremind.domain.campaigncontext;
import lombok.Builder;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* Fiche d'ennemi (monstre/créature) d'une campagne — le bestiaire du MJ.
* <p>
* Même principe de templating que {@link Npc} : champs universels hard-codés
* (nom, niveau, dossier, portrait, bandeau) + champs pilotés par le template
* ENNEMI du GameSystem ({@code GameSystem.enemyTemplate} : CA, PV, attaques…).
* Classement libre par dossier (« Démons », « Humanoïdes »…).
*/
@Data
@Builder
public class Enemy {
private String id;
private String name;
/** Niveau / FP / dangerosité — texte libre (« 5 », « FP 8 », « Boss »). Nullable. */
private String level;
/** Dossier de classement (texte libre). Null = non classé. */
private String folder;
/** ID de l'image portrait (champ universel hard-codé). Nullable. */
private String portraitImageId;
/** ID de l'image header/bannière (champ universel hard-codé). Nullable. */
private String headerImageId;
/** Valeurs TEXT/NUMBER du template ennemi. Jamais null après construction. */
private Map<String, String> values;
/** Valeurs IMAGE du template ennemi (listes d'IDs ordonnées par champ). Jamais null. */
private Map<String, List<String>> imageValues;
/** Valeurs KEY_VALUE_LIST : fieldName -> label -> value. Jamais null. */
private Map<String, Map<String, String>> keyValueValues;
/** Référence vers la Campaign parente (cross-aggregate via ID). */
private String campaignId;
/** Ordre d'affichage dans la liste. */
private int order;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
public Map<String, String> getValues() {
if (values == null) values = new HashMap<>();
return values;
}
public Map<String, List<String>> getImageValues() {
if (imageValues == null) imageValues = new HashMap<>();
return imageValues;
}
public Map<String, Map<String, String>> getKeyValueValues() {
if (keyValueValues == null) keyValueValues = new HashMap<>();
return keyValueValues;
}
}

View File

@@ -38,6 +38,13 @@ public class Room {
/** Énemis, créatures, boss éventuels (markdown libre). */ /** Énemis, créatures, boss éventuels (markdown libre). */
private String enemies; private String enemies;
/**
* IDs des fiches du bestiaire ({@link Enemy}) présentes dans la pièce
* (weak refs). Complète le texte libre {@code enemies}, comme sur Scene.
*/
@Builder.Default
private List<String> enemyIds = new ArrayList<>();
/** Loot / récompenses présentes dans la pièce. */ /** Loot / récompenses présentes dans la pièce. */
private String loot; private String loot;

View File

@@ -40,7 +40,15 @@ public class Scene {
// === Combat ou rencontre === // === Combat ou rencontre ===
private String combatDifficulty; // Difficulté estimée private String combatDifficulty; // Difficulté estimée
private String enemies; // Liste des ennemis et créatures private String enemies; // Liste des ennemis et créatures (texte libre)
/**
* IDs des fiches du bestiaire ({@link Enemy}) engagées dans cette rencontre
* (weak cross-aggregate references). Complète le texte libre `enemies` :
* l'utilisateur peut référencer ses fiches, ou tout écrire à la main, ou les deux.
*/
@Builder.Default
private List<String> enemyIds = new ArrayList<>();
/** /**
* IDs des pages du Lore associées à cette scène (weak cross-context references). * IDs des pages du Lore associées à cette scène (weak cross-context references).

View File

@@ -19,4 +19,7 @@ public interface CharacterRepository {
void deleteById(String id); void deleteById(String id);
boolean existsById(String id); boolean existsById(String id);
/** Recherche par nom (insensible à la casse) — alimente la recherche globale. */
List<Character> searchByName(String query);
} }

View File

@@ -0,0 +1,23 @@
package com.loremind.domain.campaigncontext.ports;
import com.loremind.domain.campaigncontext.Enemy;
import java.util.List;
import java.util.Optional;
/**
* Port de sortie pour la persistance des fiches d'ennemis (bestiaire de campagne).
*/
public interface EnemyRepository {
Enemy save(Enemy enemy);
Optional<Enemy> findById(String id);
List<Enemy> findByCampaignId(String campaignId);
void deleteById(String id);
/** Recherche par nom (insensible à la casse) — alimente la recherche globale. */
List<Enemy> searchByName(String query);
}

View File

@@ -19,4 +19,7 @@ public interface ItemCatalogRepository {
void deleteById(String id); void deleteById(String id);
boolean existsById(String id); boolean existsById(String id);
/** Recherche par nom (insensible à la casse) — alimente la recherche globale. */
List<ItemCatalog> searchByName(String query);
} }

View File

@@ -19,4 +19,7 @@ public interface NpcRepository {
void deleteById(String id); void deleteById(String id);
boolean existsById(String id); boolean existsById(String id);
/** Recherche par nom (insensible à la casse) — alimente la recherche globale. */
List<Npc> searchByName(String query);
} }

View File

@@ -19,4 +19,7 @@ public interface RandomTableRepository {
void deleteById(String id); void deleteById(String id);
boolean existsById(String id); boolean existsById(String id);
/** Recherche par nom (insensible à la casse) — alimente la recherche globale. */
List<RandomTable> searchByName(String query);
} }

View File

@@ -10,6 +10,9 @@ import java.util.List;
*/ */
public interface ConversationTitleGenerator { public interface ConversationTitleGenerator {
/** Renvoie un titre en francais (4-7 mots max). Jamais null ni vide. */ /**
* Renvoie un titre court (4-7 mots max), dans la langue de l'utilisateur
* (relayee au Brain via l'entete X-User-Language). Jamais null ni vide.
*/
String generate(List<ConversationMessage> firstMessages); String generate(List<ConversationMessage> firstMessages);
} }

View File

@@ -50,6 +50,14 @@ public class GameSystem {
*/ */
private List<TemplateField> npcTemplate; private List<TemplateField> npcTemplate;
/**
* Template de fiche ENNEMI (monstres/créatures du bestiaire de campagne).
* Mêmes règles que {@link #characterTemplate} — distinct du template PNJ :
* un ennemi porte des stats de combat (CA, PV, attaques…), pas une
* caractérisation narrative.
*/
private List<TemplateField> enemyTemplate;
/** Auteur déclaré — futur marketplace. Nullable. */ /** Auteur déclaré — futur marketplace. Nullable. */
private String author; private String author;
@@ -98,6 +106,10 @@ public class GameSystem {
npcTemplate = validateAndCopy(fields); npcTemplate = validateAndCopy(fields);
} }
public void replaceEnemyTemplate(List<TemplateField> fields) {
enemyTemplate = validateAndCopy(fields);
}
// --- Helpers privés ---------------------------------------------------- // --- Helpers privés ----------------------------------------------------
private static List<TemplateField> appendField(List<TemplateField> current, TemplateField field) { private static List<TemplateField> appendField(List<TemplateField> current, TemplateField field) {

View File

@@ -52,11 +52,15 @@ public record CampaignStructuralContext(
/** /**
* Résumé d'un arc : nom + description courte + ses chapitres. * Résumé d'un arc : nom + description courte + ses chapitres.
* *
* @param hub true si l'arc est de type HUB : ses chapitres sont des
* « quêtes » parallèles (vocabulaire UI). L'IA doit le savoir
* pour parler de quêtes et cibler le bon arc.
* @param illustrationCount Nombre d'illustrations attachees a cet arc (pour hint dans le prompt IA). * @param illustrationCount Nombre d'illustrations attachees a cet arc (pour hint dans le prompt IA).
*/ */
public record ArcSummary( public record ArcSummary(
String name, String name,
String description, String description,
boolean hub,
int illustrationCount, int illustrationCount,
List<ChapterSummary> chapters) { List<ChapterSummary> chapters) {
} }

View File

@@ -4,6 +4,7 @@ import com.loremind.domain.generationcontext.ChatRequest;
import com.loremind.domain.generationcontext.ChatUsage; import com.loremind.domain.generationcontext.ChatUsage;
import com.loremind.domain.generationcontext.ports.AiChatProvider; import com.loremind.domain.generationcontext.ports.AiChatProvider;
import com.loremind.domain.generationcontext.ports.AiProviderException; import com.loremind.domain.generationcontext.ports.AiProviderException;
import com.loremind.infrastructure.web.config.UserLanguageHolder;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.ParameterizedTypeReference; import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
@@ -64,6 +65,7 @@ public class BrainAiChatClient implements AiChatProvider {
Flux<ServerSentEvent<String>> flux = webClient.post() Flux<ServerSentEvent<String>> flux = webClient.post()
.uri(CHAT_STREAM_PATH) .uri(CHAT_STREAM_PATH)
.header(UserLanguageHolder.HEADER, UserLanguageHolder.get())
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.TEXT_EVENT_STREAM) .accept(MediaType.TEXT_EVENT_STREAM)
.bodyValue(payload) .bodyValue(payload)

View File

@@ -3,6 +3,7 @@ package com.loremind.infrastructure.ai;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.loremind.domain.campaigncontext.ports.CampaignPdfAdvisor; import com.loremind.domain.campaigncontext.ports.CampaignPdfAdvisor;
import com.loremind.infrastructure.web.config.UserLanguageHolder;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.ParameterizedTypeReference; import org.springframework.core.ParameterizedTypeReference;
import org.springframework.core.io.ByteArrayResource; import org.springframework.core.io.ByteArrayResource;
@@ -65,6 +66,7 @@ public class BrainCampaignAdaptClient implements CampaignPdfAdvisor {
Flux<ServerSentEvent<String>> flux = webClient.post() Flux<ServerSentEvent<String>> flux = webClient.post()
.uri(ADAPT_PATH) .uri(ADAPT_PATH)
.header(UserLanguageHolder.HEADER, UserLanguageHolder.get())
.contentType(MediaType.MULTIPART_FORM_DATA) .contentType(MediaType.MULTIPART_FORM_DATA)
.accept(MediaType.TEXT_EVENT_STREAM) .accept(MediaType.TEXT_EVENT_STREAM)
.body(BodyInserters.fromMultipartData(parts.build())) .body(BodyInserters.fromMultipartData(parts.build()))

View File

@@ -11,6 +11,7 @@ import com.loremind.domain.campaigncontext.CampaignImportProposal.RoomProposal;
import com.loremind.domain.campaigncontext.CampaignImportProposal.SceneProposal; import com.loremind.domain.campaigncontext.CampaignImportProposal.SceneProposal;
import com.loremind.domain.campaigncontext.ports.CampaignImportException; import com.loremind.domain.campaigncontext.ports.CampaignImportException;
import com.loremind.domain.campaigncontext.ports.CampaignPdfImporter; import com.loremind.domain.campaigncontext.ports.CampaignPdfImporter;
import com.loremind.infrastructure.web.config.UserLanguageHolder;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.ParameterizedTypeReference; import org.springframework.core.ParameterizedTypeReference;
import org.springframework.core.io.ByteArrayResource; import org.springframework.core.io.ByteArrayResource;
@@ -71,6 +72,7 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
Flux<ServerSentEvent<String>> flux = webClient.post() Flux<ServerSentEvent<String>> flux = webClient.post()
.uri(IMPORT_CAMPAIGN_STREAM_PATH) .uri(IMPORT_CAMPAIGN_STREAM_PATH)
.header(UserLanguageHolder.HEADER, UserLanguageHolder.get())
.contentType(MediaType.MULTIPART_FORM_DATA) .contentType(MediaType.MULTIPART_FORM_DATA)
.accept(MediaType.TEXT_EVENT_STREAM) .accept(MediaType.TEXT_EVENT_STREAM)
.body(BodyInserters.fromMultipartData(parts.build())) .body(BodyInserters.fromMultipartData(parts.build()))

View File

@@ -252,9 +252,15 @@ public class BrainChatPayloadBuilder {
ArcSummary::name, ArcSummary::name,
ArcSummary::description, ArcSummary::description,
ArcSummary::illustrationCount, ArcSummary::illustrationCount,
(map, arc) -> map.put("chapters", arc.chapters().stream() (map, arc) -> {
// Vocabulaire UI : les chapitres d'un arc HUB sont des « quêtes ».
if (arc.hub()) {
map.put("arc_type", "HUB");
}
map.put("chapters", arc.chapters().stream()
.map(this::chapterSummaryToMap) .map(this::chapterSummaryToMap)
.collect(Collectors.toList()))); .collect(Collectors.toList()));
});
} }
private Map<String, Object> chapterSummaryToMap(ChapterSummary c) { private Map<String, Object> chapterSummaryToMap(ChapterSummary c) {

View File

@@ -2,6 +2,7 @@ package com.loremind.infrastructure.ai;
import com.loremind.domain.conversationcontext.ConversationMessage; import com.loremind.domain.conversationcontext.ConversationMessage;
import com.loremind.domain.conversationcontext.ports.ConversationTitleGenerator; import com.loremind.domain.conversationcontext.ports.ConversationTitleGenerator;
import com.loremind.infrastructure.web.config.UserLanguageHolder;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@@ -50,6 +51,7 @@ public class BrainConversationTitleClient implements ConversationTitleGenerator
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
Map<String, Object> resp = webClient.post() Map<String, Object> resp = webClient.post()
.uri(PATH) .uri(PATH)
.header(UserLanguageHolder.HEADER, UserLanguageHolder.get())
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.bodyValue(payload) .bodyValue(payload)
.retrieve() .retrieve()

View File

@@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.loremind.domain.campaigncontext.ports.NotebookChatStreamer; import com.loremind.domain.campaigncontext.ports.NotebookChatStreamer;
import com.loremind.domain.campaigncontext.ports.NotebookException; import com.loremind.domain.campaigncontext.ports.NotebookException;
import com.loremind.infrastructure.web.config.UserLanguageHolder;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.ParameterizedTypeReference; import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
@@ -66,6 +67,7 @@ public class BrainNotebookChatClient implements NotebookChatStreamer {
Flux<ServerSentEvent<String>> flux = webClient.post() Flux<ServerSentEvent<String>> flux = webClient.post()
.uri(deep ? DEEP_PATH : PATH) .uri(deep ? DEEP_PATH : PATH)
.header(UserLanguageHolder.HEADER, UserLanguageHolder.get())
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.TEXT_EVENT_STREAM) .accept(MediaType.TEXT_EVENT_STREAM)
.bodyValue(payload) .bodyValue(payload)

View File

@@ -6,6 +6,7 @@ import com.loremind.domain.gamesystemcontext.RulesImportProgress;
import com.loremind.domain.gamesystemcontext.RulesImportResult; import com.loremind.domain.gamesystemcontext.RulesImportResult;
import com.loremind.domain.gamesystemcontext.ports.RulesImportException; import com.loremind.domain.gamesystemcontext.ports.RulesImportException;
import com.loremind.domain.gamesystemcontext.ports.RulesPdfImporter; import com.loremind.domain.gamesystemcontext.ports.RulesPdfImporter;
import com.loremind.infrastructure.web.config.UserLanguageHolder;
import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.ParameterizedTypeReference; import org.springframework.core.ParameterizedTypeReference;
@@ -125,6 +126,7 @@ public class BrainRulesImportClient implements RulesPdfImporter {
Flux<ServerSentEvent<String>> flux = webClient.post() Flux<ServerSentEvent<String>> flux = webClient.post()
.uri(IMPORT_RULES_STREAM_PATH) .uri(IMPORT_RULES_STREAM_PATH)
.header(UserLanguageHolder.HEADER, UserLanguageHolder.get())
.contentType(MediaType.MULTIPART_FORM_DATA) .contentType(MediaType.MULTIPART_FORM_DATA)
.accept(MediaType.TEXT_EVENT_STREAM) .accept(MediaType.TEXT_EVENT_STREAM)
.body(BodyInserters.fromMultipartData(parts.build())) .body(BodyInserters.fromMultipartData(parts.build()))

View File

@@ -1,5 +1,6 @@
package com.loremind.infrastructure.ai; package com.loremind.infrastructure.ai;
import com.loremind.infrastructure.web.config.UserLanguageHolder;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.web.client.RestTemplateBuilder; import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.boot.web.reactive.function.client.WebClientCustomizer; import org.springframework.boot.web.reactive.function.client.WebClientCustomizer;
@@ -17,6 +18,12 @@ import java.time.Duration;
* <p> * <p>
* Sans cette entete, le Brain refuse la requete (401) — defense contre * Sans cette entete, le Brain refuse la requete (401) — defense contre
* l'acces direct au Brain depuis un attaquant qui atteindrait son port. * l'acces direct au Brain depuis un attaquant qui atteindrait son port.
* <p>
* Relaie aussi l'entete X-User-Language (langue choisie dans l'UI, capturee par
* {@link com.loremind.infrastructure.web.config.UserLanguageFilter}) pour que le
* Brain redige ses reponses IA dans la langue de l'utilisateur. Lu depuis le
* ThreadLocal au moment de l'execution de la requete (thread servlet) — d'ou
* l'usage d'un interceptor (et non d'un defaultHeader fige au demarrage).
*/ */
@Configuration @Configuration
public class RestTemplateConfig { public class RestTemplateConfig {
@@ -36,6 +43,7 @@ public class RestTemplateConfig {
if (internalSecret != null && !internalSecret.isBlank()) { if (internalSecret != null && !internalSecret.isBlank()) {
request.getHeaders().set(INTERNAL_SECRET_HEADER, internalSecret); request.getHeaders().set(INTERNAL_SECRET_HEADER, internalSecret);
} }
request.getHeaders().set(UserLanguageHolder.HEADER, UserLanguageHolder.get());
return execution.execute(request, body); return execution.execute(request, body);
}) })
.build(); .build();
@@ -59,6 +67,7 @@ public class RestTemplateConfig {
if (internalSecret != null && !internalSecret.isBlank()) { if (internalSecret != null && !internalSecret.isBlank()) {
request.getHeaders().set(INTERNAL_SECRET_HEADER, internalSecret); request.getHeaders().set(INTERNAL_SECRET_HEADER, internalSecret);
} }
request.getHeaders().set(UserLanguageHolder.HEADER, UserLanguageHolder.get());
return execution.execute(request, body); return execution.execute(request, body);
}) })
.build(); .build();

View File

@@ -0,0 +1,88 @@
package com.loremind.infrastructure.persistence.entity;
import com.loremind.infrastructure.persistence.converter.StringListMapJsonConverter;
import com.loremind.infrastructure.persistence.converter.StringMapJsonConverter;
import com.loremind.infrastructure.persistence.converter.StringMapMapJsonConverter;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* Entité JPA des fiches d'ennemis (bestiaire). Mêmes règles que NpcJpaEntity.
*/
@Entity
@Table(name = "enemies", indexes = {
@Index(name = "idx_enemies_campaign_id", columnList = "campaign_id")
})
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class EnemyJpaEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
/** Niveau / FP — texte libre. Nullable. */
@Column(name = "level")
private String level;
/** Dossier de classement (« Démons », « Humanoïdes »…). Nullable = non classé. */
@Column(name = "folder")
private String folder;
@Column(name = "portrait_image_id")
private String portraitImageId;
@Column(name = "header_image_id")
private String headerImageId;
@Convert(converter = StringMapJsonConverter.class)
@Column(name = "field_values", columnDefinition = "TEXT")
private Map<String, String> values;
@Convert(converter = StringListMapJsonConverter.class)
@Column(name = "image_values", columnDefinition = "TEXT")
private Map<String, List<String>> imageValues;
@Convert(converter = StringMapMapJsonConverter.class)
@Column(name = "key_value_values", columnDefinition = "TEXT")
private Map<String, Map<String, String>> keyValueValues;
@Column(name = "campaign_id", nullable = false)
private Long campaignId;
@Column(name = "\"order\"", nullable = false)
private int order;
@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();
if (values == null) values = new HashMap<>();
if (imageValues == null) imageValues = new HashMap<>();
if (keyValueValues == null) keyValueValues = new HashMap<>();
}
@PreUpdate
protected void onUpdate() {
updatedAt = LocalDateTime.now();
}
}

View File

@@ -46,6 +46,11 @@ public class GameSystemJpaEntity {
@Column(name = "npc_template", columnDefinition = "TEXT") @Column(name = "npc_template", columnDefinition = "TEXT")
private List<TemplateField> npcTemplate; private List<TemplateField> npcTemplate;
/** Template ENNEMI (bestiaire) serialise en JSON. */
@Convert(converter = TemplateFieldListJsonConverter.class)
@Column(name = "enemy_template", columnDefinition = "TEXT")
private List<TemplateField> enemyTemplate;
@Column @Column
private String author; private String author;
@@ -64,6 +69,7 @@ public class GameSystemJpaEntity {
updatedAt = LocalDateTime.now(); updatedAt = LocalDateTime.now();
if (characterTemplate == null) characterTemplate = new ArrayList<>(); if (characterTemplate == null) characterTemplate = new ArrayList<>();
if (npcTemplate == null) npcTemplate = new ArrayList<>(); if (npcTemplate == null) npcTemplate = new ArrayList<>();
if (enemyTemplate == null) enemyTemplate = new ArrayList<>();
} }
@PreUpdate @PreUpdate

View File

@@ -75,6 +75,12 @@ public class SceneJpaEntity {
@Column(columnDefinition = "TEXT") @Column(columnDefinition = "TEXT")
private String enemies; private String enemies;
/** IDs des fiches du bestiaire liées à la rencontre (JSON, weak refs). */
@Column(name = "enemy_ids", columnDefinition = "TEXT")
@Convert(converter = StringListJsonConverter.class)
@Builder.Default
private List<String> enemyIds = new ArrayList<>();
@Column(name = "related_page_ids", columnDefinition = "TEXT") @Column(name = "related_page_ids", columnDefinition = "TEXT")
@Convert(converter = StringListJsonConverter.class) @Convert(converter = StringListJsonConverter.class)
@Builder.Default @Builder.Default

View File

@@ -10,4 +10,7 @@ import java.util.List;
public interface CharacterJpaRepository extends JpaRepository<CharacterJpaEntity, Long> { public interface CharacterJpaRepository extends JpaRepository<CharacterJpaEntity, Long> {
List<CharacterJpaEntity> findByPlaythroughIdOrderByOrderAsc(Long playthroughId); List<CharacterJpaEntity> findByPlaythroughIdOrderByOrderAsc(Long playthroughId);
/** Recherche globale : bornée pour ne jamais inonder la palette de résultats. */
List<CharacterJpaEntity> findTop20ByNameContainingIgnoreCaseOrderByNameAsc(String name);
} }

View File

@@ -0,0 +1,16 @@
package com.loremind.infrastructure.persistence.jpa;
import com.loremind.infrastructure.persistence.entity.EnemyJpaEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface EnemyJpaRepository extends JpaRepository<EnemyJpaEntity, Long> {
List<EnemyJpaEntity> findByCampaignIdOrderByOrderAsc(Long campaignId);
/** Recherche globale : bornée pour ne jamais inonder la palette de résultats. */
List<EnemyJpaEntity> findTop20ByNameContainingIgnoreCaseOrderByNameAsc(String name);
}

View File

@@ -10,4 +10,7 @@ import java.util.List;
public interface ItemCatalogJpaRepository extends JpaRepository<ItemCatalogJpaEntity, Long> { public interface ItemCatalogJpaRepository extends JpaRepository<ItemCatalogJpaEntity, Long> {
List<ItemCatalogJpaEntity> findByCampaignIdOrderByOrderAsc(Long campaignId); List<ItemCatalogJpaEntity> findByCampaignIdOrderByOrderAsc(Long campaignId);
/** Recherche globale : bornée pour ne jamais inonder la palette de résultats. */
List<ItemCatalogJpaEntity> findTop20ByNameContainingIgnoreCaseOrderByNameAsc(String name);
} }

View File

@@ -10,4 +10,7 @@ import java.util.List;
public interface NpcJpaRepository extends JpaRepository<NpcJpaEntity, Long> { public interface NpcJpaRepository extends JpaRepository<NpcJpaEntity, Long> {
List<NpcJpaEntity> findByCampaignIdOrderByOrderAsc(Long campaignId); List<NpcJpaEntity> findByCampaignIdOrderByOrderAsc(Long campaignId);
/** Recherche globale : bornée pour ne jamais inonder la palette de résultats. */
List<NpcJpaEntity> findTop20ByNameContainingIgnoreCaseOrderByNameAsc(String name);
} }

View File

@@ -10,4 +10,7 @@ import java.util.List;
public interface RandomTableJpaRepository extends JpaRepository<RandomTableJpaEntity, Long> { public interface RandomTableJpaRepository extends JpaRepository<RandomTableJpaEntity, Long> {
List<RandomTableJpaEntity> findByCampaignIdOrderByOrderAsc(Long campaignId); List<RandomTableJpaEntity> findByCampaignIdOrderByOrderAsc(Long campaignId);
/** Recherche globale : bornée pour ne jamais inonder la palette de résultats. */
List<RandomTableJpaEntity> findTop20ByNameContainingIgnoreCaseOrderByNameAsc(String name);
} }

View File

@@ -49,6 +49,13 @@ public class PostgresCharacterRepository implements CharacterRepository {
return jpaRepository.existsById(Long.parseLong(id)); return jpaRepository.existsById(Long.parseLong(id));
} }
@Override
public List<Character> searchByName(String query) {
return jpaRepository.findTop20ByNameContainingIgnoreCaseOrderByNameAsc(query).stream()
.map(this::toDomainEntity)
.collect(Collectors.toList());
}
private Character toDomainEntity(CharacterJpaEntity e) { private Character toDomainEntity(CharacterJpaEntity e) {
return Character.builder() return Character.builder()
.id(e.getId().toString()) .id(e.getId().toString())

View File

@@ -0,0 +1,87 @@
package com.loremind.infrastructure.persistence.postgres;
import com.loremind.domain.campaigncontext.Enemy;
import com.loremind.domain.campaigncontext.ports.EnemyRepository;
import com.loremind.infrastructure.persistence.entity.EnemyJpaEntity;
import com.loremind.infrastructure.persistence.jpa.EnemyJpaRepository;
import org.springframework.stereotype.Repository;
import java.util.HashMap;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
@Repository
public class PostgresEnemyRepository implements EnemyRepository {
private final EnemyJpaRepository jpaRepository;
public PostgresEnemyRepository(EnemyJpaRepository jpaRepository) {
this.jpaRepository = jpaRepository;
}
@Override
public Enemy save(Enemy enemy) {
return toDomainEntity(jpaRepository.save(toJpaEntity(enemy)));
}
@Override
public Optional<Enemy> findById(String id) {
return jpaRepository.findById(Long.parseLong(id)).map(this::toDomainEntity);
}
@Override
public List<Enemy> findByCampaignId(String campaignId) {
return jpaRepository.findByCampaignIdOrderByOrderAsc(Long.parseLong(campaignId)).stream()
.map(this::toDomainEntity)
.collect(Collectors.toList());
}
@Override
public void deleteById(String id) {
jpaRepository.deleteById(Long.parseLong(id));
}
@Override
public List<Enemy> searchByName(String query) {
return jpaRepository.findTop20ByNameContainingIgnoreCaseOrderByNameAsc(query).stream()
.map(this::toDomainEntity)
.collect(Collectors.toList());
}
private Enemy toDomainEntity(EnemyJpaEntity e) {
return Enemy.builder()
.id(e.getId().toString())
.name(e.getName())
.level(e.getLevel())
.folder(e.getFolder())
.portraitImageId(e.getPortraitImageId())
.headerImageId(e.getHeaderImageId())
.values(e.getValues() != null ? new HashMap<>(e.getValues()) : new HashMap<>())
.imageValues(e.getImageValues() != null ? new HashMap<>(e.getImageValues()) : new HashMap<>())
.keyValueValues(e.getKeyValueValues() != null ? new HashMap<>(e.getKeyValueValues()) : new HashMap<>())
.campaignId(e.getCampaignId().toString())
.order(e.getOrder())
.createdAt(e.getCreatedAt())
.updatedAt(e.getUpdatedAt())
.build();
}
private EnemyJpaEntity toJpaEntity(Enemy n) {
return EnemyJpaEntity.builder()
.id(n.getId() != null ? Long.parseLong(n.getId()) : null)
.name(n.getName())
.level(n.getLevel())
.folder(n.getFolder())
.portraitImageId(n.getPortraitImageId())
.headerImageId(n.getHeaderImageId())
.values(n.getValues() != null ? new HashMap<>(n.getValues()) : new HashMap<>())
.imageValues(n.getImageValues() != null ? new HashMap<>(n.getImageValues()) : new HashMap<>())
.keyValueValues(n.getKeyValueValues() != null ? new HashMap<>(n.getKeyValueValues()) : new HashMap<>())
.campaignId(Long.parseLong(n.getCampaignId()))
.order(n.getOrder())
.createdAt(n.getCreatedAt())
.updatedAt(n.getUpdatedAt())
.build();
}
}

View File

@@ -67,6 +67,9 @@ public class PostgresGameSystemRepository implements GameSystemRepository {
.npcTemplate(e.getNpcTemplate() != null .npcTemplate(e.getNpcTemplate() != null
? new java.util.ArrayList<>(e.getNpcTemplate()) ? new java.util.ArrayList<>(e.getNpcTemplate())
: new java.util.ArrayList<>()) : new java.util.ArrayList<>())
.enemyTemplate(e.getEnemyTemplate() != null
? new java.util.ArrayList<>(e.getEnemyTemplate())
: new java.util.ArrayList<>())
.author(e.getAuthor()) .author(e.getAuthor())
.isPublic(e.isPublic()) .isPublic(e.isPublic())
.createdAt(e.getCreatedAt()) .createdAt(e.getCreatedAt())
@@ -87,6 +90,9 @@ public class PostgresGameSystemRepository implements GameSystemRepository {
.npcTemplate(g.getNpcTemplate() != null .npcTemplate(g.getNpcTemplate() != null
? new java.util.ArrayList<>(g.getNpcTemplate()) ? new java.util.ArrayList<>(g.getNpcTemplate())
: new java.util.ArrayList<>()) : new java.util.ArrayList<>())
.enemyTemplate(g.getEnemyTemplate() != null
? new java.util.ArrayList<>(g.getEnemyTemplate())
: new java.util.ArrayList<>())
.author(g.getAuthor()) .author(g.getAuthor())
.isPublic(g.isPublic()) .isPublic(g.isPublic())
.createdAt(g.getCreatedAt()) .createdAt(g.getCreatedAt())

View File

@@ -77,6 +77,14 @@ public class PostgresItemCatalogRepository implements ItemCatalogRepository {
return jpaRepository.existsById(Long.parseLong(id)); return jpaRepository.existsById(Long.parseLong(id));
} }
@Override
@Transactional(readOnly = true)
public List<ItemCatalog> searchByName(String query) {
return jpaRepository.findTop20ByNameContainingIgnoreCaseOrderByNameAsc(query).stream()
.map(this::toDomainEntity)
.collect(Collectors.toList());
}
private ItemCatalog toDomainEntity(ItemCatalogJpaEntity e) { private ItemCatalog toDomainEntity(ItemCatalogJpaEntity e) {
List<CatalogItem> items = e.getItems().stream() List<CatalogItem> items = e.getItems().stream()
.map(c -> CatalogItem.builder() .map(c -> CatalogItem.builder()

View File

@@ -50,6 +50,13 @@ public class PostgresNpcRepository implements NpcRepository {
return jpaRepository.existsById(Long.parseLong(id)); return jpaRepository.existsById(Long.parseLong(id));
} }
@Override
public List<Npc> searchByName(String query) {
return jpaRepository.findTop20ByNameContainingIgnoreCaseOrderByNameAsc(query).stream()
.map(this::toDomainEntity)
.collect(Collectors.toList());
}
private Npc toDomainEntity(NpcJpaEntity e) { private Npc toDomainEntity(NpcJpaEntity e) {
return Npc.builder() return Npc.builder()
.id(e.getId().toString()) .id(e.getId().toString())

View File

@@ -81,6 +81,14 @@ public class PostgresRandomTableRepository implements RandomTableRepository {
return jpaRepository.existsById(Long.parseLong(id)); return jpaRepository.existsById(Long.parseLong(id));
} }
@Override
@Transactional(readOnly = true)
public List<RandomTable> searchByName(String query) {
return jpaRepository.findTop20ByNameContainingIgnoreCaseOrderByNameAsc(query).stream()
.map(this::toDomainEntity)
.collect(Collectors.toList());
}
private RandomTable toDomainEntity(RandomTableJpaEntity e) { private RandomTable toDomainEntity(RandomTableJpaEntity e) {
List<RandomTableEntry> entries = e.getEntries().stream() List<RandomTableEntry> entries = e.getEntries().stream()
.map(c -> RandomTableEntry.builder() .map(c -> RandomTableEntry.builder()

View File

@@ -80,6 +80,9 @@ public class PostgresSceneRepository implements SceneRepository {
.choicesConsequences(jpaEntity.getChoicesConsequences()) .choicesConsequences(jpaEntity.getChoicesConsequences())
.combatDifficulty(jpaEntity.getCombatDifficulty()) .combatDifficulty(jpaEntity.getCombatDifficulty())
.enemies(jpaEntity.getEnemies()) .enemies(jpaEntity.getEnemies())
.enemyIds(jpaEntity.getEnemyIds() != null
? new ArrayList<>(jpaEntity.getEnemyIds())
: new ArrayList<>())
.relatedPageIds(jpaEntity.getRelatedPageIds() != null .relatedPageIds(jpaEntity.getRelatedPageIds() != null
? new ArrayList<>(jpaEntity.getRelatedPageIds()) ? new ArrayList<>(jpaEntity.getRelatedPageIds())
: new ArrayList<>()) : new ArrayList<>())
@@ -117,6 +120,9 @@ public class PostgresSceneRepository implements SceneRepository {
.choicesConsequences(scene.getChoicesConsequences()) .choicesConsequences(scene.getChoicesConsequences())
.combatDifficulty(scene.getCombatDifficulty()) .combatDifficulty(scene.getCombatDifficulty())
.enemies(scene.getEnemies()) .enemies(scene.getEnemies())
.enemyIds(scene.getEnemyIds() != null
? new ArrayList<>(scene.getEnemyIds())
: new ArrayList<>())
.relatedPageIds(scene.getRelatedPageIds() != null .relatedPageIds(scene.getRelatedPageIds() != null
? new ArrayList<>(scene.getRelatedPageIds()) ? new ArrayList<>(scene.getRelatedPageIds())
: new ArrayList<>()) : new ArrayList<>())

View File

@@ -11,6 +11,7 @@ import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.context.request.async.AsyncRequestNotUsableException; import org.springframework.web.context.request.async.AsyncRequestNotUsableException;
import org.springframework.web.server.ResponseStatusException;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.Map; import java.util.Map;
@@ -71,6 +72,21 @@ public class GlobalExceptionHandler {
)); ));
} }
/**
* Statut HTTP explicitement choisi par un controller via {@link ResponseStatusException}
* (ex: {@code NotebookController} -> 404 si notebook introuvable, 502 si Brain injoignable).
* <p>
* SANS ce handler, le fallback {@code @ExceptionHandler(Throwable.class)} ci-dessous
* interceptait ces exceptions et renvoyait 500 — ecrasant le statut voulu (le
* resolver natif de Spring est court-circuite des qu'un advice gere Throwable).
*/
@ExceptionHandler(ResponseStatusException.class)
public ResponseEntity<Map<String, String>> handleResponseStatus(ResponseStatusException ex) {
String reason = ex.getReason();
return ResponseEntity.status(ex.getStatusCode())
.body(Map.of("error", reason != null ? reason : ex.getStatusCode().toString()));
}
/** /**
* Client HTTP parti pendant une reponse asynchrone (SSE) : le navigateur a ferme * Client HTTP parti pendant une reponse asynchrone (SSE) : le navigateur a ferme
* la connexion (onglet ferme, proxy coupe...), la reponse n'est plus utilisable. * la connexion (onglet ferme, proxy coupe...), la reponse n'est plus utilisable.

View File

@@ -0,0 +1,40 @@
package com.loremind.infrastructure.web.config;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
/**
* Capture la langue de l'utilisateur (entête {@code X-User-Language} envoyé par le
* frontend) dans {@link UserLanguageHolder} pour la durée de la requête, puis la
* nettoie systématiquement.
* <p>
* Les clients du Brain liront ce ThreadLocal au moment de construire leur appel
* (sur ce même thread servlet) pour relayer la langue au Brain. Indispensable de
* {@code clear()} en {@code finally} : les threads servlet sont recyclés dans un
* pool, une valeur oubliée fuiterait sur la requête suivante.
*/
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class UserLanguageFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
try {
UserLanguageHolder.set(request.getHeader(UserLanguageHolder.HEADER));
filterChain.doFilter(request, response);
} finally {
UserLanguageHolder.clear();
}
}
}

View File

@@ -0,0 +1,58 @@
package com.loremind.infrastructure.web.config;
import java.util.Set;
/**
* Langue de l'utilisateur courant, portée par un ThreadLocal le temps d'une
* requête HTTP entrante.
* <p>
* Le frontend Angular envoie son choix de langue (code court {@code fr}/{@code en})
* via l'entête {@code X-User-Language}. {@link UserLanguageFilter} la capture ici,
* et les clients du Brain ({@code RestTemplateConfig} pour les appels bloquants,
* les clients WebClient pour le streaming) la relaient au Brain — qui rédige alors
* ses réponses IA dans cette langue.
* <p>
* Repli systématique sur le français si rien n'est fourni (vieux client, appel interne).
*/
public final class UserLanguageHolder {
/** Nom de l'entête HTTP relayant la langue, du frontend jusqu'au Brain. */
public static final String HEADER = "X-User-Language";
/** Langue par défaut quand l'entête est absent ou non reconnu. */
public static final String DEFAULT = "fr";
/** Langues supportées (alignées sur LanguageService Angular et NAMES côté Brain). */
private static final Set<String> SUPPORTED = Set.of("fr", "en");
private static final ThreadLocal<String> CURRENT = ThreadLocal.withInitial(() -> DEFAULT);
private UserLanguageHolder() {
}
/**
* Normalise un code/entête langue arbitraire vers un code supporté.
* Tolère la casse, les variantes régionales ({@code en-US}) et un
* {@code Accept-Language} complet ({@code fr-FR,fr;q=0.9}). Repli {@code DEFAULT}.
*/
public static String normalize(String raw) {
if (raw == null || raw.isBlank()) {
return DEFAULT;
}
String primary = raw.split(",")[0].split(";")[0].trim().toLowerCase();
String base = primary.split("-")[0];
return SUPPORTED.contains(base) ? base : DEFAULT;
}
public static void set(String language) {
CURRENT.set(normalize(language));
}
public static String get() {
return CURRENT.get();
}
public static void clear() {
CURRENT.remove();
}
}

View File

@@ -16,10 +16,13 @@ public class CharacterController {
private final CharacterService characterService; private final CharacterService characterService;
private final CharacterMapper characterMapper; private final CharacterMapper characterMapper;
private final com.loremind.domain.playcontext.ports.PlaythroughRepository playthroughRepository;
public CharacterController(CharacterService characterService, CharacterMapper characterMapper) { public CharacterController(CharacterService characterService, CharacterMapper characterMapper,
com.loremind.domain.playcontext.ports.PlaythroughRepository playthroughRepository) {
this.characterService = characterService; this.characterService = characterService;
this.characterMapper = characterMapper; this.characterMapper = characterMapper;
this.playthroughRepository = playthroughRepository;
} }
@PostMapping @PostMapping
@@ -43,6 +46,31 @@ public class CharacterController {
return ResponseEntity.ok(dtos); return ResponseEntity.ok(dtos);
} }
/**
* Recherche par nom — alimente la recherche globale (Ctrl+K). Le résultat est
* enrichi du campaignId (résolu via le Playthrough) pour que le front puisse
* construire la route /campaigns/{c}/playthroughs/{p}/characters/{id}.
*/
@GetMapping("/search")
public ResponseEntity<List<CharacterSearchDTO>> search(@RequestParam("q") String query) {
List<CharacterSearchDTO> out = characterService.searchCharacters(query).stream()
.map(c -> new CharacterSearchDTO(
c.getId(),
c.getName(),
c.getPlaythroughId(),
c.getPlaythroughId() != null
? playthroughRepository.findById(c.getPlaythroughId())
.map(com.loremind.domain.playcontext.Playthrough::getCampaignId)
.orElse(null)
: null))
.filter(r -> r.campaignId() != null) // PJ orphelin (legacy) : non navigable → exclu
.collect(Collectors.toList());
return ResponseEntity.ok(out);
}
/** Résultat de recherche d'un PJ, enrichi pour la navigation. */
public record CharacterSearchDTO(String id, String name, String playthroughId, String campaignId) {}
@PutMapping("/{id}") @PutMapping("/{id}")
public ResponseEntity<CharacterDTO> updateCharacter(@PathVariable String id, @RequestBody CharacterDTO dto) { public ResponseEntity<CharacterDTO> updateCharacter(@PathVariable String id, @RequestBody CharacterDTO dto) {
Character updated = characterService.updateCharacter(id, toData(dto, dto.getOrder())); Character updated = characterService.updateCharacter(id, toData(dto, dto.getOrder()));

View File

@@ -0,0 +1,79 @@
package com.loremind.infrastructure.web.controller;
import com.loremind.application.campaigncontext.EnemyService;
import com.loremind.domain.campaigncontext.Enemy;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
/**
* REST Controller des fiches d'ennemis (bestiaire de campagne).
* Réponses = domaine {@link Enemy} sérialisé tel quel (Lombok @Data) ;
* requêtes = record dédié (le domaine n'a pas de constructeur no-args).
*/
@RestController
@RequestMapping("/api/enemies")
public class EnemyController {
private final EnemyService enemyService;
public EnemyController(EnemyService enemyService) {
this.enemyService = enemyService;
}
@PostMapping
public ResponseEntity<Enemy> create(@RequestBody EnemyRequest req) {
return ResponseEntity.ok(enemyService.createEnemy(toData(req)));
}
@GetMapping("/{id}")
public ResponseEntity<Enemy> getById(@PathVariable String id) {
return enemyService.getEnemyById(id)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
@GetMapping("/campaign/{campaignId}")
public ResponseEntity<List<Enemy>> getByCampaign(@PathVariable String campaignId) {
return ResponseEntity.ok(enemyService.getEnemiesByCampaignId(campaignId));
}
/** Recherche par nom — alimente la recherche globale (Ctrl+K). */
@GetMapping("/search")
public ResponseEntity<List<Enemy>> search(@RequestParam("q") String query) {
return ResponseEntity.ok(enemyService.searchEnemies(query));
}
@PutMapping("/{id}")
public ResponseEntity<Enemy> update(@PathVariable String id, @RequestBody EnemyRequest req) {
return ResponseEntity.ok(enemyService.updateEnemy(id, toData(req)));
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable String id) {
enemyService.deleteEnemy(id);
return ResponseEntity.noContent().build();
}
private EnemyService.EnemyData toData(EnemyRequest req) {
return new EnemyService.EnemyData(
req.name(), req.level(), req.folder(),
req.portraitImageId(), req.headerImageId(),
req.values(), req.imageValues(), req.keyValueValues(),
req.campaignId(), req.order());
}
public record EnemyRequest(
String name,
String level,
String folder,
String portraitImageId,
String headerImageId,
Map<String, String> values,
Map<String, List<String>> imageValues,
Map<String, Map<String, String>> keyValueValues,
String campaignId,
Integer order) {}
}

View File

@@ -263,6 +263,7 @@ public class GameSystemController {
dto.getRulesMarkdown(), dto.getRulesMarkdown(),
toDomainFields(dto.getCharacterTemplate()), toDomainFields(dto.getCharacterTemplate()),
toDomainFields(dto.getNpcTemplate()), toDomainFields(dto.getNpcTemplate()),
toDomainFields(dto.getEnemyTemplate()),
dto.getAuthor(), dto.getAuthor(),
dto.isPublic() dto.isPublic()
); );

View File

@@ -58,6 +58,14 @@ public class ItemCatalogController {
return ResponseEntity.noContent().build(); return ResponseEntity.noContent().build();
} }
/** Recherche par nom — alimente la recherche globale (Ctrl+K). */
@GetMapping("/search")
public ResponseEntity<List<ItemCatalogDTO>> search(@RequestParam("q") String query) {
return ResponseEntity.ok(service.searchCatalogs(query).stream()
.map(mapper::toDTO)
.collect(java.util.stream.Collectors.toList()));
}
/** Génère une PROPOSITION de catalogue via l'IA (non persistée) — l'UI préremplit le formulaire. */ /** Génère une PROPOSITION de catalogue via l'IA (non persistée) — l'UI préremplit le formulaire. */
@PostMapping("/generate") @PostMapping("/generate")
public ResponseEntity<ItemCatalogDTO> generate(@RequestBody GenerateRequest req) { public ResponseEntity<ItemCatalogDTO> generate(@RequestBody GenerateRequest req) {

View File

@@ -43,6 +43,15 @@ public class NpcController {
return ResponseEntity.ok(dtos); return ResponseEntity.ok(dtos);
} }
/** Recherche par nom — alimente la recherche globale (Ctrl+K). */
@GetMapping("/search")
public ResponseEntity<List<NpcDTO>> search(@RequestParam("q") String query) {
List<NpcDTO> dtos = npcService.searchNpcs(query).stream()
.map(npcMapper::toDTO)
.collect(Collectors.toList());
return ResponseEntity.ok(dtos);
}
/** PNJ de toutes les campagnes liées au Lore donné — alimente le graphe du Lore. */ /** PNJ de toutes les campagnes liées au Lore donné — alimente le graphe du Lore. */
@GetMapping("/lore/{loreId}") @GetMapping("/lore/{loreId}")
public ResponseEntity<List<NpcDTO>> getNpcsByLore(@PathVariable String loreId) { public ResponseEntity<List<NpcDTO>> getNpcsByLore(@PathVariable String loreId) {

View File

@@ -59,6 +59,14 @@ public class RandomTableController {
return ResponseEntity.noContent().build(); return ResponseEntity.noContent().build();
} }
/** Recherche par nom — alimente la recherche globale (Ctrl+K). */
@GetMapping("/search")
public ResponseEntity<List<RandomTableDTO>> search(@RequestParam("q") String query) {
return ResponseEntity.ok(service.searchTables(query).stream()
.map(mapper::toDTO)
.collect(java.util.stream.Collectors.toList()));
}
/** Génère une PROPOSITION de table via l'IA (non persistée) — l'UI préremplit le formulaire. */ /** Génère une PROPOSITION de table via l'IA (non persistée) — l'UI préremplit le formulaire. */
@PostMapping("/generate") @PostMapping("/generate")
public ResponseEntity<RandomTableDTO> generate(@RequestBody GenerateRequest req) { public ResponseEntity<RandomTableDTO> generate(@RequestBody GenerateRequest req) {

View File

@@ -16,6 +16,8 @@ public class RoomDTO {
private String name; private String name;
private String description; private String description;
private String enemies; private String enemies;
/** IDs des fiches du bestiaire présentes dans la pièce (weak refs). */
private List<String> enemyIds = new ArrayList<>();
private String loot; private String loot;
private String traps; private String traps;
private String gmNotes; private String gmNotes;

View File

@@ -30,6 +30,9 @@ public class SceneDTO {
private String combatDifficulty; private String combatDifficulty;
private String enemies; private String enemies;
/** IDs des fiches du bestiaire engagées dans la rencontre (weak refs). */
private List<String> enemyIds = new ArrayList<>();
/** IDs des pages du Lore liées (weak cross-context references). */ /** IDs des pages du Lore liées (weak cross-context references). */
private List<String> relatedPageIds = new ArrayList<>(); private List<String> relatedPageIds = new ArrayList<>();

View File

@@ -19,6 +19,7 @@ public class GameSystemDTO {
private String rulesMarkdown; private String rulesMarkdown;
private List<TemplateFieldDTO> characterTemplate = new ArrayList<>(); private List<TemplateFieldDTO> characterTemplate = new ArrayList<>();
private List<TemplateFieldDTO> npcTemplate = new ArrayList<>(); private List<TemplateFieldDTO> npcTemplate = new ArrayList<>();
private List<TemplateFieldDTO> enemyTemplate = new ArrayList<>();
private String author; private String author;
private boolean isPublic; private boolean isPublic;
} }

View File

@@ -27,6 +27,7 @@ public class GameSystemMapper {
dto.setRulesMarkdown(g.getRulesMarkdown()); dto.setRulesMarkdown(g.getRulesMarkdown());
dto.setCharacterTemplate(toDTOList(g.getCharacterTemplate())); dto.setCharacterTemplate(toDTOList(g.getCharacterTemplate()));
dto.setNpcTemplate(toDTOList(g.getNpcTemplate())); dto.setNpcTemplate(toDTOList(g.getNpcTemplate()));
dto.setEnemyTemplate(toDTOList(g.getEnemyTemplate()));
dto.setAuthor(g.getAuthor()); dto.setAuthor(g.getAuthor());
dto.setPublic(g.isPublic()); dto.setPublic(g.isPublic());
return dto; return dto;
@@ -41,6 +42,7 @@ public class GameSystemMapper {
.rulesMarkdown(dto.getRulesMarkdown()) .rulesMarkdown(dto.getRulesMarkdown())
.characterTemplate(toDomainList(dto.getCharacterTemplate())) .characterTemplate(toDomainList(dto.getCharacterTemplate()))
.npcTemplate(toDomainList(dto.getNpcTemplate())) .npcTemplate(toDomainList(dto.getNpcTemplate()))
.enemyTemplate(toDomainList(dto.getEnemyTemplate()))
.author(dto.getAuthor()) .author(dto.getAuthor())
.isPublic(dto.isPublic()) .isPublic(dto.isPublic())
.build(); .build();

View File

@@ -40,6 +40,9 @@ public class SceneMapper {
dto.setChoicesConsequences(scene.getChoicesConsequences()); dto.setChoicesConsequences(scene.getChoicesConsequences());
dto.setCombatDifficulty(scene.getCombatDifficulty()); dto.setCombatDifficulty(scene.getCombatDifficulty());
dto.setEnemies(scene.getEnemies()); dto.setEnemies(scene.getEnemies());
dto.setEnemyIds(scene.getEnemyIds() != null
? new ArrayList<>(scene.getEnemyIds())
: new ArrayList<>());
dto.setRelatedPageIds(scene.getRelatedPageIds() != null dto.setRelatedPageIds(scene.getRelatedPageIds() != null
? new ArrayList<>(scene.getRelatedPageIds()) ? new ArrayList<>(scene.getRelatedPageIds())
: new ArrayList<>()); : new ArrayList<>());
@@ -74,6 +77,9 @@ public class SceneMapper {
.choicesConsequences(dto.getChoicesConsequences()) .choicesConsequences(dto.getChoicesConsequences())
.combatDifficulty(dto.getCombatDifficulty()) .combatDifficulty(dto.getCombatDifficulty())
.enemies(dto.getEnemies()) .enemies(dto.getEnemies())
.enemyIds(dto.getEnemyIds() != null
? new ArrayList<>(dto.getEnemyIds())
: new ArrayList<>())
.relatedPageIds(dto.getRelatedPageIds() != null .relatedPageIds(dto.getRelatedPageIds() != null
? new ArrayList<>(dto.getRelatedPageIds()) ? new ArrayList<>(dto.getRelatedPageIds())
: new ArrayList<>()) : new ArrayList<>())
@@ -117,6 +123,9 @@ public class SceneMapper {
dto.setName(r.getName()); dto.setName(r.getName());
dto.setDescription(r.getDescription()); dto.setDescription(r.getDescription());
dto.setEnemies(r.getEnemies()); dto.setEnemies(r.getEnemies());
dto.setEnemyIds(r.getEnemyIds() != null
? new ArrayList<>(r.getEnemyIds())
: new ArrayList<>());
dto.setLoot(r.getLoot()); dto.setLoot(r.getLoot());
dto.setTraps(r.getTraps()); dto.setTraps(r.getTraps());
dto.setGmNotes(r.getGmNotes()); dto.setGmNotes(r.getGmNotes());
@@ -145,6 +154,9 @@ public class SceneMapper {
.name(d.getName()) .name(d.getName())
.description(d.getDescription()) .description(d.getDescription())
.enemies(d.getEnemies()) .enemies(d.getEnemies())
.enemyIds(d.getEnemyIds() != null
? new ArrayList<>(d.getEnemyIds())
: new ArrayList<>())
.loot(d.getLoot()) .loot(d.getLoot())
.traps(d.getTraps()) .traps(d.getTraps())
.gmNotes(d.getGmNotes()) .gmNotes(d.getGmNotes())

View File

@@ -11,6 +11,7 @@ import com.loremind.domain.campaigncontext.ports.ArcRepository;
import com.loremind.domain.campaigncontext.ports.CampaignRepository; import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.campaigncontext.ports.ChapterRepository; import com.loremind.domain.campaigncontext.ports.ChapterRepository;
import com.loremind.domain.campaigncontext.ports.CharacterRepository; import com.loremind.domain.campaigncontext.ports.CharacterRepository;
import com.loremind.domain.campaigncontext.ports.EnemyRepository;
import com.loremind.domain.campaigncontext.ports.NpcRepository; import com.loremind.domain.campaigncontext.ports.NpcRepository;
import com.loremind.domain.campaigncontext.ports.SceneRepository; import com.loremind.domain.campaigncontext.ports.SceneRepository;
import com.loremind.domain.generationcontext.CampaignStructuralContext; import com.loremind.domain.generationcontext.CampaignStructuralContext;
@@ -48,6 +49,8 @@ public class CampaignStructuralContextBuilderTest {
private CharacterRepository characterRepository; private CharacterRepository characterRepository;
@Mock @Mock
private NpcRepository npcRepository; private NpcRepository npcRepository;
@Mock
private EnemyRepository enemyRepository;
@InjectMocks @InjectMocks
private CampaignStructuralContextBuilder builder; private CampaignStructuralContextBuilder builder;

View File

@@ -37,6 +37,7 @@ class CampaignStructuralContextTest {
ArcSummary arc = new ArcSummary( ArcSummary arc = new ArcSummary(
"Acte I", "Acte I",
"Mise en place", "Mise en place",
false,
1, 1,
List.of(chapter)); List.of(chapter));
@@ -76,7 +77,7 @@ class CampaignStructuralContextTest {
@Test @Test
void illustrationCount_defaultsToZero_onAllSummaryTypes() { void illustrationCount_defaultsToZero_onAllSummaryTypes() {
ArcSummary arc = new ArcSummary("X", null, 0, List.of()); ArcSummary arc = new ArcSummary("X", null, false, 0, List.of());
ChapterSummary chapter = new ChapterSummary("X", null, 0, List.of()); ChapterSummary chapter = new ChapterSummary("X", null, 0, List.of());
SceneSummary scene = new SceneSummary("X", null, 0, List.of(), List.of()); SceneSummary scene = new SceneSummary("X", null, 0, List.of(), List.of());
@@ -92,6 +93,7 @@ class CampaignStructuralContextTest {
ArcSummary arc = new ArcSummary( ArcSummary arc = new ArcSummary(
"Acte I", "Acte I",
null, null,
false,
0, 0,
List.of( List.of(
new ChapterSummary("Ch1", null, 0, List.of()), new ChapterSummary("Ch1", null, 0, List.of()),

View File

@@ -0,0 +1,180 @@
package com.loremind.infrastructure.ai;
import com.loremind.domain.generationcontext.ChatRequest;
import com.loremind.domain.generationcontext.ChatUsage;
import com.loremind.domain.generationcontext.ports.AiProviderException;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.web.reactive.function.client.ClientResponse;
import org.springframework.web.reactive.function.client.ExchangeFunction;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Mono;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Consumer;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
* Tests unitaires purs (JUnit 5 + Mockito, sans Spring, sans réseau) de
* {@link BrainAiChatClient}.
*
* Principe : WebClient.Builder préconfiguré avec une ExchangeFunction mock
* renvoyant un flux SSE canned. Le payloadBuilder est mocké ; le sseParser est
* une instance réelle (simple parseur sans dépendance).
*/
class BrainAiChatClientTest {
/** ChatRequest minimal valide : messages vide suffit (payloadBuilder mocké). */
private ChatRequest minimalRequest() {
return ChatRequest.builder().messages(List.of()).build();
}
/** Construit un client dont le WebClient renvoie le corps SSE fourni. */
private BrainAiChatClient clientWithSse(String sseBody) {
ExchangeFunction ef = req -> Mono.just(
ClientResponse.create(HttpStatus.OK)
.header(HttpHeaders.CONTENT_TYPE, MediaType.TEXT_EVENT_STREAM_VALUE)
.body(sseBody)
.build());
return buildClient(ef);
}
/** Construit un client dont le WebClient émet une erreur transport. */
private BrainAiChatClient clientErroring() {
ExchangeFunction ef = req -> Mono.error(new RuntimeException("boom"));
return buildClient(ef);
}
private BrainAiChatClient buildClient(ExchangeFunction ef) {
WebClient.Builder builder = WebClient.builder().exchangeFunction(ef);
BrainChatPayloadBuilder payloadBuilder = mock(BrainChatPayloadBuilder.class);
when(payloadBuilder.build(org.mockito.ArgumentMatchers.any())).thenReturn(Map.of());
return new BrainAiChatClient(builder, "http://brain", payloadBuilder, new BrainSseParser());
}
// --- Collecteurs partagés pour les callbacks ---
private final List<ChatUsage> usages = new ArrayList<>();
private final List<String> tokens = new ArrayList<>();
private final AtomicBoolean completed = new AtomicBoolean(false);
private final AtomicReference<Throwable> error = new AtomicReference<>();
private final Consumer<ChatUsage> onUsage = usages::add;
private final Consumer<String> onToken = tokens::add;
private final Runnable onComplete = () -> completed.set(true);
private final Consumer<Throwable> onError = error::set;
@Test
void flux_complet_parse_usage_et_token_puis_complete() {
String sse =
"event:usage\ndata:{\"system\":1,\"history\":2,\"current\":3,\"max\":100}\n\n" +
"data:{\"token\":\"Bonjour\"}\n\n" +
"event:done\ndata:{}\n\n";
BrainAiChatClient client = clientWithSse(sse);
client.streamChat(minimalRequest(), onUsage, onToken, onComplete, onError);
// usage parsé et propagé
assertThat(usages).hasSize(1);
assertThat(usages.get(0)).isEqualTo(new ChatUsage(1, 2, 3, 100));
// token propagé
assertThat(tokens).containsExactly("Bonjour");
// event done ignoré (pas de token/usage supplémentaire)
// complétion appelée, pas d'erreur
assertThat(completed).isTrue();
assertThat(error.get()).isNull();
}
@Test
void plusieurs_tokens_propages_dans_l_ordre() {
String sse =
"data:{\"token\":\"Bon\"}\n\n" +
"data:{\"token\":\"jour\"}\n\n" +
"event:done\ndata:{}\n\n";
BrainAiChatClient client = clientWithSse(sse);
client.streamChat(minimalRequest(), onUsage, onToken, onComplete, onError);
assertThat(tokens).containsExactly("Bon", "jour");
assertThat(completed).isTrue();
assertThat(error.get()).isNull();
}
@Test
void event_error_declenche_onError_avec_AiProviderException() {
String sse =
"event:error\ndata:boom\n\n" +
"event:done\ndata:{}\n\n";
BrainAiChatClient client = clientWithSse(sse);
client.streamChat(minimalRequest(), onUsage, onToken, onComplete, onError);
assertThat(error.get())
.isInstanceOf(AiProviderException.class)
.hasMessageContaining("boom");
// Aucun token émis sur ce flux.
assertThat(tokens).isEmpty();
}
@Test
void token_vide_n_est_pas_propage() {
String sse =
"data:{\"token\":\"\"}\n\n" +
"event:done\ndata:{}\n\n";
BrainAiChatClient client = clientWithSse(sse);
client.streamChat(minimalRequest(), onUsage, onToken, onComplete, onError);
assertThat(tokens).isEmpty();
assertThat(completed).isTrue();
}
@Test
void usage_illisible_n_est_pas_propage() {
// data usage sans champs numériques -> parser renvoie ChatUsage(0,0,0,0),
// donc propagé ; ici on teste un usage avec data non-null mais vide d'entiers.
String sse =
"event:usage\ndata:{\"system\":5}\n\n" +
"event:done\ndata:{}\n\n";
BrainAiChatClient client = clientWithSse(sse);
client.streamChat(minimalRequest(), onUsage, onToken, onComplete, onError);
// Les champs absents tombent à 0 (parser tolérant).
assertThat(usages).containsExactly(new ChatUsage(5, 0, 0, 0));
}
@Test
void erreur_transport_declenche_onError_avec_AiProviderException() {
BrainAiChatClient client = clientErroring();
client.streamChat(minimalRequest(), onUsage, onToken, onComplete, onError);
assertThat(error.get())
.isInstanceOf(AiProviderException.class)
.hasMessageContaining("streaming chat");
// onComplete NON appelé puisqu'une exception a interrompu blockLast().
assertThat(completed).isFalse();
}
@Test
void flux_vide_appelle_seulement_onComplete() {
// Flux SSE vide : aucun évènement, blockLast renvoie null, onComplete appelé.
BrainAiChatClient client = clientWithSse("");
client.streamChat(minimalRequest(), onUsage, onToken, onComplete, onError);
assertThat(tokens).isEmpty();
assertThat(usages).isEmpty();
assertThat(completed).isTrue();
assertThat(error.get()).isNull();
}
}

View File

@@ -0,0 +1,213 @@
package com.loremind.infrastructure.ai;
import com.loremind.domain.generationcontext.GenerationContext;
import com.loremind.domain.generationcontext.GenerationResult;
import com.loremind.domain.generationcontext.ports.AiProviderException;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.web.client.HttpServerErrorException;
import org.springframework.web.client.ResourceAccessException;
import org.springframework.web.client.RestTemplate;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
* Tests unitaires PURS (JUnit 5 + Mockito, sans Spring, sans reseau) pour
* BrainAiClient. Le RestTemplate est mocke ; on couvre toutes les branches
* de callBrain ainsi que la traduction domaine -> wire -> domaine.
*/
class BrainAiClientTest {
private static final String BASE_URL = "http://brain";
private static final String EXPECTED_URL = "http://brain/generate-page";
private GenerationContext sampleContext() {
return new GenerationContext(
"Aetheria",
"Un monde de cendres",
"PNJ",
"Fiche personnage",
List.of("histoire", "motto"),
"Garde rouge"
);
}
private BrainGeneratePageResponse responseWith(Map<String, String> values) {
BrainGeneratePageResponse r = new BrainGeneratePageResponse();
r.setValues(values);
return r;
}
// --- Branche succes ------------------------------------------------------
@Test
void generatePage_succes_traduitReponseWireEnResultatDomaine() {
RestTemplate rt = mock(RestTemplate.class);
BrainAiClient client = new BrainAiClient(rt, BASE_URL);
BrainGeneratePageResponse wire = responseWith(Map.of(
"histoire", "Nee sous une etoile rouge",
"motto", "Jamais genou en terre"
));
when(rt.postForObject(anyString(), any(), eq(BrainGeneratePageResponse.class)))
.thenReturn(wire);
GenerationResult result = client.generatePage(sampleContext());
assertEquals(2, result.values().size());
assertEquals("Jamais genou en terre", result.values().get("motto"));
}
@Test
void generatePage_appelleBonneUrlEtContentTypeJson_avecCorpsTraduit() {
RestTemplate rt = mock(RestTemplate.class);
BrainAiClient client = new BrainAiClient(rt, BASE_URL);
when(rt.postForObject(anyString(), any(), eq(BrainGeneratePageResponse.class)))
.thenReturn(responseWith(Map.of("histoire", "v")));
client.generatePage(sampleContext());
// Capture de l'URL et de l'HttpEntity envoyes au RestTemplate
ArgumentCaptor<String> urlCaptor = ArgumentCaptor.forClass(String.class);
@SuppressWarnings("unchecked")
ArgumentCaptor<HttpEntity<BrainGeneratePageRequest>> entityCaptor =
ArgumentCaptor.forClass(HttpEntity.class);
org.mockito.Mockito.verify(rt).postForObject(
urlCaptor.capture(),
entityCaptor.capture(),
eq(BrainGeneratePageResponse.class));
assertEquals(EXPECTED_URL, urlCaptor.getValue());
HttpEntity<BrainGeneratePageRequest> entity = entityCaptor.getValue();
HttpHeaders headers = entity.getHeaders();
assertEquals(MediaType.APPLICATION_JSON, headers.getContentType());
// Verifie la traduction domaine -> wire (exerce les getters du record)
BrainGeneratePageRequest body = entity.getBody();
assertEquals("Aetheria", body.loreName());
assertEquals("Un monde de cendres", body.loreDescription());
assertEquals("PNJ", body.folderName());
assertEquals("Fiche personnage", body.templateName());
assertEquals(List.of("histoire", "motto"), body.templateFields());
assertEquals("Garde rouge", body.pageTitle());
}
// --- Branche reponse null ------------------------------------------------
@Test
void generatePage_reponseNull_leveAiProviderException() {
RestTemplate rt = mock(RestTemplate.class);
BrainAiClient client = new BrainAiClient(rt, BASE_URL);
when(rt.postForObject(anyString(), any(), eq(BrainGeneratePageResponse.class)))
.thenReturn(null);
AiProviderException ex = assertThrows(AiProviderException.class,
() -> client.generatePage(sampleContext()));
assertTrue(ex.getMessage().contains("reponse vide")
|| ex.getMessage().contains("réponse vide"));
}
// --- Branche values null -------------------------------------------------
@Test
void generatePage_valuesNull_leveAiProviderException() {
RestTemplate rt = mock(RestTemplate.class);
BrainAiClient client = new BrainAiClient(rt, BASE_URL);
// Reponse non null mais avec values == null
when(rt.postForObject(anyString(), any(), eq(BrainGeneratePageResponse.class)))
.thenReturn(responseWith(null));
assertThrows(AiProviderException.class,
() -> client.generatePage(sampleContext()));
}
// --- Branche ResourceAccessException (Brain injoignable) -----------------
@Test
void generatePage_brainInjoignable_leveAiProviderException() {
RestTemplate rt = mock(RestTemplate.class);
BrainAiClient client = new BrainAiClient(rt, BASE_URL);
ResourceAccessException cause = new ResourceAccessException("down");
when(rt.postForObject(anyString(), any(), eq(BrainGeneratePageResponse.class)))
.thenThrow(cause);
AiProviderException ex = assertThrows(AiProviderException.class,
() -> client.generatePage(sampleContext()));
assertTrue(ex.getMessage().contains("injoignable"));
assertSame(cause, ex.getCause());
}
// --- Branche RestClientResponseException (HTTP 4xx/5xx) ------------------
@Test
void generatePage_erreurHttp_leveAiProviderExceptionAvecCode() {
RestTemplate rt = mock(RestTemplate.class);
BrainAiClient client = new BrainAiClient(rt, BASE_URL);
HttpServerErrorException cause = HttpServerErrorException.create(
HttpStatus.BAD_GATEWAY, "Bad Gateway",
new HttpHeaders(), new byte[0], null);
when(rt.postForObject(anyString(), any(), eq(BrainGeneratePageResponse.class)))
.thenThrow(cause);
AiProviderException ex = assertThrows(AiProviderException.class,
() -> client.generatePage(sampleContext()));
assertTrue(ex.getMessage().contains("502"));
assertSame(cause, ex.getCause());
}
// --- Branche AiProviderException deja traduite : re-levee telle quelle ---
@Test
void generatePage_aiProviderExceptionDejaTraduite_estRelancee() {
RestTemplate rt = mock(RestTemplate.class);
BrainAiClient client = new BrainAiClient(rt, BASE_URL);
AiProviderException original = new AiProviderException("deja traduite");
when(rt.postForObject(anyString(), any(), eq(BrainGeneratePageResponse.class)))
.thenThrow(original);
AiProviderException ex = assertThrows(AiProviderException.class,
() -> client.generatePage(sampleContext()));
// Pas de re-enveloppement : c'est exactement la meme instance
assertSame(original, ex);
}
// --- Branche Exception generique (filet de securite) ---------------------
@Test
void generatePage_exceptionGenerique_leveAiProviderException() {
RestTemplate rt = mock(RestTemplate.class);
BrainAiClient client = new BrainAiClient(rt, BASE_URL);
RuntimeException cause = new IllegalStateException("JSON invalide");
when(rt.postForObject(anyString(), any(), eq(BrainGeneratePageResponse.class)))
.thenThrow(cause);
AiProviderException ex = assertThrows(AiProviderException.class,
() -> client.generatePage(sampleContext()));
assertTrue(ex.getMessage().contains("inattendue"));
assertSame(cause, ex.getCause());
}
}

View File

@@ -0,0 +1,166 @@
package com.loremind.infrastructure.ai;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.web.reactive.function.client.ClientResponse;
import org.springframework.web.reactive.function.client.ExchangeFunction;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Mono;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Tests unitaires PURS (JUnit 5, sans Spring ni réseau) pour {@link BrainCampaignAdaptClient}.
* Le WebClient.Builder injecté embarque une ExchangeFunction qui renvoie un corps SSE canned.
*/
class BrainCampaignAdaptClientTest {
private static final ObjectMapper MAPPER = new ObjectMapper();
private BrainCampaignAdaptClient clientReturning(String sseBody) {
ExchangeFunction ef = req -> Mono.just(
ClientResponse.create(HttpStatus.OK)
.header(HttpHeaders.CONTENT_TYPE, MediaType.TEXT_EVENT_STREAM_VALUE)
.body(sseBody)
.build());
WebClient.Builder builder = WebClient.builder().exchangeFunction(ef);
return new BrainCampaignAdaptClient(builder, MAPPER, "http://brain", 30);
}
private BrainCampaignAdaptClient clientFailingWith(Throwable boom) {
ExchangeFunction ef = req -> Mono.error(boom);
WebClient.Builder builder = WebClient.builder().exchangeFunction(ef);
return new BrainCampaignAdaptClient(builder, MAPPER, "http://brain", 30);
}
/** Collecteur de callbacks + déclenchement de adviseStreaming. */
private static final class Collector {
final StringBuilder tokens = new StringBuilder();
final AtomicBoolean complete = new AtomicBoolean(false);
final AtomicReference<Throwable> error = new AtomicReference<>();
void invoke(BrainCampaignAdaptClient client, String filename, String brief, String messagesJson) {
client.adviseStreaming(
new byte[]{1, 2, 3},
filename,
brief,
messagesJson,
tokens::append,
() -> complete.set(true),
error::set);
}
void invoke(BrainCampaignAdaptClient client) {
invoke(client, "camp.pdf", "brief", "[]");
}
}
@Test
void streame_tokens_puis_done() {
String sse =
"event:token\ndata:{\"token\":\"Conseil\"}\n\n" +
"event:token\ndata:{\"token\":\" final\"}\n\n" +
"event:done\ndata:{}\n\n";
Collector c = new Collector();
c.invoke(clientReturning(sse));
assertEquals("Conseil final", c.tokens.toString());
assertTrue(c.complete.get(), "onComplete appelé via event done");
assertNull(c.error.get());
}
@Test
void token_vide_ou_absent_ignore() {
// token "" -> non émis ; champ token absent -> readField renvoie data (non vide)
// donc émis tel quel : on vérifie ce comportement réel.
String sse =
"event:token\ndata:{\"token\":\"\"}\n\n" +
"event:token\ndata:{\"token\":\"OK\"}\n\n" +
"event:done\ndata:{}\n\n";
Collector c = new Collector();
c.invoke(clientReturning(sse));
assertEquals("OK", c.tokens.toString());
assertTrue(c.complete.get());
}
@Test
void event_error_appelle_onError_avec_runtimeexception() {
String sse =
"event:token\ndata:{\"token\":\"avant\"}\n\n" +
"event:error\ndata:{\"message\":\"PDF illisible\"}\n\n";
Collector c = new Collector();
c.invoke(clientReturning(sse));
assertNotNull(c.error.get());
assertInstanceOf(RuntimeException.class, c.error.get());
assertTrue(c.error.get().getMessage().contains("PDF illisible"));
assertFalse(c.complete.get(), "onComplete non appelé après error terminal");
}
@Test
void event_error_data_non_json_relaie_data_brut() {
// readField : data non parsable -> catch -> renvoie data brut.
String sse = "event:error\ndata:panne-brute\n\n";
Collector c = new Collector();
c.invoke(clientReturning(sse));
assertNotNull(c.error.get());
assertTrue(c.error.get().getMessage().contains("panne-brute"));
}
@Test
void flux_clos_sans_done_appelle_onComplete() {
// Pas de done/error -> terminated false -> onComplete de secours.
String sse = "event:token\ndata:{\"token\":\"x\"}\n\n";
Collector c = new Collector();
c.invoke(clientReturning(sse));
assertEquals("x", c.tokens.toString());
assertTrue(c.complete.get());
assertNull(c.error.get());
}
@Test
void erreur_transport_traduite_en_runtimeexception() {
Collector c = new Collector();
c.invoke(clientFailingWith(new RuntimeException("boom")));
assertNotNull(c.error.get());
assertInstanceOf(RuntimeException.class, c.error.get());
assertTrue(c.error.get().getMessage().contains("adaptation"));
assertFalse(c.complete.get());
}
@Test
void filename_null_et_brief_null_et_messages_null_acceptes() {
// Couvre les branches : filename blank -> "campaign.pdf", brief null -> "",
// messagesJson null/blank -> "[]".
String sse = "event:done\ndata:{}\n\n";
Collector c = new Collector();
c.invoke(clientReturning(sse), null, null, null);
assertTrue(c.complete.get());
assertNull(c.error.get());
}
@Test
void messages_blank_remplace_par_tableau_vide() {
String sse = "event:done\ndata:{}\n\n";
Collector c = new Collector();
c.invoke(clientReturning(sse), " ", " ", " ");
assertTrue(c.complete.get());
}
}

View File

@@ -0,0 +1,429 @@
package com.loremind.infrastructure.ai;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.loremind.domain.campaigncontext.CampaignImportProgress;
import com.loremind.domain.campaigncontext.CampaignImportProposal;
import com.loremind.domain.campaigncontext.ports.CampaignImportException;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.web.reactive.function.client.ClientResponse;
import org.springframework.web.reactive.function.client.ExchangeFunction;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Mono;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Tests unitaires PURS (JUnit 5, sans Spring ni réseau) pour {@link BrainCampaignImportClient}.
* <p>
* NB : contrairement à la consigne initiale, ce client est entièrement WebClient + SSE
* (POST /import/campaign/stream) — il n'y a PAS de RestTemplate ni d'appel one-shot.
* On injecte donc un WebClient.Builder dont l'ExchangeFunction renvoie un corps SSE
* canned (ou échoue), ce qui couvre {@code handleEvent} et tous les helpers de parsing.
*/
class BrainCampaignImportClientTest {
private static final ObjectMapper MAPPER = new ObjectMapper();
/** Construit un client dont le WebClient renvoie le corps SSE fourni. */
private BrainCampaignImportClient clientReturning(String sseBody) {
ExchangeFunction ef = req -> Mono.just(
ClientResponse.create(HttpStatus.OK)
.header(HttpHeaders.CONTENT_TYPE, MediaType.TEXT_EVENT_STREAM_VALUE)
.body(sseBody)
.build());
WebClient.Builder builder = WebClient.builder().exchangeFunction(ef);
return new BrainCampaignImportClient(builder, MAPPER, "http://brain", 30);
}
/** Construit un client dont le transport échoue immédiatement (Mono.error). */
private BrainCampaignImportClient clientFailingWith(Throwable boom) {
ExchangeFunction ef = req -> Mono.error(boom);
WebClient.Builder builder = WebClient.builder().exchangeFunction(ef);
return new BrainCampaignImportClient(builder, MAPPER, "http://brain", 30);
}
/** Collecteur mutable réunissant tous les callbacks de l'import streamé. */
private static final class Collector {
final List<CampaignImportProgress> progresses = new ArrayList<>();
final AtomicInteger heartbeats = new AtomicInteger(0);
final List<String> statuses = new ArrayList<>();
final AtomicReference<CampaignImportProposal> done = new AtomicReference<>();
final AtomicReference<Throwable> error = new AtomicReference<>();
void invoke(BrainCampaignImportClient client) {
invoke(client, "campaign.pdf");
}
void invoke(BrainCampaignImportClient client, String filename) {
client.importCampaignStreaming(
new byte[]{1, 2, 3},
filename,
progresses::add,
heartbeats::incrementAndGet,
statuses::add,
done::set,
error::set);
}
}
// ---------- flux nominal : start -> progress -> done --------------------
@Test
void streame_start_progress_done_construit_la_proposition() {
// SSE déclenchant start (page/ocr counts), progress (compteurs), puis done (arbre complet).
String sse =
"event:start\ndata:{\"total\":5,\"page_count\":12,\"ocr_page_count\":3}\n\n" +
"event:progress\ndata:{\"current\":2,\"total\":5,\"arc_count\":1,\"chapter_count\":2,\"scene_count\":4,\"npc_count\":6}\n\n" +
"event:done\ndata:" + doneJson() + "\n\n";
Collector c = new Collector();
c.invoke(clientReturning(sse));
// start : current=0, total=5, pageCount=12, ocrPageCount=3, reste 0.
assertEquals(2, c.progresses.size());
CampaignImportProgress start = c.progresses.get(0);
assertEquals(0, start.current());
assertEquals(5, start.total());
assertEquals(12, start.pageCount());
assertEquals(3, start.ocrPageCount());
// progress : compteurs propagés + pageCount/ocr mémorisés depuis start.
CampaignImportProgress prog = c.progresses.get(1);
assertEquals(2, prog.current());
assertEquals(5, prog.total());
assertEquals(12, prog.pageCount());
assertEquals(3, prog.ocrPageCount());
assertEquals(1, prog.arcCount());
assertEquals(2, prog.chapterCount());
assertEquals(4, prog.sceneCount());
assertEquals(6, prog.npcCount());
// done : arbre désérialisé (arcs/chapters/scenes/rooms + npcs).
CampaignImportProposal proposal = c.done.get();
assertNotNull(proposal);
assertEquals(1, proposal.arcs().size());
var arc = proposal.arcs().get(0);
assertEquals("Acte I", arc.name());
assertEquals("Mise en place", arc.description());
assertEquals("LINEAR", arc.type());
assertEquals(1, arc.chapters().size());
var chapter = arc.chapters().get(0);
assertEquals("Chapitre 1", chapter.name());
assertEquals(1, chapter.scenes().size());
var scene = chapter.scenes().get(0);
assertEquals("L'auberge", scene.name());
assertEquals("Lisez ceci", scene.playerNarration());
assertEquals("Secret MJ", scene.gmNotes());
assertEquals(1, scene.rooms().size());
var room = scene.rooms().get(0);
assertEquals("Cave", room.name());
assertEquals("2 gobelins", room.enemies());
assertEquals("50 po", room.loot());
assertEquals(1, proposal.npcs().size());
assertEquals("Thorin", proposal.npcs().get(0).name());
assertEquals("Nain bougon", proposal.npcs().get(0).description());
assertNull(c.error.get(), "aucune erreur sur un flux terminé par done");
}
private static String doneJson() {
return "{"
+ "\"arcs\":[{"
+ " \"name\":\"Acte I\",\"description\":\"Mise en place\",\"type\":\"LINEAR\","
+ " \"chapters\":[{"
+ " \"name\":\"Chapitre 1\",\"description\":\"intro\","
+ " \"scenes\":[{"
+ " \"name\":\"L'auberge\",\"description\":\"tendue\","
+ " \"player_narration\":\"Lisez ceci\",\"gm_notes\":\"Secret MJ\","
+ " \"rooms\":[{\"name\":\"Cave\",\"description\":\"sombre\",\"enemies\":\"2 gobelins\",\"loot\":\"50 po\"}]"
+ " }]"
+ " }]"
+ "}],"
+ "\"npcs\":[{\"name\":\"Thorin\",\"description\":\"Nain bougon\"}]"
+ "}";
}
// ---------- events simples : heartbeat / status / chunk_failed / extracting
@Test
void event_heartbeat_propage_le_keepalive() {
String sse =
"event:heartbeat\ndata:\n\n" +
"event:heartbeat\ndata:\n\n" +
"event:done\ndata:" + emptyDoneJson() + "\n\n";
Collector c = new Collector();
c.invoke(clientReturning(sse));
assertEquals(2, c.heartbeats.get());
assertNotNull(c.done.get());
}
@Test
void event_status_relaie_le_message_lisible() {
String sse =
"event:status\ndata:{\"message\":\"Fournisseur saturé, nouvelle tentative\"}\n\n" +
"event:done\ndata:" + emptyDoneJson() + "\n\n";
Collector c = new Collector();
c.invoke(clientReturning(sse));
assertEquals(1, c.statuses.size());
assertEquals("Fournisseur saturé, nouvelle tentative", c.statuses.get(0));
}
@Test
void event_status_sans_champ_message_relaie_data_brut() {
// readMessage : pas de champ "message" -> renvoie la data brute.
String sse =
"event:status\ndata:texte-brut\n\n" +
"event:done\ndata:" + emptyDoneJson() + "\n\n";
Collector c = new Collector();
c.invoke(clientReturning(sse));
assertEquals("texte-brut", c.statuses.get(0));
}
@Test
void event_chunk_failed_compose_un_status_avec_compteurs_et_message() {
String sse =
"event:chunk_failed\ndata:{\"current\":3,\"total\":10,\"message\":\"timeout LLM\"}\n\n" +
"event:done\ndata:" + emptyDoneJson() + "\n\n";
Collector c = new Collector();
c.invoke(clientReturning(sse));
assertEquals("Morceau 3/10 ignoré : timeout LLM", c.statuses.get(0));
}
@Test
void event_chunk_failed_sans_message_termine_par_un_point() {
// Branche msg.isEmpty() -> suffixe "." au lieu de " : <msg>".
String sse =
"event:chunk_failed\ndata:{\"current\":1,\"total\":4}\n\n" +
"event:done\ndata:" + emptyDoneJson() + "\n\n";
Collector c = new Collector();
c.invoke(clientReturning(sse));
assertEquals("Morceau 1/4 ignoré.", c.statuses.get(0));
}
@Test
void event_chunk_failed_avec_json_invalide_donne_zero_zero() {
// data non-JSON -> readJson renvoie null -> current/total à 0, suffixe ".".
String sse =
"event:chunk_failed\ndata:pas-du-json\n\n" +
"event:done\ndata:" + emptyDoneJson() + "\n\n";
Collector c = new Collector();
c.invoke(clientReturning(sse));
assertEquals("Morceau 0/0 ignoré.", c.statuses.get(0));
}
@Test
void event_extracting_emet_un_progress_neutre() {
String sse =
"event:extracting\ndata:\n\n" +
"event:done\ndata:" + emptyDoneJson() + "\n\n";
Collector c = new Collector();
c.invoke(clientReturning(sse));
assertEquals(1, c.progresses.size());
CampaignImportProgress p = c.progresses.get(0);
assertEquals(0, p.current());
assertEquals(0, p.total());
assertEquals(0, p.pageCount());
assertEquals(0, p.npcCount());
}
private static String emptyDoneJson() {
return "{\"arcs\":[],\"npcs\":[]}";
}
// ---------- event error (terminal) -------------------------------------
@Test
void event_error_appelle_onError_et_n_appelle_pas_onDone() {
String sse =
"event:start\ndata:{\"total\":2,\"page_count\":1,\"ocr_page_count\":0}\n\n" +
"event:error\ndata:{\"message\":\"PDF illisible\"}\n\n";
Collector c = new Collector();
c.invoke(clientReturning(sse));
assertNotNull(c.error.get());
assertInstanceOf(CampaignImportException.class, c.error.get());
assertTrue(c.error.get().getMessage().contains("PDF illisible"));
assertNull(c.done.get(), "onDone non appelé après un error terminal");
}
@Test
void event_error_sans_message_relaie_data_brut() {
String sse = "event:error\ndata:erreur-brute\n\n";
Collector c = new Collector();
c.invoke(clientReturning(sse));
assertNotNull(c.error.get());
assertTrue(c.error.get().getMessage().contains("erreur-brute"));
}
// ---------- branches de robustesse du parsing --------------------------
@Test
void event_inconnu_avec_data_non_json_est_ignore() {
// event non géré + data non-JSON -> readJson null -> return sans effet ;
// flux clos sans done -> branche d'interruption (onError).
String sse = "event:mystere\ndata:pas-du-json\n\n";
Collector c = new Collector();
c.invoke(clientReturning(sse));
assertTrue(c.progresses.isEmpty());
assertTrue(c.statuses.isEmpty());
assertNull(c.done.get());
assertNotNull(c.error.get(), "flux interrompu sans done/error -> onError");
}
@Test
void start_avec_champs_absents_utilise_les_valeurs_par_defaut() {
// JSON valide mais sans page_count/ocr/total -> path().asInt() == 0.
String sse =
"event:start\ndata:{}\n\n" +
"event:done\ndata:" + emptyDoneJson() + "\n\n";
Collector c = new Collector();
c.invoke(clientReturning(sse));
CampaignImportProgress p = c.progresses.get(0);
assertEquals(0, p.total());
assertEquals(0, p.pageCount());
assertEquals(0, p.ocrPageCount());
}
@Test
void done_avec_arbre_vide_donne_une_proposition_vide() {
// Couvre toArcs/toNpcs sur des tableaux vides + text() sur champs absents.
String sse = "event:done\ndata:" + emptyDoneJson() + "\n\n";
Collector c = new Collector();
c.invoke(clientReturning(sse));
assertNotNull(c.done.get());
assertTrue(c.done.get().arcs().isEmpty());
assertTrue(c.done.get().npcs().isEmpty());
}
@Test
void done_avec_arc_sans_chapitres_et_npc_sans_description() {
// toChapters sur noeud absent (path -> MissingNode, non-array) -> liste vide ;
// text() sur "description" absent -> "".
String sse = "event:done\ndata:{"
+ "\"arcs\":[{\"name\":\"Solo\"}],"
+ "\"npcs\":[{\"name\":\"Anon\"}]"
+ "}\n\n";
Collector c = new Collector();
c.invoke(clientReturning(sse));
var proposal = c.done.get();
assertNotNull(proposal);
var arc = proposal.arcs().get(0);
assertEquals("Solo", arc.name());
assertEquals("", arc.description());
assertEquals("", arc.type());
assertTrue(arc.chapters().isEmpty());
assertEquals("Anon", proposal.npcs().get(0).name());
assertEquals("", proposal.npcs().get(0).description());
}
@Test
void done_avec_champ_explicitement_null_donne_chaine_vide() {
// text() : valeur JSON null -> "" (branche v.isNull()).
String sse = "event:done\ndata:{"
+ "\"arcs\":[{\"name\":null,\"description\":\"d\"}],"
+ "\"npcs\":[]"
+ "}\n\n";
Collector c = new Collector();
c.invoke(clientReturning(sse));
assertEquals("", c.done.get().arcs().get(0).name());
assertEquals("d", c.done.get().arcs().get(0).description());
}
// ---------- fin de flux sans terminaison -------------------------------
@Test
void flux_clos_sans_done_ni_error_appelle_onError() {
// terminated reste false -> branche "Le flux d'import s'est interrompu...".
String sse = "event:progress\ndata:{\"current\":1,\"total\":3}\n\n";
Collector c = new Collector();
c.invoke(clientReturning(sse));
assertNull(c.done.get());
assertNotNull(c.error.get());
assertInstanceOf(CampaignImportException.class, c.error.get());
assertTrue(c.error.get().getMessage().contains("interrompu"));
}
// ---------- erreur de transport ----------------------------------------
@Test
void erreur_transport_traduite_en_CampaignImportException() {
// Mono.error -> blockLast lève -> branche catch, expose type + message de la cause.
Collector c = new Collector();
c.invoke(clientFailingWith(new RuntimeException("connexion coupée")));
assertNotNull(c.error.get());
assertInstanceOf(CampaignImportException.class, c.error.get());
assertTrue(c.error.get().getMessage().contains("streaming d'import"));
assertTrue(c.error.get().getMessage().contains("connexion coupée"));
assertNull(c.done.get());
}
@Test
void erreur_transport_sans_message_reste_geree() {
// Cause sans message -> branche getMessage() == null (pas de " — ...").
Collector c = new Collector();
c.invoke(clientFailingWith(new RuntimeException()));
assertNotNull(c.error.get());
assertInstanceOf(CampaignImportException.class, c.error.get());
assertTrue(c.error.get().getMessage().contains("streaming d'import"));
}
@Test
void erreur_transport_apres_event_error_ne_double_pas_le_callback() {
// error terminal puis le flux se clôt : terminated[0]==true -> pas de second onError.
// (vérifie que le callback n'est appelé qu'une fois via le message attendu.)
String sse = "event:error\ndata:{\"message\":\"stop\"}\n\n";
Collector c = new Collector();
c.invoke(clientReturning(sse));
assertNotNull(c.error.get());
assertTrue(c.error.get().getMessage().contains("stop"));
assertFalse(c.error.get().getMessage().contains("interrompu"));
}
// ---------- nom de fichier ---------------------------------------------
@Test
void filename_null_ou_blanc_est_accepte() {
// Couvre la branche de repli "campaign.pdf" dans filePart + part().filename().
String sse = "event:done\ndata:" + emptyDoneJson() + "\n\n";
Collector c1 = new Collector();
c1.invoke(clientReturning(sse), null);
assertNotNull(c1.done.get());
Collector c2 = new Collector();
c2.invoke(clientReturning(sse), " ");
assertNotNull(c2.done.get());
Collector c3 = new Collector();
c3.invoke(clientReturning(sse), "mon-livre.pdf");
assertNotNull(c3.done.get());
}
}

View File

@@ -7,12 +7,21 @@ import com.loremind.domain.generationcontext.CampaignStructuralContext.ChapterSu
import com.loremind.domain.generationcontext.CampaignStructuralContext.SceneSummary; import com.loremind.domain.generationcontext.CampaignStructuralContext.SceneSummary;
import com.loremind.domain.generationcontext.ChatMessage; import com.loremind.domain.generationcontext.ChatMessage;
import com.loremind.domain.generationcontext.ChatRequest; import com.loremind.domain.generationcontext.ChatRequest;
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.GameSystemContext;
import com.loremind.domain.generationcontext.LoreStructuralContext; import com.loremind.domain.generationcontext.LoreStructuralContext;
import com.loremind.domain.generationcontext.LoreStructuralContext.PageSummary; import com.loremind.domain.generationcontext.LoreStructuralContext.PageSummary;
import com.loremind.domain.generationcontext.NarrativeEntityContext; import com.loremind.domain.generationcontext.NarrativeEntityContext;
import com.loremind.domain.generationcontext.PageContext; 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.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import java.time.LocalDateTime;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@@ -165,7 +174,7 @@ class BrainChatPayloadBuilderTest {
BranchHint branch = new BranchHint("fuite", "La poursuite", "HP < 50%"); BranchHint branch = new BranchHint("fuite", "La poursuite", "HP < 50%");
SceneSummary scene = new SceneSummary("L'auberge", "Rencontre tendue", 3, List.of(branch), List.of()); SceneSummary scene = new SceneSummary("L'auberge", "Rencontre tendue", 3, List.of(branch), List.of());
ChapterSummary chapter = new ChapterSummary("L'arrivee", "...", 0, List.of(scene)); ChapterSummary chapter = new ChapterSummary("L'arrivee", "...", 0, List.of(scene));
ArcSummary arc = new ArcSummary("Acte I", "Mise en place", 1, List.of(chapter)); ArcSummary arc = new ArcSummary("Acte I", "Mise en place", false, 1, List.of(chapter));
CampaignStructuralContext camp = new CampaignStructuralContext( CampaignStructuralContext camp = new CampaignStructuralContext(
"Les Ombres", "dark fantasy", List.of(arc), List.of(), List.of()); "Les Ombres", "dark fantasy", List.of(arc), List.of(), List.of());
ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build(); ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build();
@@ -198,7 +207,7 @@ class BrainChatPayloadBuilderTest {
@Test @Test
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
void build_arcSummary_omitsIllustrationCount_whenZero() { void build_arcSummary_omitsIllustrationCount_whenZero() {
ArcSummary arc = new ArcSummary("A", "", 0, List.of()); ArcSummary arc = new ArcSummary("A", "", false, 0, List.of());
CampaignStructuralContext camp = new CampaignStructuralContext( CampaignStructuralContext camp = new CampaignStructuralContext(
"X", "", List.of(arc), List.of(), List.of()); "X", "", List.of(arc), List.of(), List.of());
ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build(); ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build();
@@ -215,7 +224,7 @@ class BrainChatPayloadBuilderTest {
void build_sceneSummary_omitsBranches_whenEmpty() { void build_sceneSummary_omitsBranches_whenEmpty() {
SceneSummary scene = new SceneSummary("S", "", 0, List.of(), List.of()); SceneSummary scene = new SceneSummary("S", "", 0, List.of(), List.of());
ChapterSummary chapter = new ChapterSummary("Ch", "", 0, List.of(scene)); ChapterSummary chapter = new ChapterSummary("Ch", "", 0, List.of(scene));
ArcSummary arc = new ArcSummary("A", "", 0, List.of(chapter)); ArcSummary arc = new ArcSummary("A", "", false, 0, List.of(chapter));
CampaignStructuralContext camp = new CampaignStructuralContext( CampaignStructuralContext camp = new CampaignStructuralContext(
"X", "", List.of(arc), List.of(), List.of()); "X", "", List.of(arc), List.of(), List.of());
ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build(); ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build();
@@ -234,7 +243,7 @@ class BrainChatPayloadBuilderTest {
BranchHint branch = new BranchHint("X", "Y", " "); BranchHint branch = new BranchHint("X", "Y", " ");
SceneSummary scene = new SceneSummary("S", "", 0, List.of(branch), List.of()); SceneSummary scene = new SceneSummary("S", "", 0, List.of(branch), List.of());
ChapterSummary chapter = new ChapterSummary("Ch", "", 0, List.of(scene)); ChapterSummary chapter = new ChapterSummary("Ch", "", 0, List.of(scene));
ArcSummary arc = new ArcSummary("A", "", 0, List.of(chapter)); ArcSummary arc = new ArcSummary("A", "", false, 0, List.of(chapter));
CampaignStructuralContext camp = new CampaignStructuralContext( CampaignStructuralContext camp = new CampaignStructuralContext(
"X", "", List.of(arc), List.of(), List.of()); "X", "", List.of(arc), List.of(), List.of());
ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build(); ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build();
@@ -284,4 +293,252 @@ class BrainChatPayloadBuilderTest {
assertFalse(payload.containsKey("lore_context")); assertFalse(payload.containsKey("lore_context"));
assertFalse(payload.containsKey("page_context")); assertFalse(payload.containsKey("page_context"));
} }
// ---------- arc HUB + characters/npcs ----------------------------------
@Test
@SuppressWarnings("unchecked")
void build_arcHub_injecteArcTypeHub() {
// arc.hub() == true -> ajoute "arc_type":"HUB" (vocabulaire « quêtes »).
ArcSummary arc = new ArcSummary("Hub central", "", true, 0, List.of());
CampaignStructuralContext camp = new CampaignStructuralContext(
"X", "", List.of(arc), List.of(), List.of());
ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build();
Map<String, Object> arcMap = firstOf(asMap(builder.build(req).get("campaign_context")), "arcs");
assertEquals("HUB", arcMap.get("arc_type"));
}
@Test
@SuppressWarnings("unchecked")
void build_arcLineaire_n_injectePasArcType() {
ArcSummary arc = new ArcSummary("Lineaire", "", false, 0, List.of());
CampaignStructuralContext camp = new CampaignStructuralContext(
"X", "", List.of(arc), List.of(), List.of());
ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build();
Map<String, Object> arcMap = firstOf(asMap(builder.build(req).get("campaign_context")), "arcs");
assertFalse(arcMap.containsKey("arc_type"));
}
@Test
@SuppressWarnings("unchecked")
void build_campaignContext_serialiseCharactersEtNpcs_avecOmissionSnippetBlank() {
// snippet renseigné -> présent ; snippet blank/null -> omis.
CharacterSummary pj1 = new CharacterSummary("Aria", "Magicienne elfe");
CharacterSummary pj2 = new CharacterSummary("Bran", " ");
NpcSummary pnj1 = new NpcSummary("Garde", "Sentinelle bourrue");
NpcSummary pnj2 = new NpcSummary("Mendiant", null);
CampaignStructuralContext camp = new CampaignStructuralContext(
"X", "", List.of(), List.of(pj1, pj2), List.of(pnj1, pnj2));
ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build();
Map<String, Object> cctx = asMap(builder.build(req).get("campaign_context"));
List<Map<String, Object>> chars = (List<Map<String, Object>>) cctx.get("characters");
assertEquals("Aria", chars.get(0).get("name"));
assertTrue(chars.get(0).containsKey("snippet"));
assertFalse(chars.get(1).containsKey("snippet"), "snippet blank omis");
List<Map<String, Object>> npcs = (List<Map<String, Object>>) cctx.get("npcs");
assertEquals("Garde", npcs.get(0).get("name"));
assertTrue(npcs.get(0).containsKey("snippet"));
assertFalse(npcs.get(1).containsKey("snippet"), "snippet null omis");
}
// ---------- rooms d'une scène ------------------------------------------
@Test
@SuppressWarnings("unchecked")
void build_sceneRooms_serialiseTousLesChampsEtBranchesEntrePieces() {
RoomBranchHint exit = new RoomBranchHint("porte nord", "Salle du trone", "clé en main");
RoomSummary room = new RoomSummary("Hall", 1, "Vaste entrée", "Spectres", List.of(exit));
SceneSummary scene = new SceneSummary("Donjon", "", 0, List.of(), List.of(room));
ChapterSummary chapter = new ChapterSummary("Ch", "", 0, List.of(scene));
ArcSummary arc = new ArcSummary("A", "", false, 0, List.of(chapter));
CampaignStructuralContext camp = new CampaignStructuralContext(
"X", "", List.of(arc), List.of(), List.of());
ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build();
Map<String, Object> arcMap = firstOf(asMap(builder.build(req).get("campaign_context")), "arcs");
Map<String, Object> sceneMap = firstOf(firstOf(arcMap, "chapters"), "scenes");
Map<String, Object> roomMap = firstOf(sceneMap, "rooms");
assertEquals("Hall", roomMap.get("name"));
assertEquals(1, roomMap.get("floor"));
assertEquals("Vaste entrée", roomMap.get("description"));
assertEquals("Spectres", roomMap.get("enemies"));
Map<String, Object> branchMap = firstOf(roomMap, "branches");
assertEquals("porte nord", branchMap.get("label"));
assertEquals("Salle du trone", branchMap.get("target_room_name"));
assertEquals("clé en main", branchMap.get("condition"));
}
@Test
@SuppressWarnings("unchecked")
void build_sceneRooms_ometLesChampsOptionnelsVides() {
// floor null, description/enemies blank, condition blank, branches non vides.
RoomBranchHint exit = new RoomBranchHint("sortie", "Autre", " ");
RoomSummary room = new RoomSummary("Cellule", null, " ", " ", List.of(exit));
SceneSummary scene = new SceneSummary("Donjon", "", 0, List.of(), List.of(room));
ChapterSummary chapter = new ChapterSummary("Ch", "", 0, List.of(scene));
ArcSummary arc = new ArcSummary("A", "", false, 0, List.of(chapter));
CampaignStructuralContext camp = new CampaignStructuralContext(
"X", "", List.of(arc), List.of(), List.of());
ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build();
Map<String, Object> arcMap = firstOf(asMap(builder.build(req).get("campaign_context")), "arcs");
Map<String, Object> sceneMap = firstOf(firstOf(arcMap, "chapters"), "scenes");
Map<String, Object> roomMap = firstOf(sceneMap, "rooms");
assertFalse(roomMap.containsKey("floor"));
assertFalse(roomMap.containsKey("description"));
assertFalse(roomMap.containsKey("enemies"));
Map<String, Object> branchMap = firstOf(roomMap, "branches");
assertFalse(branchMap.containsKey("condition"), "condition blank omise");
}
@Test
@SuppressWarnings("unchecked")
void build_sceneRooms_omisesSiListeVide() {
// s.rooms() vide -> clé "rooms" absente.
SceneSummary scene = new SceneSummary("Lineaire", "", 0, List.of(), List.of());
ChapterSummary chapter = new ChapterSummary("Ch", "", 0, List.of(scene));
ArcSummary arc = new ArcSummary("A", "", false, 0, List.of(chapter));
CampaignStructuralContext camp = new CampaignStructuralContext(
"X", "", List.of(arc), List.of(), List.of());
ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build();
Map<String, Object> arcMap = firstOf(asMap(builder.build(req).get("campaign_context")), "arcs");
Map<String, Object> sceneMap = firstOf(firstOf(arcMap, "chapters"), "scenes");
assertFalse(sceneMap.containsKey("rooms"));
}
// ---------- game_system_context ----------------------------------------
@Test
@SuppressWarnings("unchecked")
void build_gameSystemContext_completInclutDescriptionEtSections() {
GameSystemContext gs = new GameSystemContext(
"Nimble", "JDR rapide", Map.of("Combat", "Lancez 1d20"));
ChatRequest req = ChatRequest.builder().messages(sampleMessages).gameSystemContext(gs).build();
Map<String, Object> gsm = asMap(builder.build(req).get("game_system_context"));
assertEquals("Nimble", gsm.get("system_name"));
assertEquals("JDR rapide", gsm.get("system_description"));
assertEquals(Map.of("Combat", "Lancez 1d20"), gsm.get("sections"));
}
@Test
@SuppressWarnings("unchecked")
void build_gameSystemContext_descriptionBlankEtSectionsNull() {
// systemDescription blank -> omis ; sections null -> Map.of() de repli.
GameSystemContext gs = new GameSystemContext("D&D 5.1", " ", null);
ChatRequest req = ChatRequest.builder().messages(sampleMessages).gameSystemContext(gs).build();
Map<String, Object> gsm = asMap(builder.build(req).get("game_system_context"));
assertEquals("D&D 5.1", gsm.get("system_name"));
assertFalse(gsm.containsKey("system_description"));
assertEquals(Map.of(), gsm.get("sections"));
}
// ---------- session_context --------------------------------------------
@Test
@SuppressWarnings("unchecked")
void build_sessionContext_complet_inclutToutesLesListesPeuplees() {
LocalDateTime now = LocalDateTime.of(2026, 6, 14, 20, 0);
JournalEntrySummary entry = new JournalEntrySummary(
"combat", "Le dragon attaque", now, null);
JournalEntrySummary prev = new JournalEntrySummary(
"info", "Le roi est mort", now, "Session 1");
QuestSummary avail = new QuestSummary("Sauver le village", "Acte I", "Urgence");
QuestSummary inProg = new QuestSummary("Trouver l'épée", "Acte II", " ");
SessionContext sc = new SessionContext(
"Session 2", true, now,
List.of(entry), List.of(prev),
List.of(avail), List.of(inProg),
List.of("Quête secrète"), List.of("porte_ouverte"));
ChatRequest req = ChatRequest.builder().messages(sampleMessages).sessionContext(sc).build();
Map<String, Object> scm = asMap(builder.build(req).get("session_context"));
assertEquals("Session 2", scm.get("session_name"));
assertEquals(true, scm.get("active"));
assertEquals(now.toString(), scm.get("started_at"));
// entries : type/content/occurredAt présents, source omis (null) sur l'entrée courante.
Map<String, Object> entryMap = firstOf(scm, "entries");
assertEquals("combat", entryMap.get("type"));
assertEquals("Le dragon attaque", entryMap.get("content"));
assertEquals(now.toString(), entryMap.get("occurred_at"));
assertFalse(entryMap.containsKey("source_session_name"));
// previous_events : source renseignée.
Map<String, Object> prevMap = firstOf(scm, "previous_events");
assertEquals("Session 1", prevMap.get("source_session_name"));
// available_quests : description renseignée ; in_progress : description blank omise.
Map<String, Object> availMap = firstOf(scm, "available_quests");
assertEquals("Sauver le village", availMap.get("name"));
assertEquals("Acte I", availMap.get("arc_name"));
assertEquals("Urgence", availMap.get("description"));
Map<String, Object> inProgMap = firstOf(scm, "in_progress_quests");
assertFalse(inProgMap.containsKey("description"), "description blank omise");
assertEquals(List.of("Quête secrète"), scm.get("locked_quest_titles"));
assertEquals(List.of("porte_ouverte"), scm.get("active_flags"));
}
@Test
@SuppressWarnings("unchecked")
void build_sessionContext_minimal_ometListesVidesEtChampsNull() {
// startedAt null -> omis ; entries null -> List.of() ; toutes les autres listes
// vides/null -> clés absentes.
SessionContext sc = new SessionContext(
"Session vide", false, null,
null, List.of(),
List.of(), List.of(),
List.of(), List.of());
ChatRequest req = ChatRequest.builder().messages(sampleMessages).sessionContext(sc).build();
Map<String, Object> scm = asMap(builder.build(req).get("session_context"));
assertEquals("Session vide", scm.get("session_name"));
assertEquals(false, scm.get("active"));
assertFalse(scm.containsKey("started_at"));
assertEquals(List.of(), scm.get("entries"));
assertFalse(scm.containsKey("previous_events"));
assertFalse(scm.containsKey("available_quests"));
assertFalse(scm.containsKey("in_progress_quests"));
assertFalse(scm.containsKey("locked_quest_titles"));
assertFalse(scm.containsKey("active_flags"));
}
// ---------- tous les contextes présents simultanément ------------------
@Test
void build_tousLesContextes_sontTousPresents() {
LoreStructuralContext lore = new LoreStructuralContext("L", "", Map.of(), List.of());
PageContext page = new PageContext("P", "T", List.of(), Map.of());
CampaignStructuralContext camp = new CampaignStructuralContext("C", "", List.of(), List.of(), List.of());
NarrativeEntityContext entity = new NarrativeEntityContext("scene", "S", Map.of());
GameSystemContext gs = new GameSystemContext("G", null, Map.of());
SessionContext sc = new SessionContext("Sess", true, null, List.of(), List.of(),
List.of(), List.of(), List.of(), List.of());
ChatRequest req = ChatRequest.builder()
.messages(sampleMessages)
.loreContext(lore)
.pageContext(page)
.campaignContext(camp)
.narrativeEntity(entity)
.gameSystemContext(gs)
.sessionContext(sc)
.build();
Map<String, Object> payload = builder.build(req);
assertTrue(payload.containsKey("lore_context"));
assertTrue(payload.containsKey("page_context"));
assertTrue(payload.containsKey("campaign_context"));
assertTrue(payload.containsKey("narrative_entity"));
assertTrue(payload.containsKey("game_system_context"));
assertTrue(payload.containsKey("session_context"));
}
} }

View File

@@ -0,0 +1,134 @@
package com.loremind.infrastructure.ai;
import com.loremind.domain.conversationcontext.ConversationMessage;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.web.reactive.function.client.ClientResponse;
import org.springframework.web.reactive.function.client.ExchangeFunction;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Mono;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Tests unitaires purs (JUnit 5, sans Spring, sans réseau) de
* {@link BrainConversationTitleClient}.
*
* Principe : on injecte un WebClient.Builder préconfiguré avec une
* ExchangeFunction mock qui renvoie des réponses canned -> aucun appel réseau.
*/
class BrainConversationTitleClientTest {
private static final String FALLBACK = "Nouvelle conversation";
/** Construit un client dont le WebClient répond avec la réponse fournie. */
private BrainConversationTitleClient clientReturning(ClientResponse response) {
ExchangeFunction ef = req -> Mono.just(response);
WebClient.Builder builder = WebClient.builder().exchangeFunction(ef);
return new BrainConversationTitleClient(builder, "http://brain");
}
/** Construit un client dont le WebClient émet une erreur transport. */
private BrainConversationTitleClient clientErroring() {
ExchangeFunction ef = req -> Mono.error(new RuntimeException("boom"));
WebClient.Builder builder = WebClient.builder().exchangeFunction(ef);
return new BrainConversationTitleClient(builder, "http://brain");
}
private ClientResponse jsonOk(String body) {
return ClientResponse.create(HttpStatus.OK)
.header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.body(body)
.build();
}
private ConversationMessage msg(String role, String content) {
return ConversationMessage.builder().role(role).content(content).build();
}
@Test
void liste_null_renvoie_fallback() {
// Pas besoin d'appel réseau : court-circuit sur entrée null.
BrainConversationTitleClient client = clientErroring();
assertThat(client.generate(null)).isEqualTo(FALLBACK);
}
@Test
void liste_vide_renvoie_fallback() {
BrainConversationTitleClient client = clientErroring();
assertThat(client.generate(List.of())).isEqualTo(FALLBACK);
}
@Test
void reponse_ok_avec_titre_renvoie_le_titre() {
BrainConversationTitleClient client = clientReturning(jsonOk("{\"title\":\"Mon titre\"}"));
String result = client.generate(List.of(msg("user", "Salut")));
assertThat(result).isEqualTo("Mon titre");
}
@Test
void titre_avec_espaces_est_trimme() {
BrainConversationTitleClient client = clientReturning(jsonOk("{\"title\":\" Espacé \"}"));
String result = client.generate(List.of(msg("user", "Bonjour")));
assertThat(result).isEqualTo("Espacé");
}
@Test
void contenu_message_null_traite_sans_npe() {
// content null -> mappé en "" dans le payload, ne doit pas lever.
BrainConversationTitleClient client = clientReturning(jsonOk("{\"title\":\"Ok\"}"));
String result = client.generate(List.of(msg("assistant", null)));
assertThat(result).isEqualTo("Ok");
}
@Test
void titre_absent_renvoie_fallback() {
BrainConversationTitleClient client = clientReturning(jsonOk("{\"autre\":\"x\"}"));
String result = client.generate(List.of(msg("user", "Hello")));
assertThat(result).isEqualTo(FALLBACK);
}
@Test
void titre_vide_renvoie_fallback() {
BrainConversationTitleClient client = clientReturning(jsonOk("{\"title\":\"\"}"));
String result = client.generate(List.of(msg("user", "Hello")));
assertThat(result).isEqualTo(FALLBACK);
}
@Test
void titre_blanc_renvoie_fallback() {
BrainConversationTitleClient client = clientReturning(jsonOk("{\"title\":\" \"}"));
String result = client.generate(List.of(msg("user", "Hello")));
assertThat(result).isEqualTo(FALLBACK);
}
@Test
void corps_json_vide_renvoie_fallback() {
// Map décodée non null mais sans clé "title".
BrainConversationTitleClient client = clientReturning(jsonOk("{}"));
String result = client.generate(List.of(msg("user", "Hello")));
assertThat(result).isEqualTo(FALLBACK);
}
@Test
void erreur_transport_renvoie_fallback() {
BrainConversationTitleClient client = clientErroring();
String result = client.generate(List.of(msg("user", "Hello")));
assertThat(result).isEqualTo(FALLBACK);
}
@Test
void reponse_500_renvoie_fallback() {
ClientResponse err = ClientResponse.create(HttpStatus.INTERNAL_SERVER_ERROR)
.header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.body("{\"error\":\"down\"}")
.build();
BrainConversationTitleClient client = clientReturning(err);
String result = client.generate(List.of(msg("user", "Hello")));
assertThat(result).isEqualTo(FALLBACK);
}
}

View File

@@ -0,0 +1,85 @@
package com.loremind.infrastructure.ai;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
/**
* Tests unitaires des DTOs wire de l'Adapter IA :
* - BrainGeneratePageRequest (record envoye au Brain) ;
* - BrainGeneratePageResponse (@Data/@NoArgsConstructor recu du Brain).
* On instancie et on appelle les accesseurs pour couvrir le code genere.
*/
class BrainGeneratePageRequestTest {
// --- BrainGeneratePageRequest -------------------------------------------
@Test
void request_accesseursExposentLesChamps() {
BrainGeneratePageRequest req = new BrainGeneratePageRequest(
"Aetheria",
"Un monde de cendres",
"PNJ",
"Fiche personnage",
List.of("histoire", "motto"),
"Garde rouge"
);
assertEquals("Aetheria", req.loreName());
assertEquals("Un monde de cendres", req.loreDescription());
assertEquals("PNJ", req.folderName());
assertEquals("Fiche personnage", req.templateName());
assertEquals(List.of("histoire", "motto"), req.templateFields());
assertEquals("Garde rouge", req.pageTitle());
}
@Test
void request_egaliteStructurelleEtToString() {
BrainGeneratePageRequest a = new BrainGeneratePageRequest(
"n", "d", "f", "t", List.of("x"), "p");
BrainGeneratePageRequest b = new BrainGeneratePageRequest(
"n", "d", "f", "t", List.of("x"), "p");
BrainGeneratePageRequest c = new BrainGeneratePageRequest(
"AUTRE", "d", "f", "t", List.of("x"), "p");
assertEquals(a, b);
assertEquals(a.hashCode(), b.hashCode());
assertNotEquals(a, c);
// toString genere : on verifie juste qu'il est non vide et contient un champ
org.junit.jupiter.api.Assertions.assertTrue(a.toString().contains("n"));
}
// --- BrainGeneratePageResponse ------------------------------------------
@Test
void response_setterEtGetterValues() {
BrainGeneratePageResponse resp = new BrainGeneratePageResponse();
assertNull(resp.getValues());
Map<String, String> values = Map.of("histoire", "Nee sous une etoile rouge");
resp.setValues(values);
assertEquals(values, resp.getValues());
assertEquals("Nee sous une etoile rouge", resp.getValues().get("histoire"));
}
@Test
void response_egaliteEtToStringGeneresParLombok() {
BrainGeneratePageResponse a = new BrainGeneratePageResponse();
a.setValues(Map.of("f", "v"));
BrainGeneratePageResponse b = new BrainGeneratePageResponse();
b.setValues(Map.of("f", "v"));
BrainGeneratePageResponse c = new BrainGeneratePageResponse();
c.setValues(Map.of("f", "AUTRE"));
assertEquals(a, b);
assertEquals(a.hashCode(), b.hashCode());
assertNotEquals(a, c);
org.junit.jupiter.api.Assertions.assertTrue(a.toString().contains("values"));
}
}

View File

@@ -0,0 +1,202 @@
package com.loremind.infrastructure.ai;
import com.loremind.domain.campaigncontext.CatalogItem;
import com.loremind.domain.campaigncontext.ports.ItemCatalogGenerationException;
import com.loremind.domain.campaigncontext.ports.ItemCatalogGenerator.GeneratedCatalog;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.web.client.HttpServerErrorException;
import org.springframework.web.client.ResourceAccessException;
import org.springframework.web.client.RestTemplate;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
* Tests unitaires purs (JUnit 5 + Mockito, sans Spring) de {@link BrainItemCatalogClient}.
* Le RestTemplate est mocké : {@code postForObject(url, entity, Map.class)}.
*/
class BrainItemCatalogClientTest {
private RestTemplate rt;
private BrainItemCatalogClient client;
@BeforeEach
void setUp() {
rt = mock(RestTemplate.class);
client = new BrainItemCatalogClient(rt, "http://brain");
}
/** Construit une map d'objet pour le payload "items". */
private static Map<String, Object> item(Object name, Object price, Object category, Object description) {
Map<String, Object> m = new LinkedHashMap<>();
m.put("name", name);
m.put("price", price);
m.put("category", category);
m.put("description", description);
return m;
}
// ---------- generate : cas nominal et mapping ----------
@Test
void generate_reponseValide_mappeItems_etIgnoreInvalides() {
List<Object> items = new ArrayList<>();
items.add(item("Épée longue", "15 po", "Armes", "Tranchante")); // valide
items.add(item("Potion", null, null, null)); // valide, champs null
items.add(item(null, "1 po", "x", "y")); // ignoré : name null
items.add(item(" ", "1 po", "x", "y")); // ignoré : name blank
items.add("pas une map"); // ignoré : item non-Map
Map<String, Object> resp = new LinkedHashMap<>();
resp.put("name", "Boutique du forgeron");
resp.put("description", "Au village");
resp.put("items", items);
when(rt.postForObject(anyString(), any(), eq(Map.class))).thenReturn(resp);
GeneratedCatalog cat = client.generate("desc", "ctx");
assertEquals("Boutique du forgeron", cat.name());
assertEquals("Au village", cat.description());
assertEquals(2, cat.items().size());
CatalogItem i0 = cat.items().get(0);
assertEquals("Épée longue", i0.getName());
assertEquals("15 po", i0.getPrice());
assertEquals("Armes", i0.getCategory());
assertEquals("Tranchante", i0.getDescription());
CatalogItem i1 = cat.items().get(1);
assertEquals("Potion", i1.getName());
assertNull(i1.getPrice());
assertNull(i1.getCategory());
assertNull(i1.getDescription());
}
@Test
void generate_nameAbsent_fallbackSurDescription() {
Map<String, Object> resp = new LinkedHashMap<>();
resp.put("items", List.of(item("Objet", null, null, null)));
// pas de "name" -> fallback description
when(rt.postForObject(anyString(), any(), eq(Map.class))).thenReturn(resp);
GeneratedCatalog cat = client.generate("MaDescription", "ctx");
assertEquals("MaDescription", cat.name());
assertNull(cat.description());
}
@Test
void generate_nameBlank_fallbackSurDescription() {
Map<String, Object> resp = new LinkedHashMap<>();
resp.put("name", " ");
resp.put("items", List.of(item("Objet", null, null, null)));
when(rt.postForObject(anyString(), any(), eq(Map.class))).thenReturn(resp);
GeneratedCatalog cat = client.generate("Fallback", "ctx");
assertEquals("Fallback", cat.name());
}
@Test
void generate_argumentsNull_remplisParDefauts() {
Map<String, Object> resp = new LinkedHashMap<>();
resp.put("name", "Cat");
resp.put("items", List.of(item("Objet", null, null, null)));
when(rt.postForObject(anyString(), any(), eq(Map.class))).thenReturn(resp);
GeneratedCatalog cat = client.generate(null, null);
assertEquals("Cat", cat.name());
}
// ---------- generate : réponse vide / pas d'items ----------
@Test
void generate_reponseNull_leveException() {
when(rt.postForObject(anyString(), any(), eq(Map.class))).thenReturn(null);
ItemCatalogGenerationException ex = assertThrows(ItemCatalogGenerationException.class,
() -> client.generate("d", "c"));
assertTrue(ex.getMessage().contains("réponse vide"));
}
@Test
void generate_itemsAbsents_leveException() {
Map<String, Object> resp = new LinkedHashMap<>();
resp.put("name", "Cat"); // pas de "items"
when(rt.postForObject(anyString(), any(), eq(Map.class))).thenReturn(resp);
ItemCatalogGenerationException ex = assertThrows(ItemCatalogGenerationException.class,
() -> client.generate("d", "c"));
assertTrue(ex.getMessage().contains("Aucun objet"));
}
@Test
void generate_itemsVide_leveException() {
Map<String, Object> resp = new LinkedHashMap<>();
resp.put("items", new ArrayList<>());
when(rt.postForObject(anyString(), any(), eq(Map.class))).thenReturn(resp);
assertThrows(ItemCatalogGenerationException.class, () -> client.generate("d", "c"));
}
@Test
void generate_tousItemsInvalides_leveException() {
Map<String, Object> resp = new LinkedHashMap<>();
resp.put("items", List.of(item(null, null, null, null)));
when(rt.postForObject(anyString(), any(), eq(Map.class))).thenReturn(resp);
assertThrows(ItemCatalogGenerationException.class, () -> client.generate("d", "c"));
}
// ---------- generate : branches du catch ----------
@Test
void generate_brainInjoignable_resourceAccess() {
when(rt.postForObject(anyString(), any(), eq(Map.class)))
.thenThrow(new ResourceAccessException("down"));
ItemCatalogGenerationException ex = assertThrows(ItemCatalogGenerationException.class,
() -> client.generate("d", "c"));
assertTrue(ex.getMessage().contains("injoignable"));
assertInstanceOf(ResourceAccessException.class, ex.getCause());
}
@Test
void generate_erreurHttp_restClientResponse() {
when(rt.postForObject(anyString(), any(), eq(Map.class)))
.thenThrow(HttpServerErrorException.create(
HttpStatus.BAD_GATEWAY, "Bad Gateway", new HttpHeaders(), new byte[0], null));
ItemCatalogGenerationException ex = assertThrows(ItemCatalogGenerationException.class,
() -> client.generate("d", "c"));
assertTrue(ex.getMessage().contains("HTTP 502"));
}
@Test
void generate_erreurInattendue_exceptionGenerique() {
when(rt.postForObject(anyString(), any(), eq(Map.class)))
.thenThrow(new IllegalStateException("boom"));
ItemCatalogGenerationException ex = assertThrows(ItemCatalogGenerationException.class,
() -> client.generate("d", "c"));
assertTrue(ex.getMessage().contains("inattendue"));
assertInstanceOf(IllegalStateException.class, ex.getCause());
}
}

View File

@@ -0,0 +1,127 @@
package com.loremind.infrastructure.ai;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.core.io.Resource;
import org.springframework.core.io.buffer.DataBufferUtils;
import org.springframework.http.HttpEntity;
import org.springframework.mock.http.client.reactive.MockClientHttpRequest;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.reactive.function.client.ClientResponse;
import org.springframework.web.reactive.function.client.ExchangeFunction;
import org.springframework.web.reactive.function.client.ExchangeStrategies;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Mono;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
/**
* Couvre les sous-classes anonymes {@code new ByteArrayResource(){ getFilename() }}
* des clients d'upload multipart : leur {@code getFilename()} (nom par défaut si
* absent/vide) n'est appelé que lors de la SÉRIALISATION du corps, qui n'a pas lieu
* quand le transport est mocké. On la déclenche donc explicitement :
* <ul>
* <li>clients RestTemplate : on capture le {@link HttpEntity} envoyé et on lit la
* ressource « file » pour appeler {@code getFilename()} ;</li>
* <li>clients WebClient : l'{@link ExchangeFunction} sérialise réellement le corps
* multipart dans un {@link MockClientHttpRequest} (ce qui invoque getFilename).</li>
* </ul>
*/
class BrainMultipartFilenameTest {
// --- Helpers ------------------------------------------------------------
/** ExchangeFunction qui sérialise le corps de la requête (déclenche getFilename) puis renvoie un SSE 'done'. */
private static ExchangeFunction serializingExchange(String sse) {
return request -> {
MockClientHttpRequest mock = new MockClientHttpRequest(request.method(), request.url());
// Le write-handler par défaut ne souscrit pas au corps : on draine donc le
// flux nous-mêmes pour forcer l'écriture des parts (et l'appel à getFilename).
mock.setWriteHandler(body -> DataBufferUtils.join(body)
.doOnNext(DataBufferUtils::release).then());
request.writeTo(mock, ExchangeStrategies.withDefaults()).block();
return Mono.just(ClientResponse.create(org.springframework.http.HttpStatus.OK)
.header(org.springframework.http.HttpHeaders.CONTENT_TYPE,
org.springframework.http.MediaType.TEXT_EVENT_STREAM_VALUE)
.body(sse)
.build());
};
}
@SuppressWarnings("unchecked")
private static String capturedFilename(RestTemplate rt) {
ArgumentCaptor<HttpEntity> cap = ArgumentCaptor.forClass(HttpEntity.class);
verify(rt).postForObject(anyString(), cap.capture(), any());
MultiValueMap<String, Object> body = (MultiValueMap<String, Object>) cap.getValue().getBody();
return ((Resource) body.getFirst("file")).getFilename();
}
// --- BrainNotebookIndexClient (RestTemplate) ----------------------------
@Test
void notebookIndex_filePart_usesGivenFilename_elseDefault() {
RestTemplate rt = mock(RestTemplate.class);
BrainNotebookIndexClient client = new BrainNotebookIndexClient(rt, "http://brain");
try { client.index("src-1", new byte[]{1, 2}, "doc.pdf"); } catch (RuntimeException ignored) { }
assertEquals("doc.pdf", capturedFilename(rt));
RestTemplate rt2 = mock(RestTemplate.class);
BrainNotebookIndexClient client2 = new BrainNotebookIndexClient(rt2, "http://brain");
try { client2.index("src-1", new byte[]{1, 2}, null); } catch (RuntimeException ignored) { }
assertEquals("source.pdf", capturedFilename(rt2));
}
// --- BrainRulesImportClient (RestTemplate one-shot) ---------------------
@Test
void rulesImport_filePart_usesGivenFilename_elseDefault() {
RestTemplate rt = mock(RestTemplate.class);
BrainRulesImportClient client = new BrainRulesImportClient(
rt, WebClient.builder(), new ObjectMapper(), "http://brain", 600);
try { client.importRules(new byte[]{1, 2}, "regles.pdf"); } catch (RuntimeException ignored) { }
assertEquals("regles.pdf", capturedFilename(rt));
RestTemplate rt2 = mock(RestTemplate.class);
BrainRulesImportClient client2 = new BrainRulesImportClient(
rt2, WebClient.builder(), new ObjectMapper(), "http://brain", 600);
try { client2.importRules(new byte[]{1, 2}, " "); } catch (RuntimeException ignored) { }
assertEquals("rules.pdf", capturedFilename(rt2));
}
// --- BrainCampaignAdaptClient (WebClient multipart) ---------------------
@Test
void campaignAdapt_filePart_serializedFilename() {
ExchangeFunction ef = serializingExchange("event:done\ndata:{}\n\n");
BrainCampaignAdaptClient client = new BrainCampaignAdaptClient(
WebClient.builder().exchangeFunction(ef), new ObjectMapper(), "http://brain", 30);
// filename présent puis null : les deux branches de getFilename sont sérialisées.
client.adviseStreaming(new byte[]{1, 2}, "doc.pdf", "brief", "[]",
t -> { }, () -> { }, e -> { });
client.adviseStreaming(new byte[]{1, 2}, null, null, null,
t -> { }, () -> { }, e -> { });
}
// --- BrainCampaignImportClient (WebClient multipart) --------------------
@Test
void campaignImport_filePart_serializedFilename() {
ExchangeFunction ef = serializingExchange("event:done\ndata:{\"sections\":{}}\n\n");
BrainCampaignImportClient client = new BrainCampaignImportClient(
WebClient.builder().exchangeFunction(ef), new ObjectMapper(), "http://brain", 30);
client.importCampaignStreaming(new byte[]{1, 2}, "doc.pdf",
p -> { }, () -> { }, s -> { }, r -> { }, e -> { });
client.importCampaignStreaming(new byte[]{1, 2}, null,
p -> { }, () -> { }, s -> { }, r -> { }, e -> { });
}
}

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