From 84025911f88b94e7e2781b712aecaf8b6f5b78c0 Mon Sep 17 00:00:00 2001 From: "IETM_FIXE\\ietm6" Date: Mon, 15 Jun 2026 09:49:05 +0200 Subject: [PATCH 1/4] =?UTF-8?q?Prise=20en=20compte=20du=20langage=20de=20l?= =?UTF-8?q?'utilisateur=20pour=20le=20prompt=20de=20r=C3=A9ponse.=20Si=20p?= =?UTF-8?q?ar=20exemple=20l'interface=20est=20en=20anglais,=20les=20IA=20v?= =?UTF-8?q?ont=20favoriser=20l'anglais=20pour=20la=20r=C3=A9ponse?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- brain/app/api/routers/chat.py | 4 ++ brain/app/api/routers/generation.py | 27 +++++--- brain/app/api/routers/imports.py | 10 ++- brain/app/api/routers/notebooks.py | 7 ++- brain/app/api/routers/tables.py | 10 ++- brain/app/application/adapt_campaign.py | 11 +++- brain/app/application/chat.py | 20 ++++-- brain/app/application/generate_page.py | 14 +++-- brain/app/application/import_rules.py | 33 +++++----- brain/app/application/notebook_chat.py | 7 ++- brain/app/application/notebook_deep.py | 8 ++- brain/app/core/language.py | 61 +++++++++++++++++++ .../ports/ConversationTitleGenerator.java | 5 +- .../infrastructure/ai/BrainAiChatClient.java | 2 + .../ai/BrainCampaignAdaptClient.java | 2 + .../ai/BrainCampaignImportClient.java | 2 + .../ai/BrainConversationTitleClient.java | 2 + .../ai/BrainNotebookChatClient.java | 2 + .../ai/BrainRulesImportClient.java | 2 + .../infrastructure/ai/RestTemplateConfig.java | 9 +++ .../web/config/UserLanguageFilter.java | 40 ++++++++++++ .../web/config/UserLanguageHolder.java | 58 ++++++++++++++++++ web/Dockerfile | 5 -- .../app/interceptors/language.interceptor.ts | 18 ++++++ web/src/app/services/ai-chat.service.ts | 5 +- .../app/services/campaign-import.service.ts | 5 +- web/src/app/services/game-system.service.ts | 5 +- web/src/app/services/notebook.service.ts | 5 +- web/src/main.ts | 3 +- 29 files changed, 319 insertions(+), 63 deletions(-) create mode 100644 brain/app/core/language.py create mode 100644 core/src/main/java/com/loremind/infrastructure/web/config/UserLanguageFilter.java create mode 100644 core/src/main/java/com/loremind/infrastructure/web/config/UserLanguageHolder.java create mode 100644 web/src/app/interceptors/language.interceptor.ts diff --git a/brain/app/api/routers/chat.py b/brain/app/api/routers/chat.py index f617640..bd6e81c 100644 --- a/brain/app/api/routers/chat.py +++ b/brain/app/api/routers/chat.py @@ -18,6 +18,7 @@ from app.api.chat_mapping import ( from app.api.deps import get_chat_use_case from app.application.chat import ChatUseCase from app.core.config import get_settings +from app.core.language import get_user_language from app.domain.models import ChatMessage from app.domain.ports import LLMProviderError @@ -44,6 +45,7 @@ def _count_tokens(text: str | None) -> int: async def chat_stream( body: ChatStreamRequestDTO, use_case: Annotated[ChatUseCase, Depends(get_chat_use_case)], + language: Annotated[str, Depends(get_user_language)], ) -> StreamingResponse: """Chat streamé (Server-Sent Events) avec Structural Context. @@ -82,6 +84,7 @@ async def chat_stream( narrative_entity=narrative_entity, game_system_context=game_system_context, session_context=session_context, + language=language, ) # Dernier message = "current" (souvent user), le reste = historique accumulé. current_msg = messages[-1] if messages else None @@ -109,6 +112,7 @@ async def chat_stream( narrative_entity=narrative_entity, game_system_context=game_system_context, session_context=session_context, + language=language, ): # json.dumps avec ensure_ascii=False pour préserver les accents yield f"data: {json.dumps({'token': token}, ensure_ascii=False)}\n\n" diff --git a/brain/app/api/routers/generation.py b/brain/app/api/routers/generation.py index a06f5c9..5203b0f 100644 --- a/brain/app/api/routers/generation.py +++ b/brain/app/api/routers/generation.py @@ -7,6 +7,7 @@ from pydantic import BaseModel, Field from app.api.deps import get_generate_page_use_case, get_llm_provider from app.application.generate_page import GeneratePageUseCase from app.core.config import Settings, get_settings +from app.core.language import get_user_language, language_name from app.domain.models import PageGenerationContext from app.domain.ports import LLMProvider, LLMProviderError @@ -60,6 +61,7 @@ async def generate_page( use_case: Annotated[ GeneratePageUseCase, Depends(get_generate_page_use_case) ], + language: Annotated[str, Depends(get_user_language)], ) -> GeneratePageResponseDTO: """Endpoint métier : contexte LoreMind → valeurs structurées par champ. @@ -76,7 +78,7 @@ async def generate_page( ) try: - result = await use_case.execute(context) + result = await use_case.execute(context, language=language) except LLMProviderError as exc: raise HTTPException(status_code=502, detail=str(exc)) from exc @@ -101,18 +103,25 @@ class SummarizeTitleResponseDTO(BaseModel): title: str -_TITLE_SYSTEM_PROMPT = ( - "Tu generes un titre court (4 a 7 mots max) qui resume le sujet de la " - "conversation ci-dessous. Reponds UNIQUEMENT par le titre, sans guillemets, " - "sans ponctuation finale, sans prefixe type 'Titre :'. Le titre doit etre " - "en francais et capturer le sujet metier (pas 'Conversation IA')." -) +# 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')." + ) @router.post("/summarize/conversation-title", response_model=SummarizeTitleResponseDTO) async def summarize_conversation_title( body: SummarizeTitleRequestDTO, llm: Annotated[LLMProvider, Depends(get_llm_provider)], + language: Annotated[str, Depends(get_user_language)], ) -> SummarizeTitleResponseDTO: """Genere un titre court a partir des premiers echanges de la conversation. @@ -123,7 +132,7 @@ async def summarize_conversation_title( raise HTTPException(status_code=422, detail="Au moins un message requis") transcript = "\n".join(f"{m.role.upper()}: {m.content}" for m in body.messages[:6]) - prompt = f"{_TITLE_SYSTEM_PROMPT}\n\nConversation :\n{transcript}\n\nTitre :" + prompt = f"{_title_system_prompt(language)}\n\nConversation :\n{transcript}\n\nTitre :" try: raw = await llm.generate(prompt) except LLMProviderError as exc: @@ -133,5 +142,5 @@ async def summarize_conversation_title( if len(title) > 80: title = title[:80].rstrip() if not title: - title = "Nouvelle conversation" + title = _TITLE_FALLBACK.get(language, _TITLE_FALLBACK["fr"]) return SummarizeTitleResponseDTO(title=title) diff --git a/brain/app/api/routers/imports.py b/brain/app/api/routers/imports.py index 666214f..03c0bef 100644 --- a/brain/app/api/routers/imports.py +++ b/brain/app/api/routers/imports.py @@ -16,6 +16,7 @@ from app.api.deps import ( from app.application.adapt_campaign import AdaptCampaignUseCase from app.application.import_campaign import ImportCampaignUseCase from app.application.import_rules import ImportRulesUseCase +from app.core.language import get_user_language from app.domain.models import ChatMessage from app.domain.ports import LLMProviderError, PdfExtractionError @@ -40,6 +41,7 @@ class RulesImportResponseDTO(BaseModel): @router.post("/import/rules", response_model=RulesImportResponseDTO) async def import_rules( use_case: Annotated[ImportRulesUseCase, Depends(get_import_rules_use_case)], + language: Annotated[str, Depends(get_user_language)], file: UploadFile = File(...), ) -> RulesImportResponseDTO: """Import d'un PDF de règles → sections markdown structurées (proposition). @@ -58,7 +60,7 @@ async def import_rules( ) try: - result = await use_case.execute(content) + result = await use_case.execute(content, language=language) except PdfExtractionError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc except LLMProviderError as exc: @@ -74,6 +76,7 @@ async def import_rules( @router.post("/import/rules/stream") async def import_rules_stream( use_case: Annotated[ImportRulesUseCase, Depends(get_import_rules_use_case)], + language: Annotated[str, Depends(get_user_language)], file: UploadFile = File(...), ) -> StreamingResponse: """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}) return try: - async for ev in use_case.stream(content): + async for ev in use_case.stream(content, language=language): event_type = ev.pop("type") yield sse_event(event_type, ev) except PdfExtractionError as exc: @@ -146,6 +149,7 @@ async def import_campaign_stream( @router.post("/adapt/campaign/stream") async def adapt_campaign_stream( use_case: Annotated[AdaptCampaignUseCase, Depends(get_adapt_campaign_use_case)], + language: Annotated[str, Depends(get_user_language)], file: UploadFile = File(...), brief: str = Form(""), messages: str = Form("[]"), @@ -173,7 +177,7 @@ async def adapt_campaign_stream( yield sse_event("error", {"message": upload_error}) return 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("done", {}) except PdfExtractionError as exc: diff --git a/brain/app/api/routers/notebooks.py b/brain/app/api/routers/notebooks.py index a1644bf..c8c5c86 100644 --- a/brain/app/api/routers/notebooks.py +++ b/brain/app/api/routers/notebooks.py @@ -17,6 +17,7 @@ from app.application.notebook_chat import NotebookChatUseCase from app.application.notebook_deep import NotebookDeepUseCase from app.application.notebook_rag import NotebookRagUseCase from app.core.config import Settings, get_settings +from app.core.language import get_user_language from app.domain.models import ChatMessage from app.domain.ports import LLMProviderError, PdfExtractionError from app.infrastructure import vector_store @@ -77,6 +78,7 @@ async def chat_notebook_stream( body: NotebookChatRequestDTO, use_case: Annotated[NotebookChatUseCase, Depends(get_notebook_chat_use_case)], settings: Annotated[Settings, Depends(get_settings)], + language: Annotated[str, Depends(get_user_language)], ) -> StreamingResponse: """Chat ANCRÉ sur les sources (RAG) : récupère les passages pertinents puis streame la réponse. Évènements SSE : `token` {token}, `done` {}, `error` {message}.""" @@ -85,7 +87,7 @@ async def chat_notebook_stream( async def event_stream() -> AsyncIterator[str]: try: - async for ev in use_case.stream(body.source_ids, messages, context=body.context, top_k=top_k): + 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.get("token"): yield sse_event("token", {"token": ev["token"]}) @@ -107,6 +109,7 @@ async def chat_notebook_stream( async def chat_notebook_deep_stream( body: NotebookChatRequestDTO, use_case: Annotated[NotebookDeepUseCase, Depends(get_notebook_deep_use_case)], + language: Annotated[str, Depends(get_user_language)], ) -> StreamingResponse: """Analyse APPROFONDIE (map-reduce sur tout le document). Évènements SSE : `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."}) return 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") yield sse_event(ev_type, ev) except (LLMProviderError, EmbeddingError) as exc: diff --git a/brain/app/api/routers/tables.py b/brain/app/api/routers/tables.py index 4f5b5fc..1d42dee 100644 --- a/brain/app/api/routers/tables.py +++ b/brain/app/api/routers/tables.py @@ -8,6 +8,7 @@ from pydantic import BaseModel, Field from app.api.deps import get_llm_provider from app.application.llm_json import load_json_object from app.application.llm_retry import generate_with_retry +from app.core.language import get_user_language, language_name from app.domain.ports import LLMProvider, LLMProviderError router = APIRouter() @@ -51,6 +52,7 @@ class GenerateTableResponseDTO(BaseModel): async def generate_random_table( body: GenerateTableRequestDTO, llm: Annotated[LLMProvider, Depends(get_llm_provider)], + language: Annotated[str, Depends(get_user_language)], ) -> GenerateTableResponseDTO: """Génère une table aléatoire (entrées par plage) couvrant la formule de dé.""" rng = _dice_total_range(body.dice_formula) @@ -70,7 +72,7 @@ async def generate_random_table( 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" + f"- En {language_name(language)}. 'label' = résultat bref ; 'detail' = description/effet concret.\n" "Renvoie maintenant le JSON." ) try: @@ -124,6 +126,7 @@ class ImproviseRollResponseDTO(BaseModel): async def improvise_table_roll( body: ImproviseRollRequestDTO, llm: Annotated[LLMProvider, Depends(get_llm_provider)], + language: Annotated[str, Depends(get_user_language)], ) -> ImproviseRollResponseDTO: """Brode un court récit (2-3 phrases) sur un résultat tiré, pour lancer la scène.""" detail = f" ({body.result_detail.strip()})" if body.result_detail.strip() else "" @@ -133,7 +136,7 @@ async def improvise_table_roll( 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." + f"Pas de méta, pas d'options : juste la narration, en {language_name(language)}." ) try: raw = await llm.generate(prompt, temperature=0.8) @@ -167,6 +170,7 @@ class GenerateCatalogResponseDTO(BaseModel): async def generate_item_catalog( body: GenerateCatalogRequestDTO, llm: Annotated[LLMProvider, Depends(get_llm_provider)], + language: Annotated[str, Depends(get_user_language)], ) -> GenerateCatalogResponseDTO: """Génère un catalogue d'objets (boutique, butin…) — nom, prix, catégorie, description.""" context_block = f"\nContexte de la campagne :\n{body.context.strip()}\n" if body.context.strip() else "" @@ -180,7 +184,7 @@ async def generate_item_catalog( '[{"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" + f"'description' = effet/détails en une phrase. En {language_name(language)}.\n" "Renvoie maintenant le JSON." ) try: diff --git a/brain/app/application/adapt_campaign.py b/brain/app/application/adapt_campaign.py index 8a7b9a2..9727cf2 100644 --- a/brain/app/application/adapt_campaign.py +++ b/brain/app/application/adapt_campaign.py @@ -13,6 +13,7 @@ from __future__ import annotations import logging from typing import AsyncIterator +from app.core.language import DEFAULT as _DEFAULT_LANG, language_name from app.domain.models import ChatMessage from app.domain.ports import LLMChatProvider, PdfExtractionError, PdfTextExtractor @@ -27,8 +28,11 @@ _SYSTEM_PREFIX = ( "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, " + +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" @@ -64,6 +68,7 @@ class AdaptCampaignUseCase: pdf_bytes: bytes, brief: str, messages: list[ChatMessage], + language: str = _DEFAULT_LANG, ) -> AsyncIterator[str]: """Conversationnel : le PDF + la campagne sont le CONTEXTE (system prompt), `messages` est l'échange (demande initiale, puis feedbacks de l'utilisateur).""" @@ -92,7 +97,7 @@ class AdaptCampaignUseCase: f"{brief.strip() or '(campagne encore vide)'}\n\n" "--- CONTENU DU PDF À ADAPTER ---\n" f"{pdf_text}{trunc_note}\n\n" - f"{_SYSTEM_SUFFIX}\n\n" + f"{_system_suffix(language)}\n\n" "Tu es en CONVERSATION : à chaque message de l'utilisateur, ajuste, corrige " "ou propose des alternatives en gardant tout ce contexte à l'esprit." ) diff --git a/brain/app/application/chat.py b/brain/app/application/chat.py index 3f220e3..a8216b1 100644 --- a/brain/app/application/chat.py +++ b/brain/app/application/chat.py @@ -31,6 +31,7 @@ from app.domain.models import ( QuestSummary, SessionContext, ) +from app.core.language import DEFAULT as _DEFAULT_LANG, language_name from app.domain.ports import LLMChatProvider @@ -40,11 +41,13 @@ from app.domain.ports import LLMChatProvider _DEFAULT_TEMPERATURE = 0.7 -_BASE_SYSTEM = """Tu es un assistant d'écriture pour un Maître de Jeu de JDR. +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 français, ton chaleureux et créatif. +- 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. @@ -71,16 +74,18 @@ class ChatUseCase: narrative_entity: NarrativeEntityContext | None = None, game_system_context: GameSystemContext | None = None, session_context: SessionContext | None = None, + language: str = _DEFAULT_LANG, ) -> AsyncIterator[str]: """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 "niveaux haut" (lore_context ou campaign_context) doit être fourni 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( - 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( messages, @@ -97,12 +102,14 @@ class ChatUseCase: narrative_entity: NarrativeEntityContext | None = None, game_system_context: GameSystemContext | None = None, session_context: SessionContext | None = None, + language: str = _DEFAULT_LANG, ) -> str: """Version publique — utilisée par le controller HTTP pour compter les tokens du system prompt avant de streamer (jauge de contexte). """ return self._build_system_prompt( - lore_context, page_context, campaign_context, narrative_entity, game_system_context, session_context + lore_context, page_context, campaign_context, narrative_entity, + game_system_context, session_context, language, ) # --- Construction du system prompt -------------------------------------- @@ -115,8 +122,9 @@ class ChatUseCase: narrative: NarrativeEntityContext | None, game_system: GameSystemContext | None = None, session: SessionContext | None = None, + language: str = _DEFAULT_LANG, ) -> str: - sections = [_BASE_SYSTEM] + sections = [_base_system(language)] if lore is not None: sections.append(self._format_lore(lore)) if campaign is not None: diff --git a/brain/app/application/generate_page.py b/brain/app/application/generate_page.py index 5cc499c..89bdecd 100644 --- a/brain/app/application/generate_page.py +++ b/brain/app/application/generate_page.py @@ -8,6 +8,7 @@ permet de tester ce use case avec un FakeLLMProvider, sans Ollama qui tourne. """ import json +from app.core.language import DEFAULT as _DEFAULT_LANG, language_name from app.domain.models import PageGenerationContext, PageGenerationResult from app.domain.ports import LLMProvider, LLMProviderError @@ -18,13 +19,15 @@ from app.domain.ports import LLMProvider, LLMProviderError _DEFAULT_TEMPERATURE = 0.4 -_SYSTEM_INSTRUCTIONS = """Tu es un assistant d'écriture pour un Maître de Jeu de JDR. +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 français, riches et évocatrices. +- 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) : @@ -42,8 +45,9 @@ class GeneratePageUseCase: async def execute( self, context: PageGenerationContext, + language: str = _DEFAULT_LANG, ) -> PageGenerationResult: - prompt = self._build_prompt(context) + prompt = self._build_prompt(context, language) raw = await self._llm.generate( prompt, output_format="json", @@ -53,7 +57,7 @@ class GeneratePageUseCase: return PageGenerationResult(values=values) @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) lore_desc_line = ( f"\nDescription de l'univers : {context.lore_description}" @@ -62,7 +66,7 @@ class GeneratePageUseCase: ) return ( - f"{_SYSTEM_INSTRUCTIONS}\n\n" + f"{_system_instructions(language)}\n\n" f"Univers : {context.lore_name}" f"{lore_desc_line}\n" f"Catégorie (dossier) : {context.folder_name}\n" diff --git a/brain/app/application/import_rules.py b/brain/app/application/import_rules.py index 62b4007..7891d89 100644 --- a/brain/app/application/import_rules.py +++ b/brain/app/application/import_rules.py @@ -26,6 +26,7 @@ from app.application.import_status import ( from app.application.llm_json import load_json_object, looks_like_truncated_json from app.application.llm_retry import generate_with_retry from app.application.streaming import with_heartbeat +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 # pas tout réécrire en une réponse), on retraite ce morceau en 2 moitiés. Borné en @@ -88,7 +89,7 @@ Règles impératives : - 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). +- 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. @@ -117,7 +118,7 @@ Règles impératives : par paragraphe : un extrait contient typiquement 1 à 6 sections. - Titres : EN PRIORITÉ parmi : {canonical} - sinon un titre court et clair en français. + 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": []}}.""" @@ -293,7 +294,7 @@ class ImportRulesUseCase: self._chunk_target_tokens = chunk_target_tokens 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.""" doc = self._extractor.extract(pdf_bytes) chunks = chunk_text(doc.full_text, self._chunk_target_tokens) @@ -303,14 +304,14 @@ class ImportRulesUseCase: ) merger = _SectionMerger() 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( sections=merger.result(), page_count=doc.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. Évènements (dicts) : {"type": "extracting"}, puis @@ -353,7 +354,7 @@ class ImportRulesUseCase: try: sections: dict[str, str] | None = None 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, ): if kind == "heartbeat": @@ -408,11 +409,14 @@ class ImportRulesUseCase: # --- MAP : un morceau → sections ----------------------------------------- - async def _map_chunk(self, chunk: str, *, index: int, total: int) -> dict[str, str]: - return await self._extract_sections(chunk, index=index, total=total, depth=0) + async def _map_chunk(self, chunk: str, *, index: int, total: int, + 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( - 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]: """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 — @@ -421,7 +425,8 @@ class ImportRulesUseCase: schema = _ANCHORS_SCHEMA if self._segment_only else _SECTIONS_SCHEMA prompt = ( system.format( - canonical="\n".join(f" - {s}" for s in _CANONICAL_SECTIONS) + canonical="\n".join(f" - {s}" for s in _CANONICAL_SECTIONS), + language_name=language_name(language), ) + f"\n\n--- EXTRAIT {index + 1}/{total} ---\n{text}\n\n" "Renvoie maintenant le JSON des sections." @@ -444,8 +449,8 @@ class ImportRulesUseCase: notify_status( f"Le modèle est trop lent sur le morceau {index + 1} : " "re-découpage en 2 moitiés plus digestes…") - a = await self._extract_sections(left, index=index, total=total, depth=depth + 1) - b = await self._extract_sections(right, index=index, total=total, depth=depth + 1) + 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, language=language) return _combine_sections(a, b) if self._segment_only: sections, truncated = self._parse_anchors(raw, text, index=index) @@ -461,8 +466,8 @@ class ImportRulesUseCase: notify_status( f"Réponse du modèle coupée sur le morceau {index + 1} : " "re-découpage en 2 moitiés plus digestes…") - a = await self._extract_sections(left, index=index, total=total, depth=depth + 1) - b = await self._extract_sections(right, index=index, total=total, depth=depth + 1) + 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, language=language) return _combine_sections(a, b) if truncated: logger.warning( diff --git a/brain/app/application/notebook_chat.py b/brain/app/application/notebook_chat.py index 15ef2ff..b2b27d5 100644 --- a/brain/app/application/notebook_chat.py +++ b/brain/app/application/notebook_chat.py @@ -11,6 +11,7 @@ from typing import AsyncIterator from app.application.notebook_rag import NotebookRagUseCase from app.application.query_rewrite import standalone_question from app.application.rerank import pool_size, rerank +from app.core.language import DEFAULT as _DEFAULT_LANG, language_name from app.domain.models import ChatMessage from app.domain.ports import LLMChatProvider @@ -91,7 +92,7 @@ quêtes parallèles.) {{"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.""" +Réponds en {language_name}, de façon utile et concise. Mets le texte explicatif AVANT les blocs d'action.""" class NotebookChatUseCase: @@ -109,6 +110,7 @@ class NotebookChatUseCase: messages: list[ChatMessage], context: str = "", top_k: int = 6, + language: str = _DEFAULT_LANG, ) -> AsyncIterator[dict]: """Yield des évènements : {type:'sources', sources:[…]} (une fois, avant la réponse — transparence sur les passages utilisés), puis {type:'token', token}.""" @@ -144,7 +146,8 @@ class NotebookChatUseCase: if context.strip() else "--- TA CAMPAGNE ---\n(aucune donnée de campagne)\n--- FIN CAMPAGNE ---\n\n" ) system_prompt = _SYSTEM_PROMPT.format( - context_block=context_block, sources_block=sources_block) + 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): yield {"type": "token", "token": token} diff --git a/brain/app/application/notebook_deep.py b/brain/app/application/notebook_deep.py index 5a04f08..0369b65 100644 --- a/brain/app/application/notebook_deep.py +++ b/brain/app/application/notebook_deep.py @@ -21,6 +21,7 @@ import tiktoken from app.application.llm_retry import generate_with_retry 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.ports import LLMChatProvider, LLMProvider, LLMProviderError from app.infrastructure import vector_store @@ -82,7 +83,7 @@ complète — mais POSSIBLEMENT VIDE si rien d'utile n'y figure), (2) le context {notes_block} --- FIN DES NOTES --- -Réponds en français.""" +Réponds en {language_name}.""" class NotebookDeepUseCase: @@ -109,6 +110,7 @@ class NotebookDeepUseCase: messages: list[ChatMessage], context: str = "", history_limit: int = 8, + language: str = _DEFAULT_LANG, ) -> AsyncIterator[dict]: """Yield des évènements : {type:'progress',current,total}, {type:'token',token}, {type:'done'}. (Les erreurs LLM des lots sont tolérées : lot ignoré.) @@ -175,7 +177,9 @@ class NotebookDeepUseCase: f"--- TA CAMPAGNE (structure, PNJ, univers) ---\n{context.strip()}\n--- FIN CAMPAGNE ---\n\n" if context.strip() else "" ) - system_prompt = _REDUCE_SYSTEM.format(context_block=context_block, notes_block=notes_block) + system_prompt = _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 # dernier message est bien la question courante. reduce_messages = messages[-history_limit:] if messages else [ChatMessage(role="user", content=question)] diff --git a/brain/app/core/language.py b/brain/app/core/language.py new file mode 100644 index 0000000..f97caf0 --- /dev/null +++ b/brain/app/core/language.py @@ -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) diff --git a/core/src/main/java/com/loremind/domain/conversationcontext/ports/ConversationTitleGenerator.java b/core/src/main/java/com/loremind/domain/conversationcontext/ports/ConversationTitleGenerator.java index 8b90bc4..c9842a8 100644 --- a/core/src/main/java/com/loremind/domain/conversationcontext/ports/ConversationTitleGenerator.java +++ b/core/src/main/java/com/loremind/domain/conversationcontext/ports/ConversationTitleGenerator.java @@ -10,6 +10,9 @@ import java.util.List; */ 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 firstMessages); } diff --git a/core/src/main/java/com/loremind/infrastructure/ai/BrainAiChatClient.java b/core/src/main/java/com/loremind/infrastructure/ai/BrainAiChatClient.java index 628fa7a..051d655 100644 --- a/core/src/main/java/com/loremind/infrastructure/ai/BrainAiChatClient.java +++ b/core/src/main/java/com/loremind/infrastructure/ai/BrainAiChatClient.java @@ -4,6 +4,7 @@ import com.loremind.domain.generationcontext.ChatRequest; import com.loremind.domain.generationcontext.ChatUsage; import com.loremind.domain.generationcontext.ports.AiChatProvider; import com.loremind.domain.generationcontext.ports.AiProviderException; +import com.loremind.infrastructure.web.config.UserLanguageHolder; import org.springframework.beans.factory.annotation.Value; import org.springframework.core.ParameterizedTypeReference; import org.springframework.http.MediaType; @@ -64,6 +65,7 @@ public class BrainAiChatClient implements AiChatProvider { Flux> flux = webClient.post() .uri(CHAT_STREAM_PATH) + .header(UserLanguageHolder.HEADER, UserLanguageHolder.get()) .contentType(MediaType.APPLICATION_JSON) .accept(MediaType.TEXT_EVENT_STREAM) .bodyValue(payload) diff --git a/core/src/main/java/com/loremind/infrastructure/ai/BrainCampaignAdaptClient.java b/core/src/main/java/com/loremind/infrastructure/ai/BrainCampaignAdaptClient.java index 0c8a534..0f48853 100644 --- a/core/src/main/java/com/loremind/infrastructure/ai/BrainCampaignAdaptClient.java +++ b/core/src/main/java/com/loremind/infrastructure/ai/BrainCampaignAdaptClient.java @@ -3,6 +3,7 @@ package com.loremind.infrastructure.ai; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.loremind.domain.campaigncontext.ports.CampaignPdfAdvisor; +import com.loremind.infrastructure.web.config.UserLanguageHolder; import org.springframework.beans.factory.annotation.Value; import org.springframework.core.ParameterizedTypeReference; import org.springframework.core.io.ByteArrayResource; @@ -65,6 +66,7 @@ public class BrainCampaignAdaptClient implements CampaignPdfAdvisor { Flux> flux = webClient.post() .uri(ADAPT_PATH) + .header(UserLanguageHolder.HEADER, UserLanguageHolder.get()) .contentType(MediaType.MULTIPART_FORM_DATA) .accept(MediaType.TEXT_EVENT_STREAM) .body(BodyInserters.fromMultipartData(parts.build())) diff --git a/core/src/main/java/com/loremind/infrastructure/ai/BrainCampaignImportClient.java b/core/src/main/java/com/loremind/infrastructure/ai/BrainCampaignImportClient.java index 3c11180..b147899 100644 --- a/core/src/main/java/com/loremind/infrastructure/ai/BrainCampaignImportClient.java +++ b/core/src/main/java/com/loremind/infrastructure/ai/BrainCampaignImportClient.java @@ -11,6 +11,7 @@ import com.loremind.domain.campaigncontext.CampaignImportProposal.RoomProposal; import com.loremind.domain.campaigncontext.CampaignImportProposal.SceneProposal; import com.loremind.domain.campaigncontext.ports.CampaignImportException; import com.loremind.domain.campaigncontext.ports.CampaignPdfImporter; +import com.loremind.infrastructure.web.config.UserLanguageHolder; import org.springframework.beans.factory.annotation.Value; import org.springframework.core.ParameterizedTypeReference; import org.springframework.core.io.ByteArrayResource; @@ -71,6 +72,7 @@ public class BrainCampaignImportClient implements CampaignPdfImporter { Flux> flux = webClient.post() .uri(IMPORT_CAMPAIGN_STREAM_PATH) + .header(UserLanguageHolder.HEADER, UserLanguageHolder.get()) .contentType(MediaType.MULTIPART_FORM_DATA) .accept(MediaType.TEXT_EVENT_STREAM) .body(BodyInserters.fromMultipartData(parts.build())) diff --git a/core/src/main/java/com/loremind/infrastructure/ai/BrainConversationTitleClient.java b/core/src/main/java/com/loremind/infrastructure/ai/BrainConversationTitleClient.java index 41eca5f..7db93eb 100644 --- a/core/src/main/java/com/loremind/infrastructure/ai/BrainConversationTitleClient.java +++ b/core/src/main/java/com/loremind/infrastructure/ai/BrainConversationTitleClient.java @@ -2,6 +2,7 @@ package com.loremind.infrastructure.ai; import com.loremind.domain.conversationcontext.ConversationMessage; import com.loremind.domain.conversationcontext.ports.ConversationTitleGenerator; +import com.loremind.infrastructure.web.config.UserLanguageHolder; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.MediaType; import org.springframework.stereotype.Component; @@ -50,6 +51,7 @@ public class BrainConversationTitleClient implements ConversationTitleGenerator @SuppressWarnings("unchecked") Map resp = webClient.post() .uri(PATH) + .header(UserLanguageHolder.HEADER, UserLanguageHolder.get()) .contentType(MediaType.APPLICATION_JSON) .bodyValue(payload) .retrieve() diff --git a/core/src/main/java/com/loremind/infrastructure/ai/BrainNotebookChatClient.java b/core/src/main/java/com/loremind/infrastructure/ai/BrainNotebookChatClient.java index 7b1c140..5cdf1a4 100644 --- a/core/src/main/java/com/loremind/infrastructure/ai/BrainNotebookChatClient.java +++ b/core/src/main/java/com/loremind/infrastructure/ai/BrainNotebookChatClient.java @@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.loremind.domain.campaigncontext.ports.NotebookChatStreamer; import com.loremind.domain.campaigncontext.ports.NotebookException; +import com.loremind.infrastructure.web.config.UserLanguageHolder; import org.springframework.beans.factory.annotation.Value; import org.springframework.core.ParameterizedTypeReference; import org.springframework.http.MediaType; @@ -66,6 +67,7 @@ public class BrainNotebookChatClient implements NotebookChatStreamer { Flux> flux = webClient.post() .uri(deep ? DEEP_PATH : PATH) + .header(UserLanguageHolder.HEADER, UserLanguageHolder.get()) .contentType(MediaType.APPLICATION_JSON) .accept(MediaType.TEXT_EVENT_STREAM) .bodyValue(payload) diff --git a/core/src/main/java/com/loremind/infrastructure/ai/BrainRulesImportClient.java b/core/src/main/java/com/loremind/infrastructure/ai/BrainRulesImportClient.java index 436d19f..c9f7728 100644 --- a/core/src/main/java/com/loremind/infrastructure/ai/BrainRulesImportClient.java +++ b/core/src/main/java/com/loremind/infrastructure/ai/BrainRulesImportClient.java @@ -6,6 +6,7 @@ import com.loremind.domain.gamesystemcontext.RulesImportProgress; import com.loremind.domain.gamesystemcontext.RulesImportResult; import com.loremind.domain.gamesystemcontext.ports.RulesImportException; import com.loremind.domain.gamesystemcontext.ports.RulesPdfImporter; +import com.loremind.infrastructure.web.config.UserLanguageHolder; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.beans.factory.annotation.Value; import org.springframework.core.ParameterizedTypeReference; @@ -125,6 +126,7 @@ public class BrainRulesImportClient implements RulesPdfImporter { Flux> flux = webClient.post() .uri(IMPORT_RULES_STREAM_PATH) + .header(UserLanguageHolder.HEADER, UserLanguageHolder.get()) .contentType(MediaType.MULTIPART_FORM_DATA) .accept(MediaType.TEXT_EVENT_STREAM) .body(BodyInserters.fromMultipartData(parts.build())) diff --git a/core/src/main/java/com/loremind/infrastructure/ai/RestTemplateConfig.java b/core/src/main/java/com/loremind/infrastructure/ai/RestTemplateConfig.java index e06c07b..534127b 100644 --- a/core/src/main/java/com/loremind/infrastructure/ai/RestTemplateConfig.java +++ b/core/src/main/java/com/loremind/infrastructure/ai/RestTemplateConfig.java @@ -1,5 +1,6 @@ package com.loremind.infrastructure.ai; +import com.loremind.infrastructure.web.config.UserLanguageHolder; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.web.client.RestTemplateBuilder; import org.springframework.boot.web.reactive.function.client.WebClientCustomizer; @@ -17,6 +18,12 @@ import java.time.Duration; *

* Sans cette entete, le Brain refuse la requete (401) — defense contre * l'acces direct au Brain depuis un attaquant qui atteindrait son port. + *

+ * 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 public class RestTemplateConfig { @@ -36,6 +43,7 @@ public class RestTemplateConfig { if (internalSecret != null && !internalSecret.isBlank()) { request.getHeaders().set(INTERNAL_SECRET_HEADER, internalSecret); } + request.getHeaders().set(UserLanguageHolder.HEADER, UserLanguageHolder.get()); return execution.execute(request, body); }) .build(); @@ -59,6 +67,7 @@ public class RestTemplateConfig { if (internalSecret != null && !internalSecret.isBlank()) { request.getHeaders().set(INTERNAL_SECRET_HEADER, internalSecret); } + request.getHeaders().set(UserLanguageHolder.HEADER, UserLanguageHolder.get()); return execution.execute(request, body); }) .build(); diff --git a/core/src/main/java/com/loremind/infrastructure/web/config/UserLanguageFilter.java b/core/src/main/java/com/loremind/infrastructure/web/config/UserLanguageFilter.java new file mode 100644 index 0000000..77dbf5f --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/web/config/UserLanguageFilter.java @@ -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. + *

+ * 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(); + } + } +} diff --git a/core/src/main/java/com/loremind/infrastructure/web/config/UserLanguageHolder.java b/core/src/main/java/com/loremind/infrastructure/web/config/UserLanguageHolder.java new file mode 100644 index 0000000..9737e81 --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/web/config/UserLanguageHolder.java @@ -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. + *

+ * 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. + *

+ * 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 SUPPORTED = Set.of("fr", "en"); + + private static final ThreadLocal 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(); + } +} diff --git a/web/Dockerfile b/web/Dockerfile index d852216..1caef91 100644 --- a/web/Dockerfile +++ b/web/Dockerfile @@ -5,11 +5,6 @@ COPY package*.json ./ RUN npm ci --include=dev --ignore-scripts --no-audit --no-fund --no-progress COPY . . -# Neutralise les URLs absolues hardcodees dans les services (dette assumee : -# une refacto propre passerait par src/environments/*.ts + fileReplacements). -# Le reverse proxy nginx route /api/ vers core:8080, donc chemin relatif OK. -RUN find src -type f -name "*.ts" -exec sed -i "s|http://localhost:8080||g" {} + - RUN npm run build -- --configuration production FROM nginx:alpine diff --git a/web/src/app/interceptors/language.interceptor.ts b/web/src/app/interceptors/language.interceptor.ts new file mode 100644 index 0000000..b73560a --- /dev/null +++ b/web/src/app/interceptors/language.interceptor.ts @@ -0,0 +1,18 @@ +import { HttpInterceptorFn } from '@angular/common/http'; +import { inject } from '@angular/core'; +import { LanguageService } from '../services/language.service'; + +/** + * Ajoute l'entête `X-User-Language` (langue choisie dans l'UI : `fr`/`en`) à + * toutes les requêtes HttpClient. Le Core la relaie au Brain, qui rédige alors + * ses réponses IA dans cette langue. + * + * NB : les appels SSE en `fetch()` (chat, imports, notebooks) ne passent PAS par + * les intercepteurs Angular — ils ajoutent l'entête manuellement de leur côté. + */ +export const languageInterceptor: HttpInterceptorFn = (req, next) => { + const language = inject(LanguageService); + return next( + req.clone({ setHeaders: { 'X-User-Language': language.current } }) + ); +}; diff --git a/web/src/app/services/ai-chat.service.ts b/web/src/app/services/ai-chat.service.ts index 7601dc6..492b11c 100644 --- a/web/src/app/services/ai-chat.service.ts +++ b/web/src/app/services/ai-chat.service.ts @@ -1,6 +1,7 @@ import { Injectable, inject } from '@angular/core'; import { Observable } from 'rxjs'; import { TranslateService } from '@ngx-translate/core'; +import { LanguageService } from './language.service'; /** * Un message d'une conversation IA (vue front). @@ -47,6 +48,7 @@ export type NarrativeEntityType = 'arc' | 'chapter' | 'scene' | 'character' | 'n @Injectable({ providedIn: 'root' }) export class AiChatService { private readonly translate = inject(TranslateService); + private readonly language = inject(LanguageService); private readonly loreEndpoint = '/api/ai/chat/stream'; private readonly campaignEndpoint = '/api/ai/chat/stream-campaign'; private readonly sessionEndpoint = '/api/ai/chat/stream-session'; @@ -110,7 +112,8 @@ export class AiChatService { method: 'POST', headers: { 'Content-Type': 'application/json', - 'Accept': 'text/event-stream' + 'Accept': 'text/event-stream', + 'X-User-Language': this.language.current }, body: JSON.stringify(body), signal: controller.signal diff --git a/web/src/app/services/campaign-import.service.ts b/web/src/app/services/campaign-import.service.ts index f7571f5..122d0d3 100644 --- a/web/src/app/services/campaign-import.service.ts +++ b/web/src/app/services/campaign-import.service.ts @@ -7,6 +7,7 @@ import { CampaignImportProposal, CampaignImportStreamEvent } from './campaign-import.model'; +import { LanguageService } from './language.service'; /** * Service HTTP pour l'import d'un PDF de campagne. @@ -17,7 +18,7 @@ import { */ @Injectable({ providedIn: 'root' }) export class CampaignImportService { - constructor(private http: HttpClient, private translate: TranslateService) {} + constructor(private http: HttpClient, private translate: TranslateService, private language: LanguageService) {} importStructureStream(campaignId: string, file: File): Observable { return new Observable((subscriber) => { @@ -27,7 +28,7 @@ export class CampaignImportService { fetch(`/api/campaigns/${campaignId}/import-structure/stream`, { method: 'POST', - headers: { 'Accept': 'text/event-stream' }, + headers: { 'Accept': 'text/event-stream', 'X-User-Language': this.language.current }, body: form, signal: controller.signal }) diff --git a/web/src/app/services/game-system.service.ts b/web/src/app/services/game-system.service.ts index 05f0862..a02fded 100644 --- a/web/src/app/services/game-system.service.ts +++ b/web/src/app/services/game-system.service.ts @@ -3,6 +3,7 @@ import { HttpClient, HttpParams } from '@angular/common/http'; import { Observable } from 'rxjs'; import { TranslateService } from '@ngx-translate/core'; import { GameSystem, GameSystemCreate, RulesImportResponse, RulesImportStreamEvent } from './game-system.model'; +import { LanguageService } from './language.service'; /** * Service HTTP pour les GameSystems (systèmes de JDR). @@ -11,7 +12,7 @@ import { GameSystem, GameSystemCreate, RulesImportResponse, RulesImportStreamEve export class GameSystemService { private apiUrl = '/api/game-systems'; - constructor(private http: HttpClient, private translate: TranslateService) {} + constructor(private http: HttpClient, private translate: TranslateService, private language: LanguageService) {} getAll(): Observable { return this.http.get(this.apiUrl); @@ -63,7 +64,7 @@ export class GameSystemService { fetch(`${this.apiUrl}/import-rules/stream`, { method: 'POST', - headers: { 'Accept': 'text/event-stream' }, + headers: { 'Accept': 'text/event-stream', 'X-User-Language': this.language.current }, body: form, signal: controller.signal }) diff --git a/web/src/app/services/notebook.service.ts b/web/src/app/services/notebook.service.ts index 5272886..5db80a0 100644 --- a/web/src/app/services/notebook.service.ts +++ b/web/src/app/services/notebook.service.ts @@ -3,6 +3,7 @@ import { HttpClient } from '@angular/common/http'; import { Observable } from 'rxjs'; import { TranslateService } from '@ngx-translate/core'; import { Notebook, NotebookArchive, NotebookDetail, NotebookSource, NotebookChatEvent } from './notebook.model'; +import { LanguageService } from './language.service'; /** * Service des notebooks (atelier RAG) : CRUD, upload/indexation de sources, @@ -12,7 +13,7 @@ import { Notebook, NotebookArchive, NotebookDetail, NotebookSource, NotebookChat export class NotebookService { private readonly apiUrl = '/api/notebooks'; - constructor(private http: HttpClient, private zone: NgZone, private translate: TranslateService) {} + constructor(private http: HttpClient, private zone: NgZone, private translate: TranslateService, private language: LanguageService) {} listByCampaign(campaignId: string): Observable { return this.http.get(`${this.apiUrl}/campaign/${campaignId}`); @@ -73,7 +74,7 @@ export class NotebookService { try { const response = await fetch(`${this.apiUrl}/${notebookId}/chat/stream`, { method: 'POST', - headers: { 'Content-Type': 'application/json', 'Accept': 'text/event-stream' }, + headers: { 'Content-Type': 'application/json', 'Accept': 'text/event-stream', 'X-User-Language': this.language.current }, credentials: 'include', body: JSON.stringify({ message, deep, diff --git a/web/src/main.ts b/web/src/main.ts index b8d7f7c..b5c8150 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -9,6 +9,7 @@ import { provideTranslateHttpLoader } from '@ngx-translate/http-loader'; import { ConfigService } from './app/services/config.service'; import { LanguageService } from './app/services/language.service'; import { sessionExpiredInterceptor } from './app/interceptors/session-expired.interceptor'; +import { languageInterceptor } from './app/interceptors/language.interceptor'; // withPreloading(PreloadAllModules) : une fois l'app initiale rendue, Angular // telecharge en arriere-plan tous les chunks lazy-loades. Consequence : la @@ -18,7 +19,7 @@ import { sessionExpiredInterceptor } from './app/interceptors/session-expired.in bootstrapApplication(AppComponent, { providers: [ provideZoneChangeDetection(),provideRouter(routes, withPreloading(PreloadAllModules)), - provideHttpClient(withInterceptors([sessionExpiredInterceptor])), + provideHttpClient(withInterceptors([sessionExpiredInterceptor, languageInterceptor])), provideTranslateService({ loader: provideTranslateHttpLoader({ prefix: 'assets/i18n/', From 1fb45635577440a24cfdcb03bc749132e6bcf23d Mon Sep 17 00:00:00 2001 From: "IETM_FIXE\\ietm6" Date: Mon, 15 Jun 2026 10:16:01 +0200 Subject: [PATCH 2/4] =?UTF-8?q?Red=C3=A9coupage=20des=20fichiers=20et=20so?= =?UTF-8?q?rtie=20des=20prompts=20dans=20leurs=20propre=20fichiers=20pour?= =?UTF-8?q?=20ne=20pas=20tout=20m=C3=A9langer=20ensemble.=20On=20garde=20m?= =?UTF-8?q?algr=C3=A8s=20tout=20les=20promps=20=C3=A0=20cot=C3=A9=20des=20?= =?UTF-8?q?parseurs=20car=20ils=20=C3=A9voluent=20g=C3=A9n=C3=A9ralement?= =?UTF-8?q?=20ensemble.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- brain/app/api/routers/generation.py | 21 +-- brain/app/api/routers/tables.py | 47 +----- brain/app/application/adapt_campaign.py | 31 +--- brain/app/application/chat.py | 22 +-- brain/app/application/generate_page.py | 24 +--- brain/app/application/import_campaign.py | 107 +------------- brain/app/application/import_rules.py | 61 +------- brain/app/application/notebook_chat.py | 82 +---------- brain/app/application/notebook_deep.py | 48 +------ brain/app/application/prompts/__init__.py | 12 ++ .../app/application/prompts/adapt_campaign.py | 26 ++++ brain/app/application/prompts/chat.py | 23 +++ .../application/prompts/conversation_title.py | 15 ++ .../app/application/prompts/generate_page.py | 19 +++ .../application/prompts/import_campaign.py | 100 +++++++++++++ brain/app/application/prompts/import_rules.py | 62 ++++++++ brain/app/application/prompts/notebook.py | 134 ++++++++++++++++++ .../app/application/prompts/query_rewrite.py | 20 +++ brain/app/application/prompts/rerank.py | 14 ++ brain/app/application/prompts/tables.py | 60 ++++++++ brain/app/application/query_rewrite.py | 19 +-- brain/app/application/rerank.py | 13 +- 22 files changed, 525 insertions(+), 435 deletions(-) create mode 100644 brain/app/application/prompts/__init__.py create mode 100644 brain/app/application/prompts/adapt_campaign.py create mode 100644 brain/app/application/prompts/chat.py create mode 100644 brain/app/application/prompts/conversation_title.py create mode 100644 brain/app/application/prompts/generate_page.py create mode 100644 brain/app/application/prompts/import_campaign.py create mode 100644 brain/app/application/prompts/import_rules.py create mode 100644 brain/app/application/prompts/notebook.py create mode 100644 brain/app/application/prompts/query_rewrite.py create mode 100644 brain/app/application/prompts/rerank.py create mode 100644 brain/app/application/prompts/tables.py diff --git a/brain/app/api/routers/generation.py b/brain/app/api/routers/generation.py index 5203b0f..d5df287 100644 --- a/brain/app/api/routers/generation.py +++ b/brain/app/api/routers/generation.py @@ -6,8 +6,9 @@ from pydantic import BaseModel, Field from app.api.deps import get_generate_page_use_case, get_llm_provider from app.application.generate_page import GeneratePageUseCase +from app.application.prompts import conversation_title as title_prompts from app.core.config import Settings, get_settings -from app.core.language import get_user_language, language_name +from app.core.language import get_user_language from app.domain.models import PageGenerationContext from app.domain.ports import LLMProvider, LLMProviderError @@ -103,20 +104,6 @@ class SummarizeTitleResponseDTO(BaseModel): title: str -# 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')." - ) - - @router.post("/summarize/conversation-title", response_model=SummarizeTitleResponseDTO) async def summarize_conversation_title( body: SummarizeTitleRequestDTO, @@ -132,7 +119,7 @@ async def summarize_conversation_title( raise HTTPException(status_code=422, detail="Au moins un message requis") transcript = "\n".join(f"{m.role.upper()}: {m.content}" for m in body.messages[:6]) - prompt = f"{_title_system_prompt(language)}\n\nConversation :\n{transcript}\n\nTitre :" + prompt = f"{title_prompts.title_system_prompt(language)}\n\nConversation :\n{transcript}\n\nTitre :" try: raw = await llm.generate(prompt) except LLMProviderError as exc: @@ -142,5 +129,5 @@ async def summarize_conversation_title( if len(title) > 80: title = title[:80].rstrip() if not title: - title = _TITLE_FALLBACK.get(language, _TITLE_FALLBACK["fr"]) + title = title_prompts.TITLE_FALLBACK.get(language, title_prompts.TITLE_FALLBACK["fr"]) return SummarizeTitleResponseDTO(title=title) diff --git a/brain/app/api/routers/tables.py b/brain/app/api/routers/tables.py index 1d42dee..7310f28 100644 --- a/brain/app/api/routers/tables.py +++ b/brain/app/api/routers/tables.py @@ -8,7 +8,8 @@ from pydantic import BaseModel, Field from app.api.deps import get_llm_provider from app.application.llm_json import load_json_object from app.application.llm_retry import generate_with_retry -from app.core.language import get_user_language, language_name +from app.application.prompts import tables as prompts +from app.core.language import get_user_language from app.domain.ports import LLMProvider, LLMProviderError router = APIRouter() @@ -59,22 +60,8 @@ async def generate_random_table( if rng is None: raise HTTPException(status_code=422, detail="Formule de dé invalide (ex. 1d20, 2d6, d100).") lo, hi = rng - context_block = f"\nContexte de la campagne :\n{body.context.strip()}\n" if body.context.strip() else "" - prompt = ( - "Tu es un assistant de jeu de rôle. Génère une TABLE ALÉATOIRE évocatrice.\n" - f"Dé : {body.dice_formula} (résultats possibles de {lo} à {hi}).\n" - f"Sujet : {body.description.strip()}\n" - f"{context_block}\n" - "Règles IMPÉRATIVES :\n" - "- Réponds UNIQUEMENT par un objet JSON valide, sans texte autour.\n" - '- Format : {"name": "...", "description": "...", "entries": ' - '[{"min_roll": N, "max_roll": M, "label": "résultat court", "detail": "1-2 phrases"}]}\n' - f"- Les plages (min_roll..max_roll) doivent COUVRIR EXACTEMENT {lo}..{hi}, " - "sans trou ni chevauchement, dans l'ordre croissant.\n" - "- Des résultats variés, cohérents avec le sujet (et le contexte s'il est fourni).\n" - f"- En {language_name(language)}. 'label' = résultat bref ; 'detail' = description/effet concret.\n" - "Renvoie maintenant le JSON." - ) + prompt = prompts.random_table_prompt( + body.description, body.dice_formula, lo, hi, body.context, language) try: raw = await generate_with_retry(llm, prompt, output_format="json", temperature=0.7) except LLMProviderError as exc: @@ -129,15 +116,8 @@ async def improvise_table_roll( language: Annotated[str, Depends(get_user_language)], ) -> ImproviseRollResponseDTO: """Brode un court récit (2-3 phrases) sur un résultat tiré, pour lancer la scène.""" - detail = f" ({body.result_detail.strip()})" if body.result_detail.strip() else "" - context_block = f"\nContexte : {body.context.strip()}" if body.context.strip() else "" - prompt = ( - "Tu es le Maître du Jeu. Les joueurs viennent de tirer sur la table " - f"« {body.table_name.strip()} » et ont obtenu : « {body.result_label.strip()} »{detail}." - f"{context_block}\n\n" - "Décris en 2-3 phrases vivantes et immédiates ce qui se passe, pour lancer la scène. " - f"Pas de méta, pas d'options : juste la narration, en {language_name(language)}." - ) + prompt = prompts.improvise_roll_prompt( + body.table_name, body.result_label, body.result_detail, body.context, language) try: raw = await llm.generate(prompt, temperature=0.8) except LLMProviderError as exc: @@ -173,20 +153,7 @@ async def generate_item_catalog( language: Annotated[str, Depends(get_user_language)], ) -> GenerateCatalogResponseDTO: """Génère un catalogue d'objets (boutique, butin…) — nom, prix, catégorie, description.""" - context_block = f"\nContexte de la campagne :\n{body.context.strip()}\n" if body.context.strip() else "" - prompt = ( - "Tu es un assistant de jeu de rôle. Génère un CATALOGUE D'OBJETS (boutique, butin, trésor…).\n" - f"Sujet : {body.description.strip()}\n" - f"{context_block}\n" - "Règles IMPÉRATIVES :\n" - "- Réponds UNIQUEMENT par un objet JSON valide, sans texte autour.\n" - '- Format : {"name": "...", "description": "...", "items": ' - '[{"name": "Objet", "price": "ex. 50 po", "category": "ex. Armes", "description": "effet/détails"}]}\n' - "- Des objets variés et cohérents avec le sujet (et le contexte s'il est fourni).\n" - "- 'price' = prix court dans la monnaie du jeu ; 'category' = regroupement (Armes, Potions…) ; " - f"'description' = effet/détails en une phrase. En {language_name(language)}.\n" - "Renvoie maintenant le JSON." - ) + prompt = prompts.item_catalog_prompt(body.description, body.context, language) try: raw = await generate_with_retry(llm, prompt, output_format="json", temperature=0.7) except LLMProviderError as exc: diff --git a/brain/app/application/adapt_campaign.py b/brain/app/application/adapt_campaign.py index 9727cf2..6a33bf3 100644 --- a/brain/app/application/adapt_campaign.py +++ b/brain/app/application/adapt_campaign.py @@ -13,7 +13,8 @@ from __future__ import annotations import logging from typing import AsyncIterator -from app.core.language import DEFAULT as _DEFAULT_LANG, language_name +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.ports import LLMChatProvider, PdfExtractionError, PdfTextExtractor @@ -22,30 +23,6 @@ logger = logging.getLogger(__name__) # Plus créatif que l'import (tâche de structuration) : ici on conseille/adapte. _TEMPERATURE = 0.7 -_SYSTEM_PREFIX = ( - "Tu es un assistant pour Maître de Jeu de jeu de rôle. L'utilisateur a une " - "campagne EXISTANTE (décrite plus bas) et souhaite ADAPTER et INTÉGRER le " - "contenu d'un PDF (aventure, donjon, supplément) à CETTE campagne précise." -) - - -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." -) - class AdaptCampaignUseCase: """Génère (en streaming) des conseils d'adaptation d'un PDF à une campagne.""" @@ -92,12 +69,12 @@ class AdaptCampaignUseCase: ) # Concaténation (pas .format) : brief/PDF peuvent contenir des { } littéraux. system_prompt = ( - f"{_SYSTEM_PREFIX}\n\n" + f"{prompts.SYSTEM_PREFIX}\n\n" "--- CAMPAGNE EXISTANTE DE L'UTILISATEUR ---\n" f"{brief.strip() or '(campagne encore vide)'}\n\n" "--- CONTENU DU PDF À ADAPTER ---\n" f"{pdf_text}{trunc_note}\n\n" - f"{_system_suffix(language)}\n\n" + f"{prompts.system_suffix(language)}\n\n" "Tu es en CONVERSATION : à chaque message de l'utilisateur, ajuste, corrige " "ou propose des alternatives en gardant tout ce contexte à l'esprit." ) diff --git a/brain/app/application/chat.py b/brain/app/application/chat.py index a8216b1..47c0429 100644 --- a/brain/app/application/chat.py +++ b/brain/app/application/chat.py @@ -31,7 +31,8 @@ from app.domain.models import ( QuestSummary, SessionContext, ) -from app.core.language import DEFAULT as _DEFAULT_LANG, language_name +from app.application.prompts import chat as prompts +from app.core.language import DEFAULT as _DEFAULT_LANG from app.domain.ports import LLMChatProvider @@ -41,23 +42,6 @@ from app.domain.ports import LLMChatProvider _DEFAULT_TEMPERATURE = 0.7 -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").""" - - class ChatUseCase: """Orchestre un tour de conversation avec le LLM + contextes structurels.""" @@ -124,7 +108,7 @@ class ChatUseCase: session: SessionContext | None = None, language: str = _DEFAULT_LANG, ) -> str: - sections = [_base_system(language)] + sections = [prompts.base_system(language)] if lore is not None: sections.append(self._format_lore(lore)) if campaign is not None: diff --git a/brain/app/application/generate_page.py b/brain/app/application/generate_page.py index 89bdecd..0c98df8 100644 --- a/brain/app/application/generate_page.py +++ b/brain/app/application/generate_page.py @@ -8,10 +8,13 @@ permet de tester ce use case avec un FakeLLMProvider, sans Ollama qui tourne. """ import json -from app.core.language import DEFAULT as _DEFAULT_LANG, language_name +from app.application.prompts import generate_page as prompts from app.domain.models import PageGenerationContext, PageGenerationResult 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. # Une valeur trop haute (par défaut Ollama = 0.8) encourage l'IA à broder @@ -19,23 +22,6 @@ from app.domain.ports import LLMProvider, LLMProviderError _DEFAULT_TEMPERATURE = 0.4 -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.""" - - class GeneratePageUseCase: """Orchestre la génération d'une page LoreMind via un LLM.""" @@ -66,7 +52,7 @@ class GeneratePageUseCase: ) return ( - f"{_system_instructions(language)}\n\n" + f"{prompts.system_instructions(language)}\n\n" f"Univers : {context.lore_name}" f"{lore_desc_line}\n" f"Catégorie (dossier) : {context.folder_name}\n" diff --git a/brain/app/application/import_campaign.py b/brain/app/application/import_campaign.py index 00dee07..fb9d70c 100644 --- a/brain/app/application/import_campaign.py +++ b/brain/app/application/import_campaign.py @@ -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_retry import generate_with_retry +from app.application.prompts import import_campaign as prompts from app.application.streaming import with_heartbeat # 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). _TEMPERATURE = 0.1 -# Nom de l'arc unique quand le livre n'est pas découpé en actes/parties. -_DEFAULT_ARC_NAME = "Aventure principale" - # Morceaux PLUS GROS que pour les règles : l'IA voit une quête/un chapitre entier # d'un coup et le structure de façon cohérente (1 scène par lieu) au lieu de le # fragmenter en dizaines de scènes. Adapté aux providers à grand contexte (1min.ai). _CHUNK_TARGET_TOKENS = 10000 -_MAP_SYSTEM = """Tu es un assistant qui structure un livre de campagne de jeu de rôle. -On te donne un EXTRAIT brut d'un PDF de campagne (texte parfois mal coupé par la mise en page). - -Ta tâche : en dégager une ARBORESCENCE narrative à GROS GRAIN : arcs → chapitres → scènes, -et — pour les lieux explorables — leurs PIÈCES (rooms). - - Un ARC = un acte / une grande partie de la campagne (souvent un seul pour une aventure courte). - - Un CHAPITRE = une étape majeure du récit : un chapitre du livre, OU — dans une - campagne "hub" / bac-à-sable — UNE QUÊTE ou UN LIEU principal débloqué depuis le - point central (ex : Dragon of Icespire Peak → chaque quête/lieu = un chapitre). - - Une SCÈNE = un temps fort jouable du chapitre : un lieu, une rencontre clé, un moment pivot. - - Une PIÈCE (room) = une salle d'un lieu explorable (donjon, crypte, manoir...). - -TYPE D'ARC ("type") : -- "HUB" si la campagne est un bac-à-sable : des quêtes/lieux optionnels, parallèles, - débloqués depuis un point central, SANS ordre fixe imposé (ex : Dragon of Icespire Peak). -- "LINEAR" si les chapitres se jouent dans un ordre séquentiel imposé. -- Dans le doute : "LINEAR". - -GRANULARITÉ (évite la sur-détection) : -- Vise PEU de scènes : typiquement 1 à 6 par chapitre. PAS des dizaines. -- Un LIEU EXPLORABLE (donjon, crypte, manoir, grotte à plusieurs salles) = UNE SEULE - scène. Ses salles vont dans le tableau "rooms" de cette scène — JAMAIS en scènes séparées. -- NE crée PAS une scène par rencontre isolée, par PNJ, par monstre ou par paragraphe. -- IGNORE : blocs de stats, listes de monstres, encarts de règles, légendes de cartes, - pieds de page, sommaires, crédits. - -CONTENU D'UNE SCÈNE (fidélité au livre — important) : -- `description` = synopsis de la scène, 2 à 4 phrases (plus que 1 ligne, mais pas le texte intégral). -- `player_narration` = le texte d'AMBIANCE « à lire aux joueurs » (encadrés / boxed text / - « lecture à voix haute »), recopié FIDÈLEMENT s'il existe dans l'extrait. Vide sinon. -- `gm_notes` = les informations pour le MJ : secrets, développement, ce qui se passe, - conséquences, indices cachés. Vide si rien de tel. -- Ne RÉSUME pas abusivement player_narration et gm_notes : recopie le contenu utile du livre. - -PIÈCES (rooms) — uniquement pour les scènes qui sont des lieux explorables : -- Une entrée par salle numérotée/nommée du donjon (ex : "1. Entrée", "2. Salle des gardes"). -- `enemies` = créatures/boss de la salle (vide si aucune). `loot` = trésor/récompense (vide si aucun). -- Pour une scène narrative classique (pas un donjon), "rooms" est un tableau vide []. - -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 # 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 @@ -195,47 +131,12 @@ _TREE_SCHEMA: dict = { "additionalProperties": False, } -# Bloc TOC injecté quand le PDF a des bookmarks : les morceaux étant traités -# séparément, c'est CE référentiel commun qui garantit que tous nomment les -# mêmes chapitres à l'identique → la fusion par nom du _TreeMerger recolle -# les chapitres coupés au lieu de créer des doublons. -_TOC_BLOCK = """ - ---- STRUCTURE OFFICIELLE DU LIVRE (table des matières du PDF) --- -{toc} ---- FIN DE LA STRUCTURE --- -IMPORTANT : pour nommer les arcs et chapitres, reprends EXACTEMENT les titres -de cette structure (caractère pour caractère). Rattache le contenu de l'extrait -au bon chapitre de la structure, même si son titre n'apparaît pas dans l'extrait.""" - # Garde-fou prompt : une TOC de gros livre peut compter des centaines d'entrées # (sous-sous-sections). On la limite aux niveaux hauts et à un nombre raisonnable. _TOC_MAX_LEVEL = 2 _TOC_MAX_ENTRIES = 80 -# Consolidation finale : le squelette (noms seuls) est minuscule, donc l'appel -# est quasi gratuit comparé aux MAP. Température 0 et consigne CONSERVATRICE : -# ne fusionner que les doublons évidents, jamais des entités distinctes. -_CONSOLIDATE_PROMPT = """Voici le squelette d'une arborescence arc → chapitre → scène issue d'une -fusion AUTOMATIQUE de morceaux d'un livre de campagne de jeu de rôle. La fusion par nom exact -peut avoir laissé des QUASI-DOUBLONS : le même chapitre ou la même scène sous deux libellés -légèrement différents (ex: "La Crypte" et "Crypte de Karrak", "3. Salle des gardes" et -"Salle des gardes"). - -{skeleton} - -Identifie UNIQUEMENT les fusions ÉVIDENTES (même entité du livre sous deux noms). Sois -CONSERVATEUR : dans le doute, ne fusionne PAS. Deux lieux/évènements distincts ne doivent -JAMAIS être fusionnés. - -Réponds UNIQUEMENT par un objet JSON valide : -{{"chapter_merges": [{{"into": "nom du chapitre à garder", "merge": ["nom à fusionner", ...]}}], - "scene_merges": [{{"chapter": "nom du chapitre", "into": "nom de la scène à garder", - "merge": ["nom à fusionner", ...]}}]}} -S'il n'y a RIEN à fusionner (cas le plus fréquent) : {{"chapter_merges": [], "scene_merges": []}}""" - - def _format_toc(toc) -> str: """Formate la TOC du PDF en liste indentée, bornée (niveaux hauts d'abord).""" entries = [e for e in toc if e.level <= _TOC_MAX_LEVEL][:_TOC_MAX_ENTRIES] @@ -626,7 +527,7 @@ class ImportCampaignUseCase: skeleton = merger.skeleton_text() try: 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) except Exception as exc: # noqa: BLE001 — best-effort STRICT : une erreur ici # (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, retraite le texte en DEUX moitiés et concatène — le `_TreeMerger` final dédoublonne par nom (un arc/chapitre coupé entre les moitiés est recollé).""" - toc_section = _TOC_BLOCK.format(toc=toc_block) if toc_block else "" + toc_section = prompts.TOC_BLOCK.format(toc=toc_block) if toc_block else "" prompt = ( - _MAP_SYSTEM.format(default_arc=_DEFAULT_ARC_NAME) + prompts.MAP_SYSTEM.format(default_arc=prompts.DEFAULT_ARC_NAME) + toc_section + f"\n\n--- EXTRAIT {index + 1}/{total} ---\n{text}\n\n" "Renvoie maintenant le JSON de l'arborescence." diff --git a/brain/app/application/import_rules.py b/brain/app/application/import_rules.py index 7891d89..196dea9 100644 --- a/brain/app/application/import_rules.py +++ b/brain/app/application/import_rules.py @@ -25,6 +25,7 @@ from app.application.import_status import ( ) 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.prompts import import_rules as prompts from app.application.streaming import with_heartbeat from app.core.language import DEFAULT as _DEFAULT_LANG, language_name @@ -58,43 +59,6 @@ _SECTIONS_SCHEMA: dict = { "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 {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).""" - # --- Mode SEGMENTATION (modèles locaux) -------------------------------------- # Réécrire tout le texte en JSON impose une SORTIE ≈ taille de l'ENTRÉE : à # ~100 tokens/s en local, un livre = des dizaines de minutes et des troncatures @@ -103,25 +67,6 @@ Règles impératives : # qui découpons le texte original. ~50× plus rapide, fidélité parfaite du # contenu (texte source intact), plus de troncature possible. -_SEGMENT_SYSTEM = """Tu analyses un EXTRAIT brut d'un livre de règles de jeu de rôle. -Ta tâche : repérer où COMMENCENT les sections thématiques. Tu ne réécris RIEN. - -Format EXACT attendu : -{{"sections": [{{"titre": "Combat", "debut": "Le combat se déroule en tours de"}}, ...]}} - -Règles impératives : -- "debut" = les 5 à 10 PREMIERS MOTS du passage où la section commence, COPIÉS À L'IDENTIQUE - depuis l'extrait (même orthographe, même ponctuation, même langue). JAMAIS un résumé. -- La PREMIÈRE entrée commence aux tout premiers mots de l'extrait (même si le contenu - poursuit une section entamée avant cet extrait). -- Les entrées suivent l'ordre du texte. Vise des sections LARGES (un thème), pas un titre - par paragraphe : un extrait contient typiquement 1 à 6 sections. -- Titres : EN PRIORITÉ parmi : -{canonical} - sinon un titre court et clair en {language_name}. -- Pages de garde, sommaires, crédits : n'en fais pas des sections. Si l'extrait n'est que ça, - renvoie {{"sections": []}}.""" - # Schéma passé à Ollama (structured outputs) : un objet {"sections": [...]}. # Racine objet (pas tableau) car l'extraction côté Brain repère le premier {…}. _ANCHORS_SCHEMA: dict = { @@ -421,11 +366,11 @@ class ImportRulesUseCase: """Extrait les sections d'un texte. Si la SORTIE est tronquée, retraite le texte en DEUX moitiés (chacune produit une réponse complète) et fusionne — ainsi aucune section n'est perdue, quel que soit le plafond de sortie.""" - system = _SEGMENT_SYSTEM if self._segment_only else _MAP_SYSTEM + system = prompts.SEGMENT_SYSTEM if self._segment_only else prompts.MAP_SYSTEM schema = _ANCHORS_SCHEMA if self._segment_only else _SECTIONS_SCHEMA prompt = ( 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" diff --git a/brain/app/application/notebook_chat.py b/brain/app/application/notebook_chat.py index b2b27d5..88600ce 100644 --- a/brain/app/application/notebook_chat.py +++ b/brain/app/application/notebook_chat.py @@ -9,91 +9,13 @@ from __future__ import annotations from typing import AsyncIterator 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.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.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, 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": {{"": "contenu", "": "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.""" - class NotebookChatUseCase: def __init__( @@ -145,7 +67,7 @@ class NotebookChatUseCase: f"--- TA CAMPAGNE ---\n{context.strip()}\n--- FIN CAMPAGNE ---\n\n" if context.strip() else "--- TA CAMPAGNE ---\n(aucune donnée de campagne)\n--- FIN CAMPAGNE ---\n\n" ) - system_prompt = _SYSTEM_PROMPT.format( + system_prompt = prompts.CHAT_SYSTEM.format( 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): diff --git a/brain/app/application/notebook_deep.py b/brain/app/application/notebook_deep.py index 0369b65..eda2453 100644 --- a/brain/app/application/notebook_deep.py +++ b/brain/app/application/notebook_deep.py @@ -20,6 +20,7 @@ from typing import AsyncIterator import tiktoken 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.core.language import DEFAULT as _DEFAULT_LANG, language_name from app.domain.models import ChatMessage @@ -37,15 +38,6 @@ _MAP_TEMPERATURE = 0.2 # à la question par embedding, et seuls les lots plausiblement pertinents sont # relus. Sélection volontairement CONSERVATRICE (on préfère relire un lot de # trop que rater une mention) ; désactivable via deep_summary_filter=False. -_SUMMARY_PROMPT = """Résume l'EXTRAIT ci-dessous en 4 à 8 puces factuelles : lieux, PNJ et -créatures nommés, objets notables, évènements, règles particulières. Pas d'analyse, pas -d'introduction — uniquement les puces, pour servir d'index de recherche. - ---- EXTRAIT --- -{excerpt} ---- FIN EXTRAIT --- - -Résumé :""" # Un lot est gardé si son score est proche du meilleur (marge) OU bon dans # l'absolu ; et on garde toujours au moins _MIN_KEPT lots. @@ -53,38 +45,6 @@ _SELECT_MARGIN = 0.10 _SELECT_FLOOR = 0.5 _MIN_KEPT = 3 -_MAP_PROMPT = """Voici un EXTRAIT d'un document. Extrais UNIQUEMENT les informations -pertinentes pour répondre à la question ci-dessous. Conserve les détails utiles et -indique les numéros de page (format « p. X »). Si l'extrait ne contient RIEN de -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}.""" - class NotebookDeepUseCase: def __init__( @@ -177,7 +137,7 @@ class NotebookDeepUseCase: f"--- TA CAMPAGNE (structure, PNJ, univers) ---\n{context.strip()}\n--- FIN CAMPAGNE ---\n\n" if context.strip() else "" ) - system_prompt = _REDUCE_SYSTEM.format( + 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 @@ -255,7 +215,7 @@ class NotebookDeepUseCase: async def _summarize_batch(self, batch: list[dict]) -> str: excerpt = "\n\n".join(c.get("text", "").strip() for c in batch) raw = await generate_with_retry( - self._llm, _SUMMARY_PROMPT.format(excerpt=excerpt), temperature=_MAP_TEMPERATURE) + self._llm, prompts.SUMMARY_PROMPT.format(excerpt=excerpt), temperature=_MAP_TEMPERATURE) return (raw or "").strip() async def _map_batch(self, question: str, batch: list[dict]) -> str: @@ -264,7 +224,7 @@ class NotebookDeepUseCase: f"(p. {c['page']}) {c['text'].strip()}" if c.get("page") else c["text"].strip() for c in batch ) - prompt = _MAP_PROMPT.format(no_match=_NO_MATCH, question=question, excerpt=excerpt) + prompt = prompts.MAP_PROMPT.format(no_match=_NO_MATCH, question=question, excerpt=excerpt) raw = await generate_with_retry(self._llm, prompt, temperature=_MAP_TEMPERATURE) answer = raw.strip() if answer and answer.upper().rstrip(".") != _NO_MATCH: diff --git a/brain/app/application/prompts/__init__.py b/brain/app/application/prompts/__init__.py new file mode 100644 index 0000000..2146204 --- /dev/null +++ b/brain/app/application/prompts/__init__.py @@ -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`. +""" diff --git a/brain/app/application/prompts/adapt_campaign.py b/brain/app/application/prompts/adapt_campaign.py new file mode 100644 index 0000000..098d561 --- /dev/null +++ b/brain/app/application/prompts/adapt_campaign.py @@ -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." + ) diff --git a/brain/app/application/prompts/chat.py b/brain/app/application/prompts/chat.py new file mode 100644 index 0000000..0aba550 --- /dev/null +++ b/brain/app/application/prompts/chat.py @@ -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").""" diff --git a/brain/app/application/prompts/conversation_title.py b/brain/app/application/prompts/conversation_title.py new file mode 100644 index 0000000..82abd60 --- /dev/null +++ b/brain/app/application/prompts/conversation_title.py @@ -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')." + ) diff --git a/brain/app/application/prompts/generate_page.py b/brain/app/application/prompts/generate_page.py new file mode 100644 index 0000000..b605776 --- /dev/null +++ b/brain/app/application/prompts/generate_page.py @@ -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.""" diff --git a/brain/app/application/prompts/import_campaign.py b/brain/app/application/prompts/import_campaign.py new file mode 100644 index 0000000..09e3fe5 --- /dev/null +++ b/brain/app/application/prompts/import_campaign.py @@ -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": []}}""" diff --git a/brain/app/application/prompts/import_rules.py b/brain/app/application/prompts/import_rules.py new file mode 100644 index 0000000..ae47609 --- /dev/null +++ b/brain/app/application/prompts/import_rules.py @@ -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": []}}.""" diff --git a/brain/app/application/prompts/notebook.py b/brain/app/application/prompts/notebook.py new file mode 100644 index 0000000..23092bc --- /dev/null +++ b/brain/app/application/prompts/notebook.py @@ -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": {{"": "contenu", "": "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}.""" diff --git a/brain/app/application/prompts/query_rewrite.py b/brain/app/application/prompts/query_rewrite.py new file mode 100644 index 0000000..d282648 --- /dev/null +++ b/brain/app/application/prompts/query_rewrite.py @@ -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 :""" diff --git a/brain/app/application/prompts/rerank.py b/brain/app/application/prompts/rerank.py new file mode 100644 index 0000000..d031465 --- /dev/null +++ b/brain/app/application/prompts/rerank.py @@ -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.""" diff --git a/brain/app/application/prompts/tables.py b/brain/app/application/prompts/tables.py new file mode 100644 index 0000000..41bd18c --- /dev/null +++ b/brain/app/application/prompts/tables.py @@ -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." + ) diff --git a/brain/app/application/query_rewrite.py b/brain/app/application/query_rewrite.py index 2107169..438e55c 100644 --- a/brain/app/application/query_rewrite.py +++ b/brain/app/application/query_rewrite.py @@ -11,6 +11,7 @@ from __future__ import annotations import logging +from app.application.prompts import query_rewrite as prompts from app.domain.models import ChatMessage logger = logging.getLogger(__name__) @@ -23,22 +24,6 @@ _MAX_HISTORY = 6 # modèle a divagué) → on retombe sur la question brute. _MAX_REWRITE_CHARS = 400 -_REWRITE_PROMPT = """Voici la fin d'une conversation entre un Maître de Jeu et son assistant. -Réécris le DERNIER message de l'utilisateur en une question AUTONOME et complète : -remplace les pronoms et références implicites (« il », « ses », « ce lieu », « et pour -les autres ? ») par ce qu'ils désignent dans la conversation. - -Règles : -- Réponds UNIQUEMENT par la question réécrite, sans guillemets ni préfixe. -- Conserve la langue et l'intention d'origine. N'ajoute RIEN qui n'est pas demandé. -- Si le dernier message est déjà autonome, recopie-le tel quel. - ---- CONVERSATION --- -{conversation} ---- FIN --- - -Question autonome :""" - async def standalone_question(llm, messages: list[ChatMessage]) -> str: """Condense `messages` en une question autonome pour la RECHERCHE. @@ -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) try: 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 logger.warning("Réécriture de question ignorée (échec LLM) : %s", exc) return last_user diff --git a/brain/app/application/rerank.py b/brain/app/application/rerank.py index c7e6147..46e94a3 100644 --- a/brain/app/application/rerank.py +++ b/brain/app/application/rerank.py @@ -11,6 +11,7 @@ from __future__ import annotations import logging from app.application.llm_json import load_json_object +from app.application.prompts import rerank as prompts logger = logging.getLogger(__name__) @@ -23,16 +24,6 @@ POOL_MAX = 24 # prompt sans changer le jugement de pertinence. _EXCERPT_CHARS = 600 -_RERANK_PROMPT = """Tu évalues la PERTINENCE d'extraits d'un document pour répondre à une question. -Note chaque extrait de 0 (sans rapport) à 10 (répond directement), indépendamment des autres. - -QUESTION : {question} - -{passages} - -Réponds UNIQUEMENT par un objet JSON : {{"scores": [note_extrait_1, note_extrait_2, ...]}} -Le tableau doit contenir EXACTEMENT {count} notes, dans l'ordre des extraits.""" - def pool_size(top_k: int) -> int: """Taille du pool à récupérer avant reranking.""" @@ -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]}" for i, p in enumerate(passages) ) - prompt = _RERANK_PROMPT.format( + prompt = prompts.RERANK_PROMPT.format( question=question, passages=numbered, count=len(passages)) try: raw = await llm.generate(prompt, temperature=0.0) From 9d4e72af26734b7ef6b67f4db3e1dd8d926150ce Mon Sep 17 00:00:00 2001 From: "IETM_FIXE\\ietm6" Date: Tue, 16 Jun 2026 14:13:47 +0200 Subject: [PATCH 3/4] =?UTF-8?q?Mise=20=C3=A0=20jour=20du=20gitignore=20pou?= =?UTF-8?q?r=20ne=20pas=20avoir=20la=20documentation=20r=C3=A9serv=C3=A9e?= =?UTF-8?q?=20patreon=20dans=20le=20r=C3=A9pertoire=20g=C3=A9n=C3=A9ral?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.gitignore b/.gitignore index b569797..4eea073 100644 --- a/.gitignore +++ b/.gitignore @@ -109,3 +109,10 @@ docker-compose.override.yml relay/ scripts/bump-version.mjs 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/ From 1e501e03a46f4bf63cc9d7a3dd3ed49aaa3496ef Mon Sep 17 00:00:00 2001 From: "IETM_FIXE\\ietm6" Date: Tue, 16 Jun 2026 15:15:48 +0200 Subject: [PATCH 4/4] Mise en place du readme en anglais --- README.fr.md | 68 ++++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 70 +++++++++++++++++++++++++++------------------------- 2 files changed, 104 insertions(+), 34 deletions(-) create mode 100644 README.fr.md diff --git a/README.fr.md b/README.fr.md new file mode 100644 index 0000000..cf9fe2b --- /dev/null +++ b/README.fr.md @@ -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. diff --git a/README.md b/README.md index bc7a8c8..ca73f92 100644 --- a/README.md +++ b/README.md @@ -1,66 +1,68 @@ # 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) -[![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) +> A self-hostable web app for game masters who want to centralize their world, campaigns and characters — with a context-aware AI assistant. -## 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 -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 -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 -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 -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 : -- 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) +A few limitations to be aware of: +- 10 concurrent users maximum (isolated instances) +- Sessions limited to 20 minutes before reset +- The AI part is not included in the demo (requires Ollama or 1min.ai server-side) -## 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 -- **[Discord](https://discord.gg/cPpFzCjEzQ)** — annonces, support, retours utilisateurs +- **[Patreon](https://www.patreon.com/c/IGMLCreation)** — early access to features, roadmap voting, exclusive devlogs +- **[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 : -- 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. +In practice: +- You can use it for free, host it, modify it, and redistribute it. +- If you modify the code and expose the modified app over a network (even as a private SaaS), you must make your changes public under the same license. +- The worlds (Lore) and campaigns you create with LoreMind **belong entirely to you** — the license only covers the application's code.