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

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