121 lines
5.3 KiB
Python
121 lines
5.3 KiB
Python
"""Use case : chat conversationnel LoreMind avec Structural Context.
|
|
|
|
Construit un system prompt riche à partir du contexte structurel du Lore
|
|
(noms des dossiers, titres des pages, templates, tags) puis délègue au port
|
|
`LLMChatProvider` pour le streaming token par token.
|
|
|
|
Ne charge PAS les valeurs des pages — l'IA doit être au courant de ce qui
|
|
existe, pas être noyée sous le contenu. Pattern "Structural Context", plus
|
|
simple que le RAG sémantique tant que le Lore reste de taille humaine.
|
|
"""
|
|
from typing import AsyncIterator
|
|
|
|
from app.domain.models import ChatMessage, LoreStructuralContext, PageContext
|
|
from app.domain.ports import LLMChatProvider
|
|
|
|
|
|
# Température moyenne : chat conversationnel créatif mais cohérent.
|
|
# Plus élevée que le one-shot (0.4) car on veut de la variété d'idées,
|
|
# mais sans partir en délire halluciné (1.0+).
|
|
_DEFAULT_TEMPERATURE = 0.7
|
|
|
|
|
|
_BASE_SYSTEM = """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.
|
|
|
|
Règles de ton :
|
|
- Réponds en français, ton chaleureux et créatif.
|
|
- Sois concis : listes à puces courtes plutôt que longs paragraphes.
|
|
- Propose des idées qui s'intègrent dans l'univers existant ci-dessous.
|
|
|
|
Règles de cohérence (IMPORTANT) :
|
|
- Tu PEUX et DOIS inventer des éléments originaux (personnages, lieux, objets, intrigues, créatures) — c'est ton rôle d'assistant créatif.
|
|
- Tu ne peux PAS faire référence à un élément du Lore du MJ comme s'il existait déjà, SAUF s'il apparaît EXACTEMENT (même orthographe) dans la section "Organisation" ci-dessous.
|
|
- Si l'utilisateur mentionne un nom que tu ne vois pas dans l'organisation, ne fais surtout pas semblant de le connaître : dis clairement "Je ne vois pas [nom] dans ton univers actuel, veux-tu qu'on le crée ?" plutôt que d'inventer des détails à son sujet.
|
|
- Évite les précisions inventées qu'on ne peut pas vérifier : dates exactes, chiffres de population, hiérarchies politiques complexes, généalogies détaillées. Préfère des formulations ouvertes que le MJ validera ("il y a longtemps", "de nombreux", "la haute noblesse")."""
|
|
|
|
|
|
class ChatUseCase:
|
|
"""Orchestre un tour de conversation avec le LLM + contexte Lore."""
|
|
|
|
def __init__(self, llm: LLMChatProvider) -> None:
|
|
self._llm = llm
|
|
|
|
async def stream(
|
|
self,
|
|
messages: list[ChatMessage],
|
|
context: LoreStructuralContext,
|
|
page_context: PageContext | None = None,
|
|
) -> AsyncIterator[str]:
|
|
"""Streame les tokens de la réponse assistant pour le dernier message user.
|
|
|
|
Si `page_context` est fourni, le system prompt gagne une section
|
|
"PAGE EN COURS" qui oriente l'IA vers cette page précise (titre,
|
|
template, champs, valeurs actuelles). Sans ce contexte, le chat
|
|
reste générique au Lore (comportement avant b8).
|
|
"""
|
|
system_prompt = self._build_system_prompt(context, page_context)
|
|
async for token in self._llm.stream_chat(
|
|
messages,
|
|
system_prompt=system_prompt,
|
|
temperature=_DEFAULT_TEMPERATURE,
|
|
):
|
|
yield token
|
|
|
|
def _build_system_prompt(
|
|
self,
|
|
ctx: LoreStructuralContext,
|
|
page_ctx: PageContext | None,
|
|
) -> str:
|
|
desc = f"\nDescription : {ctx.lore_description}" if ctx.lore_description else ""
|
|
folders_block = self._format_folders(ctx.folders)
|
|
tags_line = ", ".join(ctx.tags) if ctx.tags else "(aucun)"
|
|
|
|
prompt = (
|
|
f"{_BASE_SYSTEM}\n\n"
|
|
f"--- UNIVERS COURANT ---\n"
|
|
f"Nom : {ctx.lore_name}{desc}\n\n"
|
|
f"Organisation :\n{folders_block}\n\n"
|
|
f"Tags déjà utilisés : {tags_line}"
|
|
)
|
|
if page_ctx is not None:
|
|
prompt += "\n\n" + self._format_page_context(page_ctx)
|
|
return prompt
|
|
|
|
@staticmethod
|
|
def _format_page_context(pc: PageContext) -> str:
|
|
"""Bloc "PAGE EN COURS" — oriente l'IA vers la page précise éditée."""
|
|
if pc.template_fields:
|
|
fields_block = "\n".join(
|
|
f'- "{f}" : {pc.values.get(f) or "(vide)"}'
|
|
for f in pc.template_fields
|
|
)
|
|
else:
|
|
fields_block = "(aucun champ défini dans ce template)"
|
|
|
|
return (
|
|
f"--- PAGE EN COURS D'ÉDITION ---\n"
|
|
f"Titre : {pc.title}\n"
|
|
f"Template : {pc.template_name}\n"
|
|
f"Champs et valeurs actuelles :\n{fields_block}\n\n"
|
|
f"IMPORTANT : concentre-toi EXCLUSIVEMENT sur cette page. "
|
|
f"Si l'utilisateur te demande de proposer des idées, elles doivent "
|
|
f"concerner UNIQUEMENT les champs listés ci-dessus. Ne déborde pas "
|
|
f"vers d'autres pages ou d'autres templates du Lore, même si ça te "
|
|
f"semblerait pertinent."
|
|
)
|
|
|
|
@staticmethod
|
|
def _format_folders(folders: dict[str, list[tuple[str, str]]]) -> str:
|
|
if not folders:
|
|
return "(Lore vide pour l'instant)"
|
|
lines: list[str] = []
|
|
for folder_name, pages in folders.items():
|
|
lines.append(f"- {folder_name} (dossier)")
|
|
if not pages:
|
|
lines.append(" (vide)")
|
|
else:
|
|
for title, template in pages:
|
|
lines.append(f" - {title} [template: {template}]")
|
|
return "\n".join(lines)
|