Ajout de la partie IA
This commit is contained in:
0
brain/app/application/__init__.py
Normal file
0
brain/app/application/__init__.py
Normal file
120
brain/app/application/chat.py
Normal file
120
brain/app/application/chat.py
Normal 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)
|
||||
98
brain/app/application/generate_page.py
Normal file
98
brain/app/application/generate_page.py
Normal file
@@ -0,0 +1,98 @@
|
||||
"""Use case : génération d'une page LoreMind à partir d'un contexte métier.
|
||||
|
||||
Couche APPLICATION — au-dessus du domaine, en-dessous de l'infra web.
|
||||
Orchestre le flux : contexte → prompt → appel LLM → parsing JSON → résultat.
|
||||
|
||||
Ne dépend que des abstractions du domaine (port `LLMProvider`). C'est ce qui
|
||||
permet de tester ce use case avec un FakeLLMProvider, sans Ollama qui tourne.
|
||||
"""
|
||||
import json
|
||||
|
||||
from app.domain.models import PageGenerationContext, PageGenerationResult
|
||||
from app.domain.ports import LLMProvider, LLMProviderError
|
||||
|
||||
|
||||
# Température basse : remplissage de champs = tâche factuelle, peu créative.
|
||||
# Une valeur trop haute (par défaut Ollama = 0.8) encourage l'IA à broder
|
||||
# et à inventer des références à des PNJ/lieux/événements inexistants.
|
||||
_DEFAULT_TEMPERATURE = 0.4
|
||||
|
||||
|
||||
_SYSTEM_INSTRUCTIONS = """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.
|
||||
- Aucun markdown, aucune explication, aucun commentaire autour du JSON.
|
||||
|
||||
Règles de cohérence (IMPORTANT) :
|
||||
- Tu PEUX inventer des détails originaux pour CETTE page : apparence, traits de caractère, anecdotes, histoire personnelle.
|
||||
- Tu ne dois PAS faire référence à d'autres personnages, lieux, organisations ou événements comme s'ils existaient déjà dans l'univers, sauf si le contexte ci-dessous les mentionne explicitement.
|
||||
- Si un champ appelle une précision externe (date, nom d'un roi, ville voisine, guerre passée), reste volontairement vague : "il y a de nombreuses années", "un bourg voisin", "une époque troublée". Le MJ préfère combler lui-même les blancs plutôt que trouver des faits inventés contradictoires avec son univers."""
|
||||
|
||||
|
||||
class GeneratePageUseCase:
|
||||
"""Orchestre la génération d'une page LoreMind via un LLM."""
|
||||
|
||||
def __init__(self, llm: LLMProvider) -> None:
|
||||
self._llm = llm
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
context: PageGenerationContext,
|
||||
) -> PageGenerationResult:
|
||||
prompt = self._build_prompt(context)
|
||||
raw = await self._llm.generate(
|
||||
prompt,
|
||||
output_format="json",
|
||||
temperature=_DEFAULT_TEMPERATURE,
|
||||
)
|
||||
values = self._parse_values(raw, context.template_fields)
|
||||
return PageGenerationResult(values=values)
|
||||
|
||||
def _build_prompt(self, context: PageGenerationContext) -> str:
|
||||
fields_block = "\n".join(f'- "{field}"' for field in context.template_fields)
|
||||
lore_desc_line = (
|
||||
f"\nDescription de l'univers : {context.lore_description}"
|
||||
if context.lore_description
|
||||
else ""
|
||||
)
|
||||
|
||||
return (
|
||||
f"{_SYSTEM_INSTRUCTIONS}\n\n"
|
||||
f"Univers : {context.lore_name}"
|
||||
f"{lore_desc_line}\n"
|
||||
f"Catégorie (dossier) : {context.folder_name}\n"
|
||||
f"Gabarit : {context.template_name}\n"
|
||||
f"Titre de la page à créer : {context.page_title}\n\n"
|
||||
f"Champs à remplir (clés JSON attendues) :\n"
|
||||
f"{fields_block}\n\n"
|
||||
f"Génère maintenant le JSON."
|
||||
)
|
||||
|
||||
def _parse_values(
|
||||
self,
|
||||
raw: str,
|
||||
expected_fields: list[str],
|
||||
) -> dict[str, str]:
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise LLMProviderError(
|
||||
f"Réponse du LLM non parseable en JSON : {exc}"
|
||||
) from exc
|
||||
|
||||
if not isinstance(parsed, dict):
|
||||
raise LLMProviderError(
|
||||
f"Le LLM a renvoyé un {type(parsed).__name__}, pas un objet JSON."
|
||||
)
|
||||
|
||||
# Filtrage défensif : on ne garde que les champs demandés, cast en str,
|
||||
# jamais None. Les champs absents de la réponse deviennent des chaînes vides
|
||||
# (l'utilisateur les complètera manuellement dans page-edit).
|
||||
return {
|
||||
field: str(parsed.get(field, "")).strip()
|
||||
for field in expected_fields
|
||||
}
|
||||
Reference in New Issue
Block a user