From 5b133aa2fe4899be67de4e763190b5eb052aa64f Mon Sep 17 00:00:00 2001 From: "IETM_FIXE\\ietm6" Date: Mon, 20 Apr 2026 14:52:20 +0200 Subject: [PATCH] Ajout de la partie IA --- .gitignore | 2 + brain/.gitignore | 4 + brain/app/__init__.py | 0 brain/app/application/__init__.py | 0 brain/app/application/chat.py | 120 ++++++++ brain/app/application/generate_page.py | 98 +++++++ brain/app/core/__init__.py | 0 brain/app/core/config.py | 29 ++ brain/app/domain/__init__.py | 0 brain/app/domain/models.py | 89 ++++++ brain/app/domain/ports.py | 86 ++++++ brain/app/infrastructure/__init__.py | 0 brain/app/infrastructure/ollama_adapter.py | 110 ++++++++ brain/app/main.py | 226 +++++++++++++++ brain/requirements.txt | 4 + core/pom.xml | 7 + .../GeneratePageValuesUseCase.java | 123 +++++++++ .../StreamChatForLoreUseCase.java | 181 ++++++++++++ .../domain/generationcontext/ChatMessage.java | 16 ++ .../domain/generationcontext/ChatRequest.java | 22 ++ .../generationcontext/GenerationContext.java | 28 ++ .../generationcontext/GenerationResult.java | 18 ++ .../LoreStructuralContext.java | 39 +++ .../domain/generationcontext/PageContext.java | 27 ++ .../ports/AiChatProvider.java | 41 +++ .../generationcontext/ports/AiProvider.java | 25 ++ .../ports/AiProviderException.java | 22 ++ .../infrastructure/ai/BrainAiChatClient.java | 178 ++++++++++++ .../infrastructure/ai/BrainAiClient.java | 104 +++++++ .../ai/BrainGeneratePageRequest.java | 37 +++ .../ai/BrainGeneratePageResponse.java | 20 ++ .../infrastructure/ai/RestTemplateConfig.java | 29 ++ .../web/controller/AiChatController.java | 142 ++++++++++ .../controller/PageGenerationController.java | 66 +++++ .../dto/generationcontext/ChatMessageDTO.java | 18 ++ .../ChatStreamRequestDTO.java | 26 ++ .../GenerationSuggestionsDTO.java | 22 ++ .../src/main/resources/application.properties | 8 + docs/plan.md | 254 ++++++++++++++++- .../page-create/page-create.component.html | 24 +- .../page-create/page-create.component.scss | 27 ++ .../lore/page-create/page-create.component.ts | 140 +++++++++- .../lore/page-edit/page-edit.component.html | 24 +- .../lore/page-edit/page-edit.component.scss | 31 +++ .../app/lore/page-edit/page-edit.component.ts | 71 ++++- web/src/app/services/ai-chat.service.ts | 171 ++++++++++++ web/src/app/services/page.service.ts | 14 + .../ai-chat-drawer.component.html | 81 ++++++ .../ai-chat-drawer.component.scss | 261 ++++++++++++++++++ .../ai-chat-drawer.component.ts | 182 ++++++++++++ 50 files changed, 3236 insertions(+), 11 deletions(-) create mode 100644 brain/.gitignore create mode 100644 brain/app/__init__.py create mode 100644 brain/app/application/__init__.py create mode 100644 brain/app/application/chat.py create mode 100644 brain/app/application/generate_page.py create mode 100644 brain/app/core/__init__.py create mode 100644 brain/app/core/config.py create mode 100644 brain/app/domain/__init__.py create mode 100644 brain/app/domain/models.py create mode 100644 brain/app/domain/ports.py create mode 100644 brain/app/infrastructure/__init__.py create mode 100644 brain/app/infrastructure/ollama_adapter.py create mode 100644 brain/app/main.py create mode 100644 brain/requirements.txt create mode 100644 core/src/main/java/com/loremind/application/generationcontext/GeneratePageValuesUseCase.java create mode 100644 core/src/main/java/com/loremind/application/generationcontext/StreamChatForLoreUseCase.java create mode 100644 core/src/main/java/com/loremind/domain/generationcontext/ChatMessage.java create mode 100644 core/src/main/java/com/loremind/domain/generationcontext/ChatRequest.java create mode 100644 core/src/main/java/com/loremind/domain/generationcontext/GenerationContext.java create mode 100644 core/src/main/java/com/loremind/domain/generationcontext/GenerationResult.java create mode 100644 core/src/main/java/com/loremind/domain/generationcontext/LoreStructuralContext.java create mode 100644 core/src/main/java/com/loremind/domain/generationcontext/PageContext.java create mode 100644 core/src/main/java/com/loremind/domain/generationcontext/ports/AiChatProvider.java create mode 100644 core/src/main/java/com/loremind/domain/generationcontext/ports/AiProvider.java create mode 100644 core/src/main/java/com/loremind/domain/generationcontext/ports/AiProviderException.java create mode 100644 core/src/main/java/com/loremind/infrastructure/ai/BrainAiChatClient.java create mode 100644 core/src/main/java/com/loremind/infrastructure/ai/BrainAiClient.java create mode 100644 core/src/main/java/com/loremind/infrastructure/ai/BrainGeneratePageRequest.java create mode 100644 core/src/main/java/com/loremind/infrastructure/ai/BrainGeneratePageResponse.java create mode 100644 core/src/main/java/com/loremind/infrastructure/ai/RestTemplateConfig.java create mode 100644 core/src/main/java/com/loremind/infrastructure/web/controller/AiChatController.java create mode 100644 core/src/main/java/com/loremind/infrastructure/web/controller/PageGenerationController.java create mode 100644 core/src/main/java/com/loremind/infrastructure/web/dto/generationcontext/ChatMessageDTO.java create mode 100644 core/src/main/java/com/loremind/infrastructure/web/dto/generationcontext/ChatStreamRequestDTO.java create mode 100644 core/src/main/java/com/loremind/infrastructure/web/dto/generationcontext/GenerationSuggestionsDTO.java create mode 100644 web/src/app/services/ai-chat.service.ts create mode 100644 web/src/app/shared/ai-chat-drawer/ai-chat-drawer.component.html create mode 100644 web/src/app/shared/ai-chat-drawer/ai-chat-drawer.component.scss create mode 100644 web/src/app/shared/ai-chat-drawer/ai-chat-drawer.component.ts diff --git a/.gitignore b/.gitignore index ddb4e3c..f80e167 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,5 @@ Thumbs.db # Documentation temporaire docs/edraw/ +docs/academy/ +brain/.env.example diff --git a/brain/.gitignore b/brain/.gitignore new file mode 100644 index 0000000..a98520d --- /dev/null +++ b/brain/.gitignore @@ -0,0 +1,4 @@ +.venv/ +__pycache__/ +*.pyc +.env diff --git a/brain/app/__init__.py b/brain/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/brain/app/application/__init__.py b/brain/app/application/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/brain/app/application/chat.py b/brain/app/application/chat.py new file mode 100644 index 0000000..1f582d4 --- /dev/null +++ b/brain/app/application/chat.py @@ -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) diff --git a/brain/app/application/generate_page.py b/brain/app/application/generate_page.py new file mode 100644 index 0000000..c4a89d6 --- /dev/null +++ b/brain/app/application/generate_page.py @@ -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 + } diff --git a/brain/app/core/__init__.py b/brain/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/brain/app/core/config.py b/brain/app/core/config.py new file mode 100644 index 0000000..5f623ba --- /dev/null +++ b/brain/app/core/config.py @@ -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() diff --git a/brain/app/domain/__init__.py b/brain/app/domain/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/brain/app/domain/models.py b/brain/app/domain/models.py new file mode 100644 index 0000000..a714422 --- /dev/null +++ b/brain/app/domain/models.py @@ -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` 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] diff --git a/brain/app/domain/ports.py b/brain/app/domain/ports.py new file mode 100644 index 0000000..a2b81d7 --- /dev/null +++ b/brain/app/domain/ports.py @@ -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. + """ diff --git a/brain/app/infrastructure/__init__.py b/brain/app/infrastructure/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/brain/app/infrastructure/ollama_adapter.py b/brain/app/infrastructure/ollama_adapter.py new file mode 100644 index 0000000..9a0b12d --- /dev/null +++ b/brain/app/infrastructure/ollama_adapter.py @@ -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 diff --git a/brain/app/main.py b/brain/app/main.py new file mode 100644 index 0000000..06560e8 --- /dev/null +++ b/brain/app/main.py @@ -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") diff --git a/brain/requirements.txt b/brain/requirements.txt new file mode 100644 index 0000000..f61e899 --- /dev/null +++ b/brain/requirements.txt @@ -0,0 +1,4 @@ +fastapi==0.115.* +uvicorn[standard]==0.32.* +httpx==0.27.* +pydantic-settings==2.6.* diff --git a/core/pom.xml b/core/pom.xml index f697cb7..e578539 100644 --- a/core/pom.xml +++ b/core/pom.xml @@ -29,6 +29,13 @@ spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-webflux + + org.springframework.boot diff --git a/core/src/main/java/com/loremind/application/generationcontext/GeneratePageValuesUseCase.java b/core/src/main/java/com/loremind/application/generationcontext/GeneratePageValuesUseCase.java new file mode 100644 index 0000000..559d84d --- /dev/null +++ b/core/src/main/java/com/loremind/application/generationcontext/GeneratePageValuesUseCase.java @@ -0,0 +1,123 @@ +package com.loremind.application.generationcontext; + +import com.loremind.domain.generationcontext.GenerationContext; +import com.loremind.domain.generationcontext.GenerationResult; +import com.loremind.domain.generationcontext.ports.AiProvider; +import com.loremind.domain.lorecontext.Lore; +import com.loremind.domain.lorecontext.LoreNode; +import com.loremind.domain.lorecontext.Page; +import com.loremind.domain.lorecontext.Template; +import com.loremind.domain.lorecontext.ports.LoreNodeRepository; +import com.loremind.domain.lorecontext.ports.LoreRepository; +import com.loremind.domain.lorecontext.ports.PageRepository; +import com.loremind.domain.lorecontext.ports.TemplateRepository; +import org.springframework.stereotype.Service; + +import java.util.Map; + +/** + * Use case applicatif : génère des suggestions de valeurs pour les champs + * d'une Page via l'IA. + * + * Orchestrateur (couche Application de l'hexagonal). C'est le seul endroit + * qui touche simultanément au LoreContext (chargement) et au GenerationContext + * (appel IA). Le domaine reste isolé. + * + * Décision produit : ce use case NE PERSISTE PAS les valeurs générées. + * Il renvoie des suggestions que l'utilisateur validera manuellement via + * le endpoint PUT /api/pages/{id} existant. + */ +@Service +public class GeneratePageValuesUseCase { + + private final PageRepository pageRepository; + private final TemplateRepository templateRepository; + private final LoreRepository loreRepository; + private final LoreNodeRepository loreNodeRepository; + private final AiProvider aiProvider; + + public GeneratePageValuesUseCase( + PageRepository pageRepository, + TemplateRepository templateRepository, + LoreRepository loreRepository, + LoreNodeRepository loreNodeRepository, + AiProvider aiProvider) { + this.pageRepository = pageRepository; + this.templateRepository = templateRepository; + this.loreRepository = loreRepository; + this.loreNodeRepository = loreNodeRepository; + this.aiProvider = aiProvider; + } + + /** + * Génère les valeurs suggérées pour les champs dynamiques d'une Page. + * + * @param pageId identifiant de la Page à enrichir + * @return map fieldName -> valeur suggérée (jamais null, peut contenir des chaînes vides) + * @throws IllegalArgumentException si la Page est introuvable + * @throws IllegalStateException si le Template, le Lore ou le dossier parent sont + * incohérents (intégrité BDD cassée) ou si le Template + * n'a aucun champ à générer + */ + public Map execute(String pageId) { + Page page = loadPage(pageId); + Template template = loadTemplate(page.getTemplateId(), pageId); + Lore lore = loadLore(page.getLoreId(), pageId); + LoreNode folder = loadFolder(page.getNodeId(), pageId); + + requireNonEmptyFields(template); + + GenerationContext context = GenerationContext.builder() + .loreName(lore.getName()) + .loreDescription(lore.getDescription()) + .folderName(folder.getName()) + .templateName(template.getName()) + .templateFields(template.getFields()) + .pageTitle(page.getTitle()) + .build(); + + GenerationResult result = aiProvider.generatePage(context); + return result.getValues(); + } + + // --- Helpers de chargement (un lookup = un message d'erreur clair) ------ + + private Page loadPage(String pageId) { + return pageRepository.findById(pageId) + .orElseThrow(() -> new IllegalArgumentException( + "Page non trouvée avec l'ID: " + pageId)); + } + + private Template loadTemplate(String templateId, String pageId) { + if (templateId == null || templateId.isBlank()) { + throw new IllegalStateException( + "La page " + pageId + " n'a pas de template associé."); + } + return templateRepository.findById(templateId) + .orElseThrow(() -> new IllegalStateException( + "Template introuvable (id=" + templateId + + ") pour la page " + pageId)); + } + + private Lore loadLore(String loreId, String pageId) { + return loreRepository.findById(loreId) + .orElseThrow(() -> new IllegalStateException( + "Lore introuvable (id=" + loreId + + ") pour la page " + pageId)); + } + + private LoreNode loadFolder(String nodeId, String pageId) { + return loreNodeRepository.findById(nodeId) + .orElseThrow(() -> new IllegalStateException( + "Dossier parent introuvable (id=" + nodeId + + ") pour la page " + pageId)); + } + + private void requireNonEmptyFields(Template template) { + if (template.getFields() == null || template.getFields().isEmpty()) { + throw new IllegalStateException( + "Le template '" + template.getName() + + "' n'a aucun champ à générer."); + } + } +} diff --git a/core/src/main/java/com/loremind/application/generationcontext/StreamChatForLoreUseCase.java b/core/src/main/java/com/loremind/application/generationcontext/StreamChatForLoreUseCase.java new file mode 100644 index 0000000..d493748 --- /dev/null +++ b/core/src/main/java/com/loremind/application/generationcontext/StreamChatForLoreUseCase.java @@ -0,0 +1,181 @@ +package com.loremind.application.generationcontext; + +import com.loremind.domain.generationcontext.ChatMessage; +import com.loremind.domain.generationcontext.ChatRequest; +import com.loremind.domain.generationcontext.LoreStructuralContext; +import com.loremind.domain.generationcontext.LoreStructuralContext.FolderPage; +import com.loremind.domain.generationcontext.PageContext; +import com.loremind.domain.generationcontext.ports.AiChatProvider; +import com.loremind.domain.lorecontext.Lore; +import com.loremind.domain.lorecontext.LoreNode; +import com.loremind.domain.lorecontext.Page; +import com.loremind.domain.lorecontext.Template; +import com.loremind.domain.lorecontext.ports.LoreNodeRepository; +import com.loremind.domain.lorecontext.ports.LoreRepository; +import com.loremind.domain.lorecontext.ports.PageRepository; +import com.loremind.domain.lorecontext.ports.TemplateRepository; +import org.springframework.stereotype.Service; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; +import java.util.stream.Collectors; + +/** + * Use case applicatif : chat conversationnel avec Structural Context d'un Lore. + * + * Orchestrateur — charge la carte structurelle (dossiers + pages + templates + * + tags) depuis le LoreContext, la traduit vers le GenerationContext, puis + * délègue au port AiChatProvider pour le streaming. + * + * Zéro persistance : la conversation est éphémère (responsabilité du frontend). + */ +@Service +public class StreamChatForLoreUseCase { + + private final LoreRepository loreRepository; + private final LoreNodeRepository loreNodeRepository; + private final PageRepository pageRepository; + private final TemplateRepository templateRepository; + private final AiChatProvider aiChatProvider; + + public StreamChatForLoreUseCase( + LoreRepository loreRepository, + LoreNodeRepository loreNodeRepository, + PageRepository pageRepository, + TemplateRepository templateRepository, + AiChatProvider aiChatProvider) { + this.loreRepository = loreRepository; + this.loreNodeRepository = loreNodeRepository; + this.pageRepository = pageRepository; + this.templateRepository = templateRepository; + this.aiChatProvider = aiChatProvider; + } + + /** + * Streame la réponse du LLM pour le Lore donné avec la conversation fournie. + * + * Méthode bloquante : retourne une fois le stream terminé (onComplete ou onError). + * L'appelant (controller SSE) doit l'exécuter dans un thread dédié. + * + * @param loreId obligatoire — l'univers concerné + * @param pageId optionnel (nullable) — si fourni, focalise l'IA sur cette page + * précise (template, champs, valeurs actuelles). + * @throws IllegalArgumentException si le Lore (ou la Page si pageId fourni) est introuvable + */ + public void execute( + String loreId, + String pageId, + List messages, + Consumer onToken, + Runnable onComplete, + Consumer onError) { + + LoreStructuralContext loreContext = buildLoreContext(loreId); + PageContext pageContext = (pageId == null || pageId.isBlank()) + ? null + : buildPageContext(pageId); + + ChatRequest request = ChatRequest.builder() + .messages(messages) + .loreContext(loreContext) + .pageContext(pageContext) + .build(); + + aiChatProvider.streamChat(request, onToken, onComplete, onError); + } + + // --- Construction du contexte d'une page précise ------------------------ + + /** + * Charge la Page + son Template et construit un PageContext prêt à injecter. + * Si le template est absent (page orpheline), on renvoie un PageContext + * minimal (titre + template "?", champs vides) — l'IA reste contextualisée + * sur la page sans pouvoir proposer de champs précis. + */ + private PageContext buildPageContext(String pageId) { + Page page = pageRepository.findById(pageId) + .orElseThrow(() -> new IllegalArgumentException( + "Page non trouvée avec l'ID: " + pageId)); + + String templateName = "?"; + List templateFields = Collections.emptyList(); + if (page.hasTemplate()) { + Template template = templateRepository.findById(page.getTemplateId()).orElse(null); + if (template != null) { + templateName = template.getName(); + templateFields = template.getFields() != null + ? template.getFields() + : Collections.emptyList(); + } + } + + Map values = page.getValues() != null + ? page.getValues() + : Collections.emptyMap(); + + return PageContext.builder() + .title(page.getTitle()) + .templateName(templateName) + .templateFields(templateFields) + .values(values) + .build(); + } + + // --- Construction de la carte structurelle ------------------------------ + + private LoreStructuralContext buildLoreContext(String loreId) { + Lore lore = loreRepository.findById(loreId) + .orElseThrow(() -> new IllegalArgumentException( + "Lore non trouvé avec l'ID: " + loreId)); + + List nodes = loreNodeRepository.findByLoreId(loreId); + List pages = pageRepository.findByLoreId(loreId); + List