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:
@@ -18,6 +18,7 @@ from app.api.chat_mapping import (
|
|||||||
from app.api.deps import get_chat_use_case
|
from app.api.deps import get_chat_use_case
|
||||||
from app.application.chat import ChatUseCase
|
from app.application.chat import ChatUseCase
|
||||||
from app.core.config import get_settings
|
from app.core.config import get_settings
|
||||||
|
from app.core.language import get_user_language
|
||||||
from app.domain.models import ChatMessage
|
from app.domain.models import ChatMessage
|
||||||
from app.domain.ports import LLMProviderError
|
from app.domain.ports import LLMProviderError
|
||||||
|
|
||||||
@@ -44,6 +45,7 @@ def _count_tokens(text: str | None) -> int:
|
|||||||
async def chat_stream(
|
async def chat_stream(
|
||||||
body: ChatStreamRequestDTO,
|
body: ChatStreamRequestDTO,
|
||||||
use_case: Annotated[ChatUseCase, Depends(get_chat_use_case)],
|
use_case: Annotated[ChatUseCase, Depends(get_chat_use_case)],
|
||||||
|
language: Annotated[str, Depends(get_user_language)],
|
||||||
) -> StreamingResponse:
|
) -> StreamingResponse:
|
||||||
"""Chat streamé (Server-Sent Events) avec Structural Context.
|
"""Chat streamé (Server-Sent Events) avec Structural Context.
|
||||||
|
|
||||||
@@ -82,6 +84,7 @@ async def chat_stream(
|
|||||||
narrative_entity=narrative_entity,
|
narrative_entity=narrative_entity,
|
||||||
game_system_context=game_system_context,
|
game_system_context=game_system_context,
|
||||||
session_context=session_context,
|
session_context=session_context,
|
||||||
|
language=language,
|
||||||
)
|
)
|
||||||
# Dernier message = "current" (souvent user), le reste = historique accumulé.
|
# Dernier message = "current" (souvent user), le reste = historique accumulé.
|
||||||
current_msg = messages[-1] if messages else None
|
current_msg = messages[-1] if messages else None
|
||||||
@@ -109,6 +112,7 @@ async def chat_stream(
|
|||||||
narrative_entity=narrative_entity,
|
narrative_entity=narrative_entity,
|
||||||
game_system_context=game_system_context,
|
game_system_context=game_system_context,
|
||||||
session_context=session_context,
|
session_context=session_context,
|
||||||
|
language=language,
|
||||||
):
|
):
|
||||||
# json.dumps avec ensure_ascii=False pour préserver les accents
|
# json.dumps avec ensure_ascii=False pour préserver les accents
|
||||||
yield f"data: {json.dumps({'token': token}, ensure_ascii=False)}\n\n"
|
yield f"data: {json.dumps({'token': token}, ensure_ascii=False)}\n\n"
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from pydantic import BaseModel, Field
|
|||||||
from app.api.deps import get_generate_page_use_case, get_llm_provider
|
from app.api.deps import get_generate_page_use_case, get_llm_provider
|
||||||
from app.application.generate_page import GeneratePageUseCase
|
from app.application.generate_page import GeneratePageUseCase
|
||||||
from app.core.config import Settings, get_settings
|
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.models import PageGenerationContext
|
||||||
from app.domain.ports import LLMProvider, LLMProviderError
|
from app.domain.ports import LLMProvider, LLMProviderError
|
||||||
|
|
||||||
@@ -60,6 +61,7 @@ async def generate_page(
|
|||||||
use_case: Annotated[
|
use_case: Annotated[
|
||||||
GeneratePageUseCase, Depends(get_generate_page_use_case)
|
GeneratePageUseCase, Depends(get_generate_page_use_case)
|
||||||
],
|
],
|
||||||
|
language: Annotated[str, Depends(get_user_language)],
|
||||||
) -> GeneratePageResponseDTO:
|
) -> GeneratePageResponseDTO:
|
||||||
"""Endpoint métier : contexte LoreMind → valeurs structurées par champ.
|
"""Endpoint métier : contexte LoreMind → valeurs structurées par champ.
|
||||||
|
|
||||||
@@ -76,7 +78,7 @@ async def generate_page(
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = await use_case.execute(context)
|
result = await use_case.execute(context, language=language)
|
||||||
except LLMProviderError as exc:
|
except LLMProviderError as exc:
|
||||||
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||||
|
|
||||||
@@ -101,18 +103,25 @@ class SummarizeTitleResponseDTO(BaseModel):
|
|||||||
title: str
|
title: str
|
||||||
|
|
||||||
|
|
||||||
_TITLE_SYSTEM_PROMPT = (
|
# Titre de repli (LLM injoignable / réponse vide), localisé selon la langue UI.
|
||||||
"Tu generes un titre court (4 a 7 mots max) qui resume le sujet de la "
|
_TITLE_FALLBACK = {"fr": "Nouvelle conversation", "en": "New conversation"}
|
||||||
"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')."
|
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)
|
@router.post("/summarize/conversation-title", response_model=SummarizeTitleResponseDTO)
|
||||||
async def summarize_conversation_title(
|
async def summarize_conversation_title(
|
||||||
body: SummarizeTitleRequestDTO,
|
body: SummarizeTitleRequestDTO,
|
||||||
llm: Annotated[LLMProvider, Depends(get_llm_provider)],
|
llm: Annotated[LLMProvider, Depends(get_llm_provider)],
|
||||||
|
language: Annotated[str, Depends(get_user_language)],
|
||||||
) -> SummarizeTitleResponseDTO:
|
) -> SummarizeTitleResponseDTO:
|
||||||
"""Genere un titre court a partir des premiers echanges de la conversation.
|
"""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")
|
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])
|
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:
|
try:
|
||||||
raw = await llm.generate(prompt)
|
raw = await llm.generate(prompt)
|
||||||
except LLMProviderError as exc:
|
except LLMProviderError as exc:
|
||||||
@@ -133,5 +142,5 @@ async def summarize_conversation_title(
|
|||||||
if len(title) > 80:
|
if len(title) > 80:
|
||||||
title = title[:80].rstrip()
|
title = title[:80].rstrip()
|
||||||
if not title:
|
if not title:
|
||||||
title = "Nouvelle conversation"
|
title = _TITLE_FALLBACK.get(language, _TITLE_FALLBACK["fr"])
|
||||||
return SummarizeTitleResponseDTO(title=title)
|
return SummarizeTitleResponseDTO(title=title)
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from app.api.deps import (
|
|||||||
from app.application.adapt_campaign import AdaptCampaignUseCase
|
from app.application.adapt_campaign import AdaptCampaignUseCase
|
||||||
from app.application.import_campaign import ImportCampaignUseCase
|
from app.application.import_campaign import ImportCampaignUseCase
|
||||||
from app.application.import_rules import ImportRulesUseCase
|
from app.application.import_rules import ImportRulesUseCase
|
||||||
|
from app.core.language import get_user_language
|
||||||
from app.domain.models import ChatMessage
|
from app.domain.models import ChatMessage
|
||||||
from app.domain.ports import LLMProviderError, PdfExtractionError
|
from app.domain.ports import LLMProviderError, PdfExtractionError
|
||||||
|
|
||||||
@@ -40,6 +41,7 @@ class RulesImportResponseDTO(BaseModel):
|
|||||||
@router.post("/import/rules", response_model=RulesImportResponseDTO)
|
@router.post("/import/rules", response_model=RulesImportResponseDTO)
|
||||||
async def import_rules(
|
async def import_rules(
|
||||||
use_case: Annotated[ImportRulesUseCase, Depends(get_import_rules_use_case)],
|
use_case: Annotated[ImportRulesUseCase, Depends(get_import_rules_use_case)],
|
||||||
|
language: Annotated[str, Depends(get_user_language)],
|
||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...),
|
||||||
) -> RulesImportResponseDTO:
|
) -> RulesImportResponseDTO:
|
||||||
"""Import d'un PDF de règles → sections markdown structurées (proposition).
|
"""Import d'un PDF de règles → sections markdown structurées (proposition).
|
||||||
@@ -58,7 +60,7 @@ async def import_rules(
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = await use_case.execute(content)
|
result = await use_case.execute(content, language=language)
|
||||||
except PdfExtractionError as exc:
|
except PdfExtractionError as exc:
|
||||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
except LLMProviderError as exc:
|
except LLMProviderError as exc:
|
||||||
@@ -74,6 +76,7 @@ async def import_rules(
|
|||||||
@router.post("/import/rules/stream")
|
@router.post("/import/rules/stream")
|
||||||
async def import_rules_stream(
|
async def import_rules_stream(
|
||||||
use_case: Annotated[ImportRulesUseCase, Depends(get_import_rules_use_case)],
|
use_case: Annotated[ImportRulesUseCase, Depends(get_import_rules_use_case)],
|
||||||
|
language: Annotated[str, Depends(get_user_language)],
|
||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...),
|
||||||
) -> StreamingResponse:
|
) -> StreamingResponse:
|
||||||
"""Import streamé : émet l'avancement (SSE) puis le résultat final.
|
"""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})
|
yield sse_event("error", {"message": upload_error})
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
async for ev in use_case.stream(content):
|
async for ev in use_case.stream(content, language=language):
|
||||||
event_type = ev.pop("type")
|
event_type = ev.pop("type")
|
||||||
yield sse_event(event_type, ev)
|
yield sse_event(event_type, ev)
|
||||||
except PdfExtractionError as exc:
|
except PdfExtractionError as exc:
|
||||||
@@ -146,6 +149,7 @@ async def import_campaign_stream(
|
|||||||
@router.post("/adapt/campaign/stream")
|
@router.post("/adapt/campaign/stream")
|
||||||
async def adapt_campaign_stream(
|
async def adapt_campaign_stream(
|
||||||
use_case: Annotated[AdaptCampaignUseCase, Depends(get_adapt_campaign_use_case)],
|
use_case: Annotated[AdaptCampaignUseCase, Depends(get_adapt_campaign_use_case)],
|
||||||
|
language: Annotated[str, Depends(get_user_language)],
|
||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...),
|
||||||
brief: str = Form(""),
|
brief: str = Form(""),
|
||||||
messages: str = Form("[]"),
|
messages: str = Form("[]"),
|
||||||
@@ -173,7 +177,7 @@ async def adapt_campaign_stream(
|
|||||||
yield sse_event("error", {"message": upload_error})
|
yield sse_event("error", {"message": upload_error})
|
||||||
return
|
return
|
||||||
try:
|
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("token", {"token": token})
|
||||||
yield sse_event("done", {})
|
yield sse_event("done", {})
|
||||||
except PdfExtractionError as exc:
|
except PdfExtractionError as exc:
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from app.application.notebook_chat import NotebookChatUseCase
|
|||||||
from app.application.notebook_deep import NotebookDeepUseCase
|
from app.application.notebook_deep import NotebookDeepUseCase
|
||||||
from app.application.notebook_rag import NotebookRagUseCase
|
from app.application.notebook_rag import NotebookRagUseCase
|
||||||
from app.core.config import Settings, get_settings
|
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.models import ChatMessage
|
||||||
from app.domain.ports import LLMProviderError, PdfExtractionError
|
from app.domain.ports import LLMProviderError, PdfExtractionError
|
||||||
from app.infrastructure import vector_store
|
from app.infrastructure import vector_store
|
||||||
@@ -77,6 +78,7 @@ async def chat_notebook_stream(
|
|||||||
body: NotebookChatRequestDTO,
|
body: NotebookChatRequestDTO,
|
||||||
use_case: Annotated[NotebookChatUseCase, Depends(get_notebook_chat_use_case)],
|
use_case: Annotated[NotebookChatUseCase, Depends(get_notebook_chat_use_case)],
|
||||||
settings: Annotated[Settings, Depends(get_settings)],
|
settings: Annotated[Settings, Depends(get_settings)],
|
||||||
|
language: Annotated[str, Depends(get_user_language)],
|
||||||
) -> StreamingResponse:
|
) -> StreamingResponse:
|
||||||
"""Chat ANCRÉ sur les sources (RAG) : récupère les passages pertinents puis
|
"""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}."""
|
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]:
|
async def event_stream() -> AsyncIterator[str]:
|
||||||
try:
|
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["type"] == "token":
|
||||||
if ev.get("token"):
|
if ev.get("token"):
|
||||||
yield sse_event("token", {"token": ev["token"]})
|
yield sse_event("token", {"token": ev["token"]})
|
||||||
@@ -107,6 +109,7 @@ async def chat_notebook_stream(
|
|||||||
async def chat_notebook_deep_stream(
|
async def chat_notebook_deep_stream(
|
||||||
body: NotebookChatRequestDTO,
|
body: NotebookChatRequestDTO,
|
||||||
use_case: Annotated[NotebookDeepUseCase, Depends(get_notebook_deep_use_case)],
|
use_case: Annotated[NotebookDeepUseCase, Depends(get_notebook_deep_use_case)],
|
||||||
|
language: Annotated[str, Depends(get_user_language)],
|
||||||
) -> StreamingResponse:
|
) -> StreamingResponse:
|
||||||
"""Analyse APPROFONDIE (map-reduce sur tout le document). Évènements SSE :
|
"""Analyse APPROFONDIE (map-reduce sur tout le document). Évènements SSE :
|
||||||
`progress` {current,total} pendant la lecture, puis `token` {token}, puis `done`."""
|
`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."})
|
yield sse_event("error", {"message": "Question vide."})
|
||||||
return
|
return
|
||||||
try:
|
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")
|
ev_type = ev.pop("type")
|
||||||
yield sse_event(ev_type, ev)
|
yield sse_event(ev_type, ev)
|
||||||
except (LLMProviderError, EmbeddingError) as exc:
|
except (LLMProviderError, EmbeddingError) as exc:
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from pydantic import BaseModel, Field
|
|||||||
from app.api.deps import get_llm_provider
|
from app.api.deps import get_llm_provider
|
||||||
from app.application.llm_json import load_json_object
|
from app.application.llm_json import load_json_object
|
||||||
from app.application.llm_retry import generate_with_retry
|
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
|
from app.domain.ports import LLMProvider, LLMProviderError
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -51,6 +52,7 @@ class GenerateTableResponseDTO(BaseModel):
|
|||||||
async def generate_random_table(
|
async def generate_random_table(
|
||||||
body: GenerateTableRequestDTO,
|
body: GenerateTableRequestDTO,
|
||||||
llm: Annotated[LLMProvider, Depends(get_llm_provider)],
|
llm: Annotated[LLMProvider, Depends(get_llm_provider)],
|
||||||
|
language: Annotated[str, Depends(get_user_language)],
|
||||||
) -> GenerateTableResponseDTO:
|
) -> GenerateTableResponseDTO:
|
||||||
"""Génère une table aléatoire (entrées par plage) couvrant la formule de dé."""
|
"""Génère une table aléatoire (entrées par plage) couvrant la formule de dé."""
|
||||||
rng = _dice_total_range(body.dice_formula)
|
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}, "
|
f"- Les plages (min_roll..max_roll) doivent COUVRIR EXACTEMENT {lo}..{hi}, "
|
||||||
"sans trou ni chevauchement, dans l'ordre croissant.\n"
|
"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"
|
"- 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."
|
"Renvoie maintenant le JSON."
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
@@ -124,6 +126,7 @@ class ImproviseRollResponseDTO(BaseModel):
|
|||||||
async def improvise_table_roll(
|
async def improvise_table_roll(
|
||||||
body: ImproviseRollRequestDTO,
|
body: ImproviseRollRequestDTO,
|
||||||
llm: Annotated[LLMProvider, Depends(get_llm_provider)],
|
llm: Annotated[LLMProvider, Depends(get_llm_provider)],
|
||||||
|
language: Annotated[str, Depends(get_user_language)],
|
||||||
) -> ImproviseRollResponseDTO:
|
) -> ImproviseRollResponseDTO:
|
||||||
"""Brode un court récit (2-3 phrases) sur un résultat tiré, pour lancer la scène."""
|
"""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 ""
|
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"« {body.table_name.strip()} » et ont obtenu : « {body.result_label.strip()} »{detail}."
|
||||||
f"{context_block}\n\n"
|
f"{context_block}\n\n"
|
||||||
"Décris en 2-3 phrases vivantes et immédiates ce qui se passe, pour lancer la scène. "
|
"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:
|
try:
|
||||||
raw = await llm.generate(prompt, temperature=0.8)
|
raw = await llm.generate(prompt, temperature=0.8)
|
||||||
@@ -167,6 +170,7 @@ class GenerateCatalogResponseDTO(BaseModel):
|
|||||||
async def generate_item_catalog(
|
async def generate_item_catalog(
|
||||||
body: GenerateCatalogRequestDTO,
|
body: GenerateCatalogRequestDTO,
|
||||||
llm: Annotated[LLMProvider, Depends(get_llm_provider)],
|
llm: Annotated[LLMProvider, Depends(get_llm_provider)],
|
||||||
|
language: Annotated[str, Depends(get_user_language)],
|
||||||
) -> GenerateCatalogResponseDTO:
|
) -> GenerateCatalogResponseDTO:
|
||||||
"""Génère un catalogue d'objets (boutique, butin…) — nom, prix, catégorie, description."""
|
"""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 ""
|
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'
|
'[{"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"
|
"- 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…) ; "
|
"- '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."
|
"Renvoie maintenant le JSON."
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from __future__ import annotations
|
|||||||
import logging
|
import logging
|
||||||
from typing import AsyncIterator
|
from typing import AsyncIterator
|
||||||
|
|
||||||
|
from app.core.language import DEFAULT as _DEFAULT_LANG, language_name
|
||||||
from app.domain.models import ChatMessage
|
from app.domain.models import ChatMessage
|
||||||
from app.domain.ports import LLMChatProvider, PdfExtractionError, PdfTextExtractor
|
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."
|
"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"
|
"en markdown structuré (titres ##, listes). Couvre notamment :\n"
|
||||||
"- **Où l'insérer** : à quel(s) arc(s)/chapitre(s) EXISTANT(s) rattacher ce "
|
"- **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"
|
"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,
|
pdf_bytes: bytes,
|
||||||
brief: str,
|
brief: str,
|
||||||
messages: list[ChatMessage],
|
messages: list[ChatMessage],
|
||||||
|
language: str = _DEFAULT_LANG,
|
||||||
) -> AsyncIterator[str]:
|
) -> AsyncIterator[str]:
|
||||||
"""Conversationnel : le PDF + la campagne sont le CONTEXTE (system prompt),
|
"""Conversationnel : le PDF + la campagne sont le CONTEXTE (system prompt),
|
||||||
`messages` est l'échange (demande initiale, puis feedbacks de l'utilisateur)."""
|
`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"
|
f"{brief.strip() or '(campagne encore vide)'}\n\n"
|
||||||
"--- CONTENU DU PDF À ADAPTER ---\n"
|
"--- CONTENU DU PDF À ADAPTER ---\n"
|
||||||
f"{pdf_text}{trunc_note}\n\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 "
|
"Tu es en CONVERSATION : à chaque message de l'utilisateur, ajuste, corrige "
|
||||||
"ou propose des alternatives en gardant tout ce contexte à l'esprit."
|
"ou propose des alternatives en gardant tout ce contexte à l'esprit."
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ from app.domain.models import (
|
|||||||
QuestSummary,
|
QuestSummary,
|
||||||
SessionContext,
|
SessionContext,
|
||||||
)
|
)
|
||||||
|
from app.core.language import DEFAULT as _DEFAULT_LANG, language_name
|
||||||
from app.domain.ports import LLMChatProvider
|
from app.domain.ports import LLMChatProvider
|
||||||
|
|
||||||
|
|
||||||
@@ -40,11 +41,13 @@ from app.domain.ports import LLMChatProvider
|
|||||||
_DEFAULT_TEMPERATURE = 0.7
|
_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.
|
Tu dialogues avec le MJ pour l'aider à enrichir son univers et ses campagnes.
|
||||||
|
|
||||||
Règles de ton :
|
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.
|
- Sois concis : listes à puces courtes plutôt que longs paragraphes.
|
||||||
- Propose des idées qui s'intègrent dans le contexte existant ci-dessous.
|
- 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,
|
narrative_entity: NarrativeEntityContext | None = None,
|
||||||
game_system_context: GameSystemContext | None = None,
|
game_system_context: GameSystemContext | None = None,
|
||||||
session_context: SessionContext | None = None,
|
session_context: SessionContext | None = None,
|
||||||
|
language: str = _DEFAULT_LANG,
|
||||||
) -> AsyncIterator[str]:
|
) -> AsyncIterator[str]:
|
||||||
"""Streame les tokens de la réponse assistant pour le dernier message user.
|
"""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
|
Les contextes sont tous optionnels, mais au moins l'un des deux
|
||||||
"niveaux haut" (lore_context ou campaign_context) doit être fourni
|
"niveaux haut" (lore_context ou campaign_context) doit être fourni
|
||||||
pour que le prompt ait du sens. Le controller (main.py) applique
|
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(
|
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(
|
async for token in self._llm.stream_chat(
|
||||||
messages,
|
messages,
|
||||||
@@ -97,12 +102,14 @@ class ChatUseCase:
|
|||||||
narrative_entity: NarrativeEntityContext | None = None,
|
narrative_entity: NarrativeEntityContext | None = None,
|
||||||
game_system_context: GameSystemContext | None = None,
|
game_system_context: GameSystemContext | None = None,
|
||||||
session_context: SessionContext | None = None,
|
session_context: SessionContext | None = None,
|
||||||
|
language: str = _DEFAULT_LANG,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Version publique — utilisée par le controller HTTP pour compter
|
"""Version publique — utilisée par le controller HTTP pour compter
|
||||||
les tokens du system prompt avant de streamer (jauge de contexte).
|
les tokens du system prompt avant de streamer (jauge de contexte).
|
||||||
"""
|
"""
|
||||||
return self._build_system_prompt(
|
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 --------------------------------------
|
# --- Construction du system prompt --------------------------------------
|
||||||
@@ -115,8 +122,9 @@ class ChatUseCase:
|
|||||||
narrative: NarrativeEntityContext | None,
|
narrative: NarrativeEntityContext | None,
|
||||||
game_system: GameSystemContext | None = None,
|
game_system: GameSystemContext | None = None,
|
||||||
session: SessionContext | None = None,
|
session: SessionContext | None = None,
|
||||||
|
language: str = _DEFAULT_LANG,
|
||||||
) -> str:
|
) -> str:
|
||||||
sections = [_BASE_SYSTEM]
|
sections = [_base_system(language)]
|
||||||
if lore is not None:
|
if lore is not None:
|
||||||
sections.append(self._format_lore(lore))
|
sections.append(self._format_lore(lore))
|
||||||
if campaign is not None:
|
if campaign is not None:
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ permet de tester ce use case avec un FakeLLMProvider, sans Ollama qui tourne.
|
|||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
|
|
||||||
|
from app.core.language import DEFAULT as _DEFAULT_LANG, language_name
|
||||||
from app.domain.models import PageGenerationContext, PageGenerationResult
|
from app.domain.models import PageGenerationContext, PageGenerationResult
|
||||||
from app.domain.ports import LLMProvider, LLMProviderError
|
from app.domain.ports import LLMProvider, LLMProviderError
|
||||||
|
|
||||||
@@ -18,13 +19,15 @@ from app.domain.ports import LLMProvider, LLMProviderError
|
|||||||
_DEFAULT_TEMPERATURE = 0.4
|
_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.
|
Tu vas générer le contenu d'une page appartenant à un univers fictionnel.
|
||||||
|
|
||||||
Règles impératives de ta réponse :
|
Règles impératives de ta réponse :
|
||||||
- Tu réponds UNIQUEMENT par un objet JSON valide.
|
- Tu réponds UNIQUEMENT par un objet JSON valide.
|
||||||
- Les clés du JSON correspondent EXACTEMENT aux noms de champs demandés.
|
- 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.
|
- Aucun markdown, aucune explication, aucun commentaire autour du JSON.
|
||||||
|
|
||||||
Règles de cohérence (IMPORTANT) :
|
Règles de cohérence (IMPORTANT) :
|
||||||
@@ -42,8 +45,9 @@ class GeneratePageUseCase:
|
|||||||
async def execute(
|
async def execute(
|
||||||
self,
|
self,
|
||||||
context: PageGenerationContext,
|
context: PageGenerationContext,
|
||||||
|
language: str = _DEFAULT_LANG,
|
||||||
) -> PageGenerationResult:
|
) -> PageGenerationResult:
|
||||||
prompt = self._build_prompt(context)
|
prompt = self._build_prompt(context, language)
|
||||||
raw = await self._llm.generate(
|
raw = await self._llm.generate(
|
||||||
prompt,
|
prompt,
|
||||||
output_format="json",
|
output_format="json",
|
||||||
@@ -53,7 +57,7 @@ class GeneratePageUseCase:
|
|||||||
return PageGenerationResult(values=values)
|
return PageGenerationResult(values=values)
|
||||||
|
|
||||||
@staticmethod
|
@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)
|
fields_block = "\n".join(f'- "{field}"' for field in context.template_fields)
|
||||||
lore_desc_line = (
|
lore_desc_line = (
|
||||||
f"\nDescription de l'univers : {context.lore_description}"
|
f"\nDescription de l'univers : {context.lore_description}"
|
||||||
@@ -62,7 +66,7 @@ class GeneratePageUseCase:
|
|||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
f"{_SYSTEM_INSTRUCTIONS}\n\n"
|
f"{_system_instructions(language)}\n\n"
|
||||||
f"Univers : {context.lore_name}"
|
f"Univers : {context.lore_name}"
|
||||||
f"{lore_desc_line}\n"
|
f"{lore_desc_line}\n"
|
||||||
f"Catégorie (dossier) : {context.folder_name}\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_json import load_json_object, looks_like_truncated_json
|
||||||
from app.application.llm_retry import generate_with_retry
|
from app.application.llm_retry import generate_with_retry
|
||||||
from app.application.streaming import with_heartbeat
|
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
|
# 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
|
# 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.
|
- 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 :
|
- Utilise EN PRIORITÉ ces titres canoniques quand le contenu y correspond :
|
||||||
{canonical}
|
{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
|
- 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.
|
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.
|
- 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.
|
par paragraphe : un extrait contient typiquement 1 à 6 sections.
|
||||||
- Titres : EN PRIORITÉ parmi :
|
- Titres : EN PRIORITÉ parmi :
|
||||||
{canonical}
|
{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,
|
- Pages de garde, sommaires, crédits : n'en fais pas des sections. Si l'extrait n'est que ça,
|
||||||
renvoie {{"sections": []}}."""
|
renvoie {{"sections": []}}."""
|
||||||
|
|
||||||
@@ -293,7 +294,7 @@ class ImportRulesUseCase:
|
|||||||
self._chunk_target_tokens = chunk_target_tokens
|
self._chunk_target_tokens = chunk_target_tokens
|
||||||
self._segment_only = segment_only
|
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."""
|
"""Variante non-streamée : traite tout puis renvoie le résultat complet."""
|
||||||
doc = self._extractor.extract(pdf_bytes)
|
doc = self._extractor.extract(pdf_bytes)
|
||||||
chunks = chunk_text(doc.full_text, self._chunk_target_tokens)
|
chunks = chunk_text(doc.full_text, self._chunk_target_tokens)
|
||||||
@@ -303,14 +304,14 @@ class ImportRulesUseCase:
|
|||||||
)
|
)
|
||||||
merger = _SectionMerger()
|
merger = _SectionMerger()
|
||||||
for i, chunk in enumerate(chunks):
|
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(
|
return RulesImportResult(
|
||||||
sections=merger.result(),
|
sections=merger.result(),
|
||||||
page_count=doc.page_count,
|
page_count=doc.page_count,
|
||||||
ocr_page_count=doc.ocr_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.
|
"""Variante streamée : yield des évènements d'avancement au fil de l'eau.
|
||||||
|
|
||||||
Évènements (dicts) : {"type": "extracting"}, puis
|
Évènements (dicts) : {"type": "extracting"}, puis
|
||||||
@@ -353,7 +354,7 @@ class ImportRulesUseCase:
|
|||||||
try:
|
try:
|
||||||
sections: dict[str, str] | None = None
|
sections: dict[str, str] | None = None
|
||||||
async for kind, payload in with_heartbeat(
|
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,
|
status_queue=status_queue,
|
||||||
):
|
):
|
||||||
if kind == "heartbeat":
|
if kind == "heartbeat":
|
||||||
@@ -408,11 +409,14 @@ class ImportRulesUseCase:
|
|||||||
|
|
||||||
# --- MAP : un morceau → sections -----------------------------------------
|
# --- MAP : un morceau → sections -----------------------------------------
|
||||||
|
|
||||||
async def _map_chunk(self, chunk: str, *, index: int, total: int) -> dict[str, str]:
|
async def _map_chunk(self, chunk: str, *, index: int, total: int,
|
||||||
return await self._extract_sections(chunk, index=index, total=total, depth=0)
|
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(
|
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]:
|
) -> dict[str, str]:
|
||||||
"""Extrait les sections d'un texte. Si la SORTIE est tronquée, retraite le
|
"""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 —
|
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
|
schema = _ANCHORS_SCHEMA if self._segment_only else _SECTIONS_SCHEMA
|
||||||
prompt = (
|
prompt = (
|
||||||
system.format(
|
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"
|
+ f"\n\n--- EXTRAIT {index + 1}/{total} ---\n{text}\n\n"
|
||||||
"Renvoie maintenant le JSON des sections."
|
"Renvoie maintenant le JSON des sections."
|
||||||
@@ -444,8 +449,8 @@ class ImportRulesUseCase:
|
|||||||
notify_status(
|
notify_status(
|
||||||
f"Le modèle est trop lent sur le morceau {index + 1} : "
|
f"Le modèle est trop lent sur le morceau {index + 1} : "
|
||||||
"re-découpage en 2 moitiés plus digestes…")
|
"re-découpage en 2 moitiés plus digestes…")
|
||||||
a = await self._extract_sections(left, 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)
|
b = await self._extract_sections(right, index=index, total=total, depth=depth + 1, language=language)
|
||||||
return _combine_sections(a, b)
|
return _combine_sections(a, b)
|
||||||
if self._segment_only:
|
if self._segment_only:
|
||||||
sections, truncated = self._parse_anchors(raw, text, index=index)
|
sections, truncated = self._parse_anchors(raw, text, index=index)
|
||||||
@@ -461,8 +466,8 @@ class ImportRulesUseCase:
|
|||||||
notify_status(
|
notify_status(
|
||||||
f"Réponse du modèle coupée sur le morceau {index + 1} : "
|
f"Réponse du modèle coupée sur le morceau {index + 1} : "
|
||||||
"re-découpage en 2 moitiés plus digestes…")
|
"re-découpage en 2 moitiés plus digestes…")
|
||||||
a = await self._extract_sections(left, 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)
|
b = await self._extract_sections(right, index=index, total=total, depth=depth + 1, language=language)
|
||||||
return _combine_sections(a, b)
|
return _combine_sections(a, b)
|
||||||
if truncated:
|
if truncated:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from typing import AsyncIterator
|
|||||||
from app.application.notebook_rag import NotebookRagUseCase
|
from app.application.notebook_rag import NotebookRagUseCase
|
||||||
from app.application.query_rewrite import standalone_question
|
from app.application.query_rewrite import standalone_question
|
||||||
from app.application.rerank import pool_size, rerank
|
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.models import ChatMessage
|
||||||
from app.domain.ports import LLMChatProvider
|
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":"..."}}]}}
|
{{"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:
|
class NotebookChatUseCase:
|
||||||
@@ -109,6 +110,7 @@ class NotebookChatUseCase:
|
|||||||
messages: list[ChatMessage],
|
messages: list[ChatMessage],
|
||||||
context: str = "",
|
context: str = "",
|
||||||
top_k: int = 6,
|
top_k: int = 6,
|
||||||
|
language: str = _DEFAULT_LANG,
|
||||||
) -> AsyncIterator[dict]:
|
) -> AsyncIterator[dict]:
|
||||||
"""Yield des évènements : {type:'sources', sources:[…]} (une fois, avant la
|
"""Yield des évènements : {type:'sources', sources:[…]} (une fois, avant la
|
||||||
réponse — transparence sur les passages utilisés), puis {type:'token', token}."""
|
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"
|
if context.strip() else "--- TA CAMPAGNE ---\n(aucune donnée de campagne)\n--- FIN CAMPAGNE ---\n\n"
|
||||||
)
|
)
|
||||||
system_prompt = _SYSTEM_PROMPT.format(
|
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):
|
async for token in self._llm.stream_chat(messages, system_prompt=system_prompt):
|
||||||
yield {"type": "token", "token": token}
|
yield {"type": "token", "token": token}
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import tiktoken
|
|||||||
|
|
||||||
from app.application.llm_retry import generate_with_retry
|
from app.application.llm_retry import generate_with_retry
|
||||||
from app.application.query_rewrite import standalone_question
|
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.models import ChatMessage
|
||||||
from app.domain.ports import LLMChatProvider, LLMProvider, LLMProviderError
|
from app.domain.ports import LLMChatProvider, LLMProvider, LLMProviderError
|
||||||
from app.infrastructure import vector_store
|
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}
|
{notes_block}
|
||||||
--- FIN DES NOTES ---
|
--- FIN DES NOTES ---
|
||||||
|
|
||||||
Réponds en français."""
|
Réponds en {language_name}."""
|
||||||
|
|
||||||
|
|
||||||
class NotebookDeepUseCase:
|
class NotebookDeepUseCase:
|
||||||
@@ -109,6 +110,7 @@ class NotebookDeepUseCase:
|
|||||||
messages: list[ChatMessage],
|
messages: list[ChatMessage],
|
||||||
context: str = "",
|
context: str = "",
|
||||||
history_limit: int = 8,
|
history_limit: int = 8,
|
||||||
|
language: str = _DEFAULT_LANG,
|
||||||
) -> AsyncIterator[dict]:
|
) -> AsyncIterator[dict]:
|
||||||
"""Yield des évènements : {type:'progress',current,total}, {type:'token',token},
|
"""Yield des évènements : {type:'progress',current,total}, {type:'token',token},
|
||||||
{type:'done'}. (Les erreurs LLM des lots sont tolérées : lot ignoré.)
|
{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"
|
f"--- TA CAMPAGNE (structure, PNJ, univers) ---\n{context.strip()}\n--- FIN CAMPAGNE ---\n\n"
|
||||||
if context.strip() else ""
|
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
|
# Historique récent pour la cohérence des relances ; on garantit que le
|
||||||
# dernier message est bien la question courante.
|
# dernier message est bien la question courante.
|
||||||
reduce_messages = messages[-history_limit:] if messages else [ChatMessage(role="user", content=question)]
|
reduce_messages = messages[-history_limit:] if messages else [ChatMessage(role="user", content=question)]
|
||||||
|
|||||||
61
brain/app/core/language.py
Normal file
61
brain/app/core/language.py
Normal 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)
|
||||||
@@ -10,6 +10,9 @@ import java.util.List;
|
|||||||
*/
|
*/
|
||||||
public interface ConversationTitleGenerator {
|
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);
|
String generate(List<ConversationMessage> firstMessages);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import com.loremind.domain.generationcontext.ChatRequest;
|
|||||||
import com.loremind.domain.generationcontext.ChatUsage;
|
import com.loremind.domain.generationcontext.ChatUsage;
|
||||||
import com.loremind.domain.generationcontext.ports.AiChatProvider;
|
import com.loremind.domain.generationcontext.ports.AiChatProvider;
|
||||||
import com.loremind.domain.generationcontext.ports.AiProviderException;
|
import com.loremind.domain.generationcontext.ports.AiProviderException;
|
||||||
|
import com.loremind.infrastructure.web.config.UserLanguageHolder;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.core.ParameterizedTypeReference;
|
import org.springframework.core.ParameterizedTypeReference;
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
@@ -64,6 +65,7 @@ public class BrainAiChatClient implements AiChatProvider {
|
|||||||
|
|
||||||
Flux<ServerSentEvent<String>> flux = webClient.post()
|
Flux<ServerSentEvent<String>> flux = webClient.post()
|
||||||
.uri(CHAT_STREAM_PATH)
|
.uri(CHAT_STREAM_PATH)
|
||||||
|
.header(UserLanguageHolder.HEADER, UserLanguageHolder.get())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.accept(MediaType.TEXT_EVENT_STREAM)
|
.accept(MediaType.TEXT_EVENT_STREAM)
|
||||||
.bodyValue(payload)
|
.bodyValue(payload)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package com.loremind.infrastructure.ai;
|
|||||||
import com.fasterxml.jackson.databind.JsonNode;
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import com.loremind.domain.campaigncontext.ports.CampaignPdfAdvisor;
|
import com.loremind.domain.campaigncontext.ports.CampaignPdfAdvisor;
|
||||||
|
import com.loremind.infrastructure.web.config.UserLanguageHolder;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.core.ParameterizedTypeReference;
|
import org.springframework.core.ParameterizedTypeReference;
|
||||||
import org.springframework.core.io.ByteArrayResource;
|
import org.springframework.core.io.ByteArrayResource;
|
||||||
@@ -65,6 +66,7 @@ public class BrainCampaignAdaptClient implements CampaignPdfAdvisor {
|
|||||||
|
|
||||||
Flux<ServerSentEvent<String>> flux = webClient.post()
|
Flux<ServerSentEvent<String>> flux = webClient.post()
|
||||||
.uri(ADAPT_PATH)
|
.uri(ADAPT_PATH)
|
||||||
|
.header(UserLanguageHolder.HEADER, UserLanguageHolder.get())
|
||||||
.contentType(MediaType.MULTIPART_FORM_DATA)
|
.contentType(MediaType.MULTIPART_FORM_DATA)
|
||||||
.accept(MediaType.TEXT_EVENT_STREAM)
|
.accept(MediaType.TEXT_EVENT_STREAM)
|
||||||
.body(BodyInserters.fromMultipartData(parts.build()))
|
.body(BodyInserters.fromMultipartData(parts.build()))
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import com.loremind.domain.campaigncontext.CampaignImportProposal.RoomProposal;
|
|||||||
import com.loremind.domain.campaigncontext.CampaignImportProposal.SceneProposal;
|
import com.loremind.domain.campaigncontext.CampaignImportProposal.SceneProposal;
|
||||||
import com.loremind.domain.campaigncontext.ports.CampaignImportException;
|
import com.loremind.domain.campaigncontext.ports.CampaignImportException;
|
||||||
import com.loremind.domain.campaigncontext.ports.CampaignPdfImporter;
|
import com.loremind.domain.campaigncontext.ports.CampaignPdfImporter;
|
||||||
|
import com.loremind.infrastructure.web.config.UserLanguageHolder;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.core.ParameterizedTypeReference;
|
import org.springframework.core.ParameterizedTypeReference;
|
||||||
import org.springframework.core.io.ByteArrayResource;
|
import org.springframework.core.io.ByteArrayResource;
|
||||||
@@ -71,6 +72,7 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
|
|||||||
|
|
||||||
Flux<ServerSentEvent<String>> flux = webClient.post()
|
Flux<ServerSentEvent<String>> flux = webClient.post()
|
||||||
.uri(IMPORT_CAMPAIGN_STREAM_PATH)
|
.uri(IMPORT_CAMPAIGN_STREAM_PATH)
|
||||||
|
.header(UserLanguageHolder.HEADER, UserLanguageHolder.get())
|
||||||
.contentType(MediaType.MULTIPART_FORM_DATA)
|
.contentType(MediaType.MULTIPART_FORM_DATA)
|
||||||
.accept(MediaType.TEXT_EVENT_STREAM)
|
.accept(MediaType.TEXT_EVENT_STREAM)
|
||||||
.body(BodyInserters.fromMultipartData(parts.build()))
|
.body(BodyInserters.fromMultipartData(parts.build()))
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.loremind.infrastructure.ai;
|
|||||||
|
|
||||||
import com.loremind.domain.conversationcontext.ConversationMessage;
|
import com.loremind.domain.conversationcontext.ConversationMessage;
|
||||||
import com.loremind.domain.conversationcontext.ports.ConversationTitleGenerator;
|
import com.loremind.domain.conversationcontext.ports.ConversationTitleGenerator;
|
||||||
|
import com.loremind.infrastructure.web.config.UserLanguageHolder;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
@@ -50,6 +51,7 @@ public class BrainConversationTitleClient implements ConversationTitleGenerator
|
|||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
Map<String, Object> resp = webClient.post()
|
Map<String, Object> resp = webClient.post()
|
||||||
.uri(PATH)
|
.uri(PATH)
|
||||||
|
.header(UserLanguageHolder.HEADER, UserLanguageHolder.get())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.bodyValue(payload)
|
.bodyValue(payload)
|
||||||
.retrieve()
|
.retrieve()
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.JsonNode;
|
|||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import com.loremind.domain.campaigncontext.ports.NotebookChatStreamer;
|
import com.loremind.domain.campaigncontext.ports.NotebookChatStreamer;
|
||||||
import com.loremind.domain.campaigncontext.ports.NotebookException;
|
import com.loremind.domain.campaigncontext.ports.NotebookException;
|
||||||
|
import com.loremind.infrastructure.web.config.UserLanguageHolder;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.core.ParameterizedTypeReference;
|
import org.springframework.core.ParameterizedTypeReference;
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
@@ -66,6 +67,7 @@ public class BrainNotebookChatClient implements NotebookChatStreamer {
|
|||||||
|
|
||||||
Flux<ServerSentEvent<String>> flux = webClient.post()
|
Flux<ServerSentEvent<String>> flux = webClient.post()
|
||||||
.uri(deep ? DEEP_PATH : PATH)
|
.uri(deep ? DEEP_PATH : PATH)
|
||||||
|
.header(UserLanguageHolder.HEADER, UserLanguageHolder.get())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.accept(MediaType.TEXT_EVENT_STREAM)
|
.accept(MediaType.TEXT_EVENT_STREAM)
|
||||||
.bodyValue(payload)
|
.bodyValue(payload)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import com.loremind.domain.gamesystemcontext.RulesImportProgress;
|
|||||||
import com.loremind.domain.gamesystemcontext.RulesImportResult;
|
import com.loremind.domain.gamesystemcontext.RulesImportResult;
|
||||||
import com.loremind.domain.gamesystemcontext.ports.RulesImportException;
|
import com.loremind.domain.gamesystemcontext.ports.RulesImportException;
|
||||||
import com.loremind.domain.gamesystemcontext.ports.RulesPdfImporter;
|
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.Qualifier;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.core.ParameterizedTypeReference;
|
import org.springframework.core.ParameterizedTypeReference;
|
||||||
@@ -125,6 +126,7 @@ public class BrainRulesImportClient implements RulesPdfImporter {
|
|||||||
|
|
||||||
Flux<ServerSentEvent<String>> flux = webClient.post()
|
Flux<ServerSentEvent<String>> flux = webClient.post()
|
||||||
.uri(IMPORT_RULES_STREAM_PATH)
|
.uri(IMPORT_RULES_STREAM_PATH)
|
||||||
|
.header(UserLanguageHolder.HEADER, UserLanguageHolder.get())
|
||||||
.contentType(MediaType.MULTIPART_FORM_DATA)
|
.contentType(MediaType.MULTIPART_FORM_DATA)
|
||||||
.accept(MediaType.TEXT_EVENT_STREAM)
|
.accept(MediaType.TEXT_EVENT_STREAM)
|
||||||
.body(BodyInserters.fromMultipartData(parts.build()))
|
.body(BodyInserters.fromMultipartData(parts.build()))
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.loremind.infrastructure.ai;
|
package com.loremind.infrastructure.ai;
|
||||||
|
|
||||||
|
import com.loremind.infrastructure.web.config.UserLanguageHolder;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.boot.web.client.RestTemplateBuilder;
|
import org.springframework.boot.web.client.RestTemplateBuilder;
|
||||||
import org.springframework.boot.web.reactive.function.client.WebClientCustomizer;
|
import org.springframework.boot.web.reactive.function.client.WebClientCustomizer;
|
||||||
@@ -17,6 +18,12 @@ import java.time.Duration;
|
|||||||
* <p>
|
* <p>
|
||||||
* Sans cette entete, le Brain refuse la requete (401) — defense contre
|
* Sans cette entete, le Brain refuse la requete (401) — defense contre
|
||||||
* l'acces direct au Brain depuis un attaquant qui atteindrait son port.
|
* 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
|
@Configuration
|
||||||
public class RestTemplateConfig {
|
public class RestTemplateConfig {
|
||||||
@@ -36,6 +43,7 @@ public class RestTemplateConfig {
|
|||||||
if (internalSecret != null && !internalSecret.isBlank()) {
|
if (internalSecret != null && !internalSecret.isBlank()) {
|
||||||
request.getHeaders().set(INTERNAL_SECRET_HEADER, internalSecret);
|
request.getHeaders().set(INTERNAL_SECRET_HEADER, internalSecret);
|
||||||
}
|
}
|
||||||
|
request.getHeaders().set(UserLanguageHolder.HEADER, UserLanguageHolder.get());
|
||||||
return execution.execute(request, body);
|
return execution.execute(request, body);
|
||||||
})
|
})
|
||||||
.build();
|
.build();
|
||||||
@@ -59,6 +67,7 @@ public class RestTemplateConfig {
|
|||||||
if (internalSecret != null && !internalSecret.isBlank()) {
|
if (internalSecret != null && !internalSecret.isBlank()) {
|
||||||
request.getHeaders().set(INTERNAL_SECRET_HEADER, internalSecret);
|
request.getHeaders().set(INTERNAL_SECRET_HEADER, internalSecret);
|
||||||
}
|
}
|
||||||
|
request.getHeaders().set(UserLanguageHolder.HEADER, UserLanguageHolder.get());
|
||||||
return execution.execute(request, body);
|
return execution.execute(request, body);
|
||||||
})
|
})
|
||||||
.build();
|
.build();
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,11 +5,6 @@ COPY package*.json ./
|
|||||||
RUN npm ci --include=dev --ignore-scripts --no-audit --no-fund --no-progress
|
RUN npm ci --include=dev --ignore-scripts --no-audit --no-fund --no-progress
|
||||||
COPY . .
|
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
|
RUN npm run build -- --configuration production
|
||||||
|
|
||||||
FROM nginx:alpine
|
FROM nginx:alpine
|
||||||
|
|||||||
18
web/src/app/interceptors/language.interceptor.ts
Normal file
18
web/src/app/interceptors/language.interceptor.ts
Normal 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 } })
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Injectable, inject } from '@angular/core';
|
import { Injectable, inject } from '@angular/core';
|
||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
import { TranslateService } from '@ngx-translate/core';
|
import { TranslateService } from '@ngx-translate/core';
|
||||||
|
import { LanguageService } from './language.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Un message d'une conversation IA (vue front).
|
* Un message d'une conversation IA (vue front).
|
||||||
@@ -47,6 +48,7 @@ export type NarrativeEntityType = 'arc' | 'chapter' | 'scene' | 'character' | 'n
|
|||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class AiChatService {
|
export class AiChatService {
|
||||||
private readonly translate = inject(TranslateService);
|
private readonly translate = inject(TranslateService);
|
||||||
|
private readonly language = inject(LanguageService);
|
||||||
private readonly loreEndpoint = '/api/ai/chat/stream';
|
private readonly loreEndpoint = '/api/ai/chat/stream';
|
||||||
private readonly campaignEndpoint = '/api/ai/chat/stream-campaign';
|
private readonly campaignEndpoint = '/api/ai/chat/stream-campaign';
|
||||||
private readonly sessionEndpoint = '/api/ai/chat/stream-session';
|
private readonly sessionEndpoint = '/api/ai/chat/stream-session';
|
||||||
@@ -110,7 +112,8 @@ export class AiChatService {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'Accept': 'text/event-stream'
|
'Accept': 'text/event-stream',
|
||||||
|
'X-User-Language': this.language.current
|
||||||
},
|
},
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
signal: controller.signal
|
signal: controller.signal
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
CampaignImportProposal,
|
CampaignImportProposal,
|
||||||
CampaignImportStreamEvent
|
CampaignImportStreamEvent
|
||||||
} from './campaign-import.model';
|
} from './campaign-import.model';
|
||||||
|
import { LanguageService } from './language.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Service HTTP pour l'import d'un PDF de campagne.
|
* Service HTTP pour l'import d'un PDF de campagne.
|
||||||
@@ -17,7 +18,7 @@ import {
|
|||||||
*/
|
*/
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class CampaignImportService {
|
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> {
|
importStructureStream(campaignId: string, file: File): Observable<CampaignImportStreamEvent> {
|
||||||
return new Observable<CampaignImportStreamEvent>((subscriber) => {
|
return new Observable<CampaignImportStreamEvent>((subscriber) => {
|
||||||
@@ -27,7 +28,7 @@ export class CampaignImportService {
|
|||||||
|
|
||||||
fetch(`/api/campaigns/${campaignId}/import-structure/stream`, {
|
fetch(`/api/campaigns/${campaignId}/import-structure/stream`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Accept': 'text/event-stream' },
|
headers: { 'Accept': 'text/event-stream', 'X-User-Language': this.language.current },
|
||||||
body: form,
|
body: form,
|
||||||
signal: controller.signal
|
signal: controller.signal
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { HttpClient, HttpParams } from '@angular/common/http';
|
|||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
import { TranslateService } from '@ngx-translate/core';
|
import { TranslateService } from '@ngx-translate/core';
|
||||||
import { GameSystem, GameSystemCreate, RulesImportResponse, RulesImportStreamEvent } from './game-system.model';
|
import { GameSystem, GameSystemCreate, RulesImportResponse, RulesImportStreamEvent } from './game-system.model';
|
||||||
|
import { LanguageService } from './language.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Service HTTP pour les GameSystems (systèmes de JDR).
|
* Service HTTP pour les GameSystems (systèmes de JDR).
|
||||||
@@ -11,7 +12,7 @@ import { GameSystem, GameSystemCreate, RulesImportResponse, RulesImportStreamEve
|
|||||||
export class GameSystemService {
|
export class GameSystemService {
|
||||||
private apiUrl = '/api/game-systems';
|
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[]> {
|
getAll(): Observable<GameSystem[]> {
|
||||||
return this.http.get<GameSystem[]>(this.apiUrl);
|
return this.http.get<GameSystem[]>(this.apiUrl);
|
||||||
@@ -63,7 +64,7 @@ export class GameSystemService {
|
|||||||
|
|
||||||
fetch(`${this.apiUrl}/import-rules/stream`, {
|
fetch(`${this.apiUrl}/import-rules/stream`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Accept': 'text/event-stream' },
|
headers: { 'Accept': 'text/event-stream', 'X-User-Language': this.language.current },
|
||||||
body: form,
|
body: form,
|
||||||
signal: controller.signal
|
signal: controller.signal
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { HttpClient } from '@angular/common/http';
|
|||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
import { TranslateService } from '@ngx-translate/core';
|
import { TranslateService } from '@ngx-translate/core';
|
||||||
import { Notebook, NotebookArchive, NotebookDetail, NotebookSource, NotebookChatEvent } from './notebook.model';
|
import { Notebook, NotebookArchive, NotebookDetail, NotebookSource, NotebookChatEvent } from './notebook.model';
|
||||||
|
import { LanguageService } from './language.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Service des notebooks (atelier RAG) : CRUD, upload/indexation de sources,
|
* Service des notebooks (atelier RAG) : CRUD, upload/indexation de sources,
|
||||||
@@ -12,7 +13,7 @@ import { Notebook, NotebookArchive, NotebookDetail, NotebookSource, NotebookChat
|
|||||||
export class NotebookService {
|
export class NotebookService {
|
||||||
private readonly apiUrl = '/api/notebooks';
|
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[]> {
|
listByCampaign(campaignId: string): Observable<Notebook[]> {
|
||||||
return this.http.get<Notebook[]>(`${this.apiUrl}/campaign/${campaignId}`);
|
return this.http.get<Notebook[]>(`${this.apiUrl}/campaign/${campaignId}`);
|
||||||
@@ -73,7 +74,7 @@ export class NotebookService {
|
|||||||
try {
|
try {
|
||||||
const response = await fetch(`${this.apiUrl}/${notebookId}/chat/stream`, {
|
const response = await fetch(`${this.apiUrl}/${notebookId}/chat/stream`, {
|
||||||
method: 'POST',
|
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',
|
credentials: 'include',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
message, deep,
|
message, deep,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { provideTranslateHttpLoader } from '@ngx-translate/http-loader';
|
|||||||
import { ConfigService } from './app/services/config.service';
|
import { ConfigService } from './app/services/config.service';
|
||||||
import { LanguageService } from './app/services/language.service';
|
import { LanguageService } from './app/services/language.service';
|
||||||
import { sessionExpiredInterceptor } from './app/interceptors/session-expired.interceptor';
|
import { sessionExpiredInterceptor } from './app/interceptors/session-expired.interceptor';
|
||||||
|
import { languageInterceptor } from './app/interceptors/language.interceptor';
|
||||||
|
|
||||||
// withPreloading(PreloadAllModules) : une fois l'app initiale rendue, Angular
|
// withPreloading(PreloadAllModules) : une fois l'app initiale rendue, Angular
|
||||||
// telecharge en arriere-plan tous les chunks lazy-loades. Consequence : la
|
// 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, {
|
bootstrapApplication(AppComponent, {
|
||||||
providers: [
|
providers: [
|
||||||
provideZoneChangeDetection(),provideRouter(routes, withPreloading(PreloadAllModules)),
|
provideZoneChangeDetection(),provideRouter(routes, withPreloading(PreloadAllModules)),
|
||||||
provideHttpClient(withInterceptors([sessionExpiredInterceptor])),
|
provideHttpClient(withInterceptors([sessionExpiredInterceptor, languageInterceptor])),
|
||||||
provideTranslateService({
|
provideTranslateService({
|
||||||
loader: provideTranslateHttpLoader({
|
loader: provideTranslateHttpLoader({
|
||||||
prefix: 'assets/i18n/',
|
prefix: 'assets/i18n/',
|
||||||
|
|||||||
Reference in New Issue
Block a user