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:
@@ -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."
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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)]
|
||||
|
||||
Reference in New Issue
Block a user