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