Ajout de la partie IA
This commit is contained in:
0
brain/app/__init__.py
Normal file
0
brain/app/__init__.py
Normal file
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
|
||||
}
|
||||
0
brain/app/core/__init__.py
Normal file
0
brain/app/core/__init__.py
Normal file
29
brain/app/core/config.py
Normal file
29
brain/app/core/config.py
Normal file
@@ -0,0 +1,29 @@
|
||||
"""Configuration applicative centralisée (principe 12-factor : config via env).
|
||||
|
||||
Équivalent Python du `application.properties` Spring Boot, avec validation
|
||||
Pydantic : une variable manquante/invalide = crash au démarrage, pas une
|
||||
NullPointerException surprise à la 3ème requête.
|
||||
"""
|
||||
from functools import lru_cache
|
||||
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Settings chargés depuis .env ou variables d'environnement."""
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
ollama_base_url: str = "http://localhost:11434"
|
||||
llm_model: str = "gemma4:e2b"
|
||||
llm_timeout_seconds: int = 120
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
"""Singleton via cache — FastAPI l'injecte avec Depends() dans les routes."""
|
||||
return Settings()
|
||||
0
brain/app/domain/__init__.py
Normal file
0
brain/app/domain/__init__.py
Normal file
89
brain/app/domain/models.py
Normal file
89
brain/app/domain/models.py
Normal file
@@ -0,0 +1,89 @@
|
||||
"""Modèles de domaine pour le cas d'usage de génération de page LoreMind.
|
||||
|
||||
On utilise @dataclass (pas Pydantic) pour garder le domaine exempt de toute
|
||||
dépendance framework. Pydantic apparaît uniquement aux frontières : DTOs HTTP
|
||||
dans `main.py`, Settings dans `core/config.py`.
|
||||
"""
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PageGenerationContext:
|
||||
"""Contexte métier à fournir au LLM pour générer une page LoreMind.
|
||||
|
||||
Les champs correspondent aux entités du Lore Context côté Core Java :
|
||||
- lore_* : l'univers (Lore)
|
||||
- folder_name : le dossier (LoreNode) qui catégorise la page
|
||||
- template_* : le gabarit qui liste les champs à remplir
|
||||
- page_title : le titre de la page à créer
|
||||
"""
|
||||
|
||||
lore_name: str
|
||||
folder_name: str
|
||||
template_name: str
|
||||
template_fields: list[str]
|
||||
page_title: str
|
||||
lore_description: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PageGenerationResult:
|
||||
"""Résultat métier : une valeur textuelle générée par champ du template.
|
||||
|
||||
La clé du dict est le nom du champ (ex: "apparence"), la valeur est
|
||||
le contenu généré par le LLM. Cohérent avec la structure
|
||||
`Page.values: Map<String,String>` côté Core Java.
|
||||
"""
|
||||
|
||||
values: dict[str, str]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ChatMessage:
|
||||
"""Message d'une conversation — rôle + contenu textuel.
|
||||
|
||||
Rôles possibles (OpenAI/Ollama compatibles) :
|
||||
- "system" : prompt système (contexte, instructions)
|
||||
- "user" : message de l'utilisateur
|
||||
- "assistant" : réponse précédente du LLM
|
||||
"""
|
||||
|
||||
role: str
|
||||
content: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LoreStructuralContext:
|
||||
"""Carte structurelle d'un Lore pour nourrir l'IA sans tout lui envoyer.
|
||||
|
||||
Pas de contenu des pages — uniquement noms, dossiers, templates, tags.
|
||||
Suffit pour que l'IA propose des suggestions cohérentes avec l'existant.
|
||||
|
||||
Le dict `folders` est indexé par nom de dossier et mappe vers la liste
|
||||
des pages qu'il contient, chaque page étant représentée par le tuple
|
||||
(page_title, template_name).
|
||||
"""
|
||||
|
||||
lore_name: str
|
||||
lore_description: str | None
|
||||
folders: dict[str, list[tuple[str, str]]]
|
||||
tags: list[str]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PageContext:
|
||||
"""Contexte d'une page spécifique en cours d'édition.
|
||||
|
||||
Injecté dans le system prompt pour focaliser le chat sur CETTE page
|
||||
précise : son template, ses champs, ses valeurs actuelles. Permet à
|
||||
l'IA d'éviter de parler d'autres pages du Lore par mégarde.
|
||||
|
||||
Complémentaire de `LoreStructuralContext` : l'un donne la carte
|
||||
générale (toutes les pages existantes), l'autre zoome sur la page
|
||||
en cours de discussion.
|
||||
"""
|
||||
|
||||
title: str
|
||||
template_name: str
|
||||
template_fields: list[str]
|
||||
values: dict[str, str]
|
||||
86
brain/app/domain/ports.py
Normal file
86
brain/app/domain/ports.py
Normal file
@@ -0,0 +1,86 @@
|
||||
"""Ports (contrats) du domaine du Brain LoreMind.
|
||||
|
||||
Un Port est une INTERFACE abstraite exposée par le domaine vers le monde
|
||||
extérieur. Le domaine définit CE QU'IL ATTEND, pas COMMENT c'est implémenté.
|
||||
|
||||
En Python moderne on privilégie Protocol (PEP 544) sur ABC pour bénéficier
|
||||
du duck typing structurel : toute classe qui possède les bonnes méthodes
|
||||
satisfait le contrat, sans héritage explicite.
|
||||
"""
|
||||
from typing import AsyncIterator, Protocol
|
||||
|
||||
|
||||
class LLMProvider(Protocol):
|
||||
"""Port sortant — contrat pour un fournisseur de modèle de langage.
|
||||
|
||||
Toute implémentation (Ollama, OpenAI, Claude, faux-mock de test) doit
|
||||
exposer au minimum cette méthode `generate`.
|
||||
"""
|
||||
|
||||
async def generate(
|
||||
self,
|
||||
prompt: str,
|
||||
*,
|
||||
output_format: str | None = None,
|
||||
temperature: float | None = None,
|
||||
) -> str:
|
||||
"""Génère une réponse textuelle à partir d'un prompt donné.
|
||||
|
||||
Args:
|
||||
prompt: le texte envoyé au modèle.
|
||||
output_format: contrainte de format optionnelle. Exemple : "json"
|
||||
pour forcer le modèle à renvoyer du JSON valide. Les
|
||||
fournisseurs qui ne supportent pas une valeur donnée doivent
|
||||
l'ignorer silencieusement ou la traduire au mieux.
|
||||
temperature: créativité du modèle, 0.0 (déterministe/factuel) à
|
||||
1.0+ (très créatif, hallucine plus facilement). None =
|
||||
valeur par défaut de l'adapter. Recommandation LoreMind :
|
||||
~0.4 pour du remplissage factuel, ~0.7 pour du chat créatif.
|
||||
|
||||
Raises:
|
||||
LLMProviderError: si le fournisseur sous-jacent a échoué.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
class LLMChatProvider(Protocol):
|
||||
"""Port sortant — fournisseur de chat streamé (conversation multi-tours).
|
||||
|
||||
Distinct de LLMProvider par Interface Segregation Principle : le chat
|
||||
streamé est une capacité séparée (messages structurés, flux de tokens)
|
||||
qui mérite son propre contrat. Un même adapter concret (ex: Ollama)
|
||||
peut satisfaire les deux protocoles simultanément grâce au duck typing.
|
||||
"""
|
||||
|
||||
async def stream_chat(
|
||||
self,
|
||||
messages: list["ChatMessage"], # forward ref, évite import circulaire
|
||||
*,
|
||||
system_prompt: str | None = None,
|
||||
temperature: float | None = None,
|
||||
) -> AsyncIterator[str]:
|
||||
"""Streame la réponse du LLM token par token.
|
||||
|
||||
Args:
|
||||
messages: historique de la conversation (chronologique, le dernier
|
||||
message étant typiquement celui de l'utilisateur en attente
|
||||
de réponse).
|
||||
system_prompt: instructions système optionnelles (contexte global,
|
||||
règles de comportement). Prefixe la conversation si fourni.
|
||||
temperature: créativité du modèle (voir `LLMProvider.generate`).
|
||||
|
||||
Yields:
|
||||
Fragments de texte (tokens) au fur et à mesure de la génération.
|
||||
|
||||
Raises:
|
||||
LLMProviderError: si le fournisseur sous-jacent a échoué.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
class LLMProviderError(Exception):
|
||||
"""Erreur du domaine signalant qu'un LLMProvider n'a pas pu générer.
|
||||
|
||||
Définie dans le domaine (pas dans l'infra) pour que les couches
|
||||
supérieures puissent l'attraper sans connaître l'adapter concret.
|
||||
"""
|
||||
0
brain/app/infrastructure/__init__.py
Normal file
0
brain/app/infrastructure/__init__.py
Normal file
110
brain/app/infrastructure/ollama_adapter.py
Normal file
110
brain/app/infrastructure/ollama_adapter.py
Normal file
@@ -0,0 +1,110 @@
|
||||
"""Adapter Ollama — implémentation concrète des ports LLMProvider et LLMChatProvider.
|
||||
|
||||
Isole le reste de l'application des spécificités du protocole Ollama
|
||||
(URL /api/generate, /api/chat, payload, parsing). Pour swap vers OpenAI
|
||||
demain, on écrit un nouvel adapter sans toucher au reste du code.
|
||||
"""
|
||||
import json
|
||||
from typing import AsyncIterator
|
||||
|
||||
import httpx
|
||||
|
||||
from app.core.config import Settings
|
||||
from app.domain.models import ChatMessage
|
||||
from app.domain.ports import LLMProviderError
|
||||
|
||||
|
||||
class OllamaLLMProvider:
|
||||
"""Implémentation des ports LLM — appelle un serveur Ollama via HTTP.
|
||||
|
||||
Satisfait implicitement (duck typing) à la fois `LLMProvider` (endpoint
|
||||
/api/generate, appel unique) et `LLMChatProvider` (endpoint /api/chat,
|
||||
streaming token par token).
|
||||
"""
|
||||
|
||||
def __init__(self, settings: Settings) -> None:
|
||||
self._base_url = settings.ollama_base_url
|
||||
self._model = settings.llm_model
|
||||
self._timeout = settings.llm_timeout_seconds
|
||||
|
||||
async def generate(
|
||||
self,
|
||||
prompt: str,
|
||||
*,
|
||||
output_format: str | None = None,
|
||||
temperature: float | None = None,
|
||||
) -> str:
|
||||
url = f"{self._base_url}/api/generate"
|
||||
payload: dict[str, object] = {
|
||||
"model": self._model,
|
||||
"prompt": prompt,
|
||||
"stream": False,
|
||||
}
|
||||
if output_format is not None:
|
||||
payload["format"] = output_format
|
||||
if temperature is not None:
|
||||
# Ollama attend les hyperparamètres sous la clé "options".
|
||||
payload["options"] = {"temperature": temperature}
|
||||
|
||||
async with httpx.AsyncClient(timeout=self._timeout) as client:
|
||||
try:
|
||||
response = await client.post(url, json=payload)
|
||||
response.raise_for_status()
|
||||
except httpx.HTTPError as exc:
|
||||
raise LLMProviderError(
|
||||
f"Erreur lors de l'appel à Ollama : {exc}"
|
||||
) from exc
|
||||
|
||||
return response.json()["response"]
|
||||
|
||||
async def stream_chat(
|
||||
self,
|
||||
messages: list[ChatMessage],
|
||||
*,
|
||||
system_prompt: str | None = None,
|
||||
temperature: float | None = None,
|
||||
) -> AsyncIterator[str]:
|
||||
"""Streame depuis Ollama /api/chat. Parse le NDJSON ligne par ligne.
|
||||
|
||||
Ollama renvoie un JSON par ligne au fil de la génération :
|
||||
- étapes intermédiaires : `{"message": {"content": "token"}, "done": false}`
|
||||
- étape finale : `{"done": true, ...}`
|
||||
|
||||
On yield chaque token non-vide au consommateur, qui se charge du
|
||||
formatage SSE (c'est la responsabilité du controller HTTP, pas
|
||||
de l'adapter LLM).
|
||||
"""
|
||||
url = f"{self._base_url}/api/chat"
|
||||
|
||||
payload_messages: list[dict[str, str]] = []
|
||||
if system_prompt:
|
||||
payload_messages.append({"role": "system", "content": system_prompt})
|
||||
payload_messages.extend(
|
||||
{"role": m.role, "content": m.content} for m in messages
|
||||
)
|
||||
|
||||
payload: dict[str, object] = {
|
||||
"model": self._model,
|
||||
"messages": payload_messages,
|
||||
"stream": True,
|
||||
}
|
||||
if temperature is not None:
|
||||
payload["options"] = {"temperature": temperature}
|
||||
|
||||
async with httpx.AsyncClient(timeout=self._timeout) as client:
|
||||
try:
|
||||
async with client.stream("POST", url, json=payload) as response:
|
||||
response.raise_for_status()
|
||||
async for line in response.aiter_lines():
|
||||
if not line.strip():
|
||||
continue
|
||||
chunk = json.loads(line)
|
||||
if chunk.get("done"):
|
||||
break
|
||||
token = chunk.get("message", {}).get("content", "")
|
||||
if token:
|
||||
yield token
|
||||
except httpx.HTTPError as exc:
|
||||
raise LLMProviderError(
|
||||
f"Erreur lors du streaming Ollama : {exc}"
|
||||
) from exc
|
||||
226
brain/app/main.py
Normal file
226
brain/app/main.py
Normal file
@@ -0,0 +1,226 @@
|
||||
"""Point d'entrée FastAPI du Brain LoreMind.
|
||||
|
||||
Controller volontairement FIN : il valide l'entrée (DTOs Pydantic), délègue
|
||||
au domaine via injection de dépendance (ports + use cases), et transforme les
|
||||
erreurs du domaine en réponses HTTP. Aucune connaissance d'Ollama ici.
|
||||
"""
|
||||
import json
|
||||
from typing import Annotated, AsyncIterator
|
||||
|
||||
from fastapi import Depends, FastAPI, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.application.chat import ChatUseCase
|
||||
from app.application.generate_page import GeneratePageUseCase
|
||||
from app.core.config import Settings, get_settings
|
||||
from app.domain.models import ChatMessage, LoreStructuralContext, PageContext, PageGenerationContext
|
||||
from app.domain.ports import LLMProvider, LLMProviderError
|
||||
from app.infrastructure.ollama_adapter import OllamaLLMProvider
|
||||
|
||||
app = FastAPI(
|
||||
title="LoreMind Brain",
|
||||
description="Backend IA pour la génération de contenu narratif.",
|
||||
version="0.1.0",
|
||||
)
|
||||
|
||||
|
||||
# --- DTOs HTTP (frontière, c'est ici et seulement ici qu'on utilise Pydantic) ---
|
||||
|
||||
|
||||
class GenerateRequest(BaseModel):
|
||||
prompt: str
|
||||
|
||||
|
||||
class GenerateResponse(BaseModel):
|
||||
model: str
|
||||
response: str
|
||||
|
||||
|
||||
class GeneratePageRequestDTO(BaseModel):
|
||||
"""Contexte envoyé par le Core Java pour remplir une page via le LLM."""
|
||||
|
||||
lore_name: str
|
||||
folder_name: str
|
||||
template_name: str
|
||||
template_fields: list[str] = Field(min_length=1)
|
||||
page_title: str
|
||||
lore_description: str | None = None
|
||||
|
||||
|
||||
class GeneratePageResponseDTO(BaseModel):
|
||||
"""Retour : une valeur textuelle par champ du template (clé = field name)."""
|
||||
|
||||
values: dict[str, str]
|
||||
|
||||
|
||||
class ChatMessageDTO(BaseModel):
|
||||
"""Un message de la conversation. Rôles acceptés : user, assistant, system."""
|
||||
|
||||
role: str = Field(pattern="^(user|assistant|system)$")
|
||||
content: str
|
||||
|
||||
|
||||
class FolderPageDTO(BaseModel):
|
||||
"""Résumé d'une page dans un dossier (titre + nom de template)."""
|
||||
|
||||
title: str
|
||||
template_name: str
|
||||
|
||||
|
||||
class LoreContextDTO(BaseModel):
|
||||
"""Carte structurelle du Lore : on envoie des noms, pas des contenus."""
|
||||
|
||||
lore_name: str
|
||||
lore_description: str | None = None
|
||||
folders: dict[str, list[FolderPageDTO]] = Field(default_factory=dict)
|
||||
tags: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class PageContextDTO(BaseModel):
|
||||
"""Contexte d'une page spécifique pour focaliser le chat (optionnel)."""
|
||||
|
||||
title: str
|
||||
template_name: str
|
||||
template_fields: list[str] = Field(default_factory=list)
|
||||
values: dict[str, str] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class ChatStreamRequestDTO(BaseModel):
|
||||
"""Requête de chat streamé : historique + contexte Lore (+ page éditée)."""
|
||||
|
||||
messages: list[ChatMessageDTO] = Field(min_length=1)
|
||||
lore_context: LoreContextDTO
|
||||
page_context: PageContextDTO | None = None
|
||||
|
||||
|
||||
# --- Factories d'injection de dépendance ---
|
||||
|
||||
|
||||
def get_llm_provider(
|
||||
settings: Annotated[Settings, Depends(get_settings)],
|
||||
) -> LLMProvider:
|
||||
"""Factory d'adapter — point d'inversion de dépendance.
|
||||
|
||||
C'est ici (et uniquement ici) qu'on choisit QUEL adapter concret
|
||||
incarne le port. Pour swap vers un autre fournisseur, on change
|
||||
cette ligne et rien d'autre.
|
||||
"""
|
||||
return OllamaLLMProvider(settings)
|
||||
|
||||
|
||||
def get_generate_page_use_case(
|
||||
llm: Annotated[LLMProvider, Depends(get_llm_provider)],
|
||||
) -> GeneratePageUseCase:
|
||||
"""Factory du use case — injecte le port LLMProvider sans connaître l'adapter."""
|
||||
return GeneratePageUseCase(llm=llm)
|
||||
|
||||
|
||||
def get_chat_use_case(
|
||||
llm: Annotated[LLMProvider, Depends(get_llm_provider)],
|
||||
) -> ChatUseCase:
|
||||
"""Factory du use case chat.
|
||||
|
||||
L'adapter OllamaLLMProvider satisfait les deux protocoles (LLMProvider
|
||||
et LLMChatProvider) par duck typing ; on lui passe la même instance.
|
||||
"""
|
||||
return ChatUseCase(llm=llm) # type: ignore[arg-type]
|
||||
|
||||
|
||||
# --- Endpoints ---
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health() -> dict[str, str]:
|
||||
"""Sonde de santé — permet au Core Java de vérifier que le Brain répond."""
|
||||
return {"status": "ok", "service": "brain"}
|
||||
|
||||
|
||||
@app.post("/generate", response_model=GenerateResponse)
|
||||
async def generate(
|
||||
body: GenerateRequest,
|
||||
settings: Annotated[Settings, Depends(get_settings)],
|
||||
llm: Annotated[LLMProvider, Depends(get_llm_provider)],
|
||||
) -> GenerateResponse:
|
||||
"""Endpoint libre : prompt → texte brut. Utile pour debug et exploration."""
|
||||
try:
|
||||
text = await llm.generate(body.prompt)
|
||||
except LLMProviderError as exc:
|
||||
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||
|
||||
return GenerateResponse(model=settings.llm_model, response=text)
|
||||
|
||||
|
||||
@app.post("/generate-page", response_model=GeneratePageResponseDTO)
|
||||
async def generate_page(
|
||||
body: GeneratePageRequestDTO,
|
||||
use_case: Annotated[
|
||||
GeneratePageUseCase, Depends(get_generate_page_use_case)
|
||||
],
|
||||
) -> GeneratePageResponseDTO:
|
||||
"""Endpoint métier : contexte LoreMind → valeurs structurées par champ.
|
||||
|
||||
Branche tout le use case `GeneratePageUseCase`. Ce controller ne fait
|
||||
que le mapping DTO ↔ dataclass et la traduction d'erreur domaine → HTTP.
|
||||
"""
|
||||
context = PageGenerationContext(
|
||||
lore_name=body.lore_name,
|
||||
lore_description=body.lore_description,
|
||||
folder_name=body.folder_name,
|
||||
template_name=body.template_name,
|
||||
template_fields=body.template_fields,
|
||||
page_title=body.page_title,
|
||||
)
|
||||
|
||||
try:
|
||||
result = await use_case.execute(context)
|
||||
except LLMProviderError as exc:
|
||||
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||
|
||||
return GeneratePageResponseDTO(values=result.values)
|
||||
|
||||
|
||||
@app.post("/chat/stream")
|
||||
async def chat_stream(
|
||||
body: ChatStreamRequestDTO,
|
||||
use_case: Annotated[ChatUseCase, Depends(get_chat_use_case)],
|
||||
) -> StreamingResponse:
|
||||
"""Chat streamé (Server-Sent Events) avec Structural Context du Lore.
|
||||
|
||||
Format de flux :
|
||||
- Chaque token : `data: {"token": "..."}\\n\\n`
|
||||
- Fin normale : `event: done\\ndata: {}\\n\\n`
|
||||
- Erreur LLM : `event: error\\ndata: {"message": "..."}\\n\\n`
|
||||
|
||||
Le media_type `text/event-stream` déclenche le comportement SSE côté
|
||||
navigateur (objet EventSource) et la désactivation automatique du buffer.
|
||||
"""
|
||||
messages = [ChatMessage(role=m.role, content=m.content) for m in body.messages]
|
||||
context = LoreStructuralContext(
|
||||
lore_name=body.lore_context.lore_name,
|
||||
lore_description=body.lore_context.lore_description,
|
||||
folders={
|
||||
folder: [(p.title, p.template_name) for p in pages]
|
||||
for folder, pages in body.lore_context.folders.items()
|
||||
},
|
||||
tags=body.lore_context.tags,
|
||||
)
|
||||
page_context: PageContext | None = None
|
||||
if body.page_context is not None:
|
||||
page_context = PageContext(
|
||||
title=body.page_context.title,
|
||||
template_name=body.page_context.template_name,
|
||||
template_fields=body.page_context.template_fields,
|
||||
values=body.page_context.values,
|
||||
)
|
||||
|
||||
async def event_stream() -> AsyncIterator[str]:
|
||||
try:
|
||||
async for token in use_case.stream(messages, context, page_context):
|
||||
# json.dumps avec ensure_ascii=False pour préserver les accents
|
||||
yield f"data: {json.dumps({'token': token}, ensure_ascii=False)}\n\n"
|
||||
yield "event: done\ndata: {}\n\n"
|
||||
except LLMProviderError as exc:
|
||||
yield f"event: error\ndata: {json.dumps({'message': str(exc)})}\n\n"
|
||||
|
||||
return StreamingResponse(event_stream(), media_type="text/event-stream")
|
||||
Reference in New Issue
Block a user