Ajout de la partie IA

This commit is contained in:
2026-04-20 14:52:20 +02:00
parent 187b865c4a
commit bffbe1a662
50 changed files with 3236 additions and 11 deletions

View File

@@ -0,0 +1,120 @@
"""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)