Prise en compte du langage de l'utilisateur pour le prompt de réponse. Si par exemple l'interface est en anglais, les IA vont favoriser l'anglais pour la réponse

This commit is contained in:
2026-06-15 09:49:05 +02:00
parent 914767f793
commit 84025911f8
29 changed files with 319 additions and 63 deletions

View File

@@ -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"

View File

@@ -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 = (
# 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 "
"en francais et capturer le sujet metier (pas 'Conversation IA')."
)
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)

View File

@@ -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:

View File

@@ -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:

View File

@@ -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:

View File

@@ -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."
)

View File

@@ -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:

View File

@@ -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"

View File

@@ -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(

View File

@@ -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}

View File

@@ -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)]

View File

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

View File

@@ -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<ConversationMessage> firstMessages);
}

View File

@@ -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<ServerSentEvent<String>> flux = webClient.post()
.uri(CHAT_STREAM_PATH)
.header(UserLanguageHolder.HEADER, UserLanguageHolder.get())
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.TEXT_EVENT_STREAM)
.bodyValue(payload)

View File

@@ -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<ServerSentEvent<String>> 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()))

View File

@@ -11,6 +11,7 @@ import com.loremind.domain.campaigncontext.CampaignImportProposal.RoomProposal;
import com.loremind.domain.campaigncontext.CampaignImportProposal.SceneProposal;
import com.loremind.domain.campaigncontext.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<ServerSentEvent<String>> 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()))

View File

@@ -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<String, Object> resp = webClient.post()
.uri(PATH)
.header(UserLanguageHolder.HEADER, UserLanguageHolder.get())
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(payload)
.retrieve()

View File

@@ -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<ServerSentEvent<String>> flux = webClient.post()
.uri(deep ? DEEP_PATH : PATH)
.header(UserLanguageHolder.HEADER, UserLanguageHolder.get())
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.TEXT_EVENT_STREAM)
.bodyValue(payload)

View File

@@ -6,6 +6,7 @@ import com.loremind.domain.gamesystemcontext.RulesImportProgress;
import com.loremind.domain.gamesystemcontext.RulesImportResult;
import com.loremind.domain.gamesystemcontext.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<ServerSentEvent<String>> 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()))

View File

@@ -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;
* <p>
* Sans cette entete, le Brain refuse la requete (401) — defense contre
* l'acces direct au Brain depuis un attaquant qui atteindrait son port.
* <p>
* Relaie aussi l'entete X-User-Language (langue choisie dans l'UI, capturee par
* {@link com.loremind.infrastructure.web.config.UserLanguageFilter}) pour que le
* Brain redige ses reponses IA dans la langue de l'utilisateur. Lu depuis le
* ThreadLocal au moment de l'execution de la requete (thread servlet) — d'ou
* l'usage d'un interceptor (et non d'un defaultHeader fige au demarrage).
*/
@Configuration
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();

View File

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

View File

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

View File

@@ -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

View File

@@ -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 } })
);
};

View File

@@ -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

View File

@@ -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<CampaignImportStreamEvent> {
return new Observable<CampaignImportStreamEvent>((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
})

View File

@@ -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<GameSystem[]> {
return this.http.get<GameSystem[]>(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
})

View File

@@ -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<Notebook[]> {
return this.http.get<Notebook[]>(`${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,

View File

@@ -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/',