Ajout de la partie IA
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -41,3 +41,5 @@ Thumbs.db
|
|||||||
|
|
||||||
# Documentation temporaire
|
# Documentation temporaire
|
||||||
docs/edraw/
|
docs/edraw/
|
||||||
|
docs/academy/
|
||||||
|
brain/.env.example
|
||||||
|
|||||||
4
brain/.gitignore
vendored
Normal file
4
brain/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.env
|
||||||
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")
|
||||||
4
brain/requirements.txt
Normal file
4
brain/requirements.txt
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
fastapi==0.115.*
|
||||||
|
uvicorn[standard]==0.32.*
|
||||||
|
httpx==0.27.*
|
||||||
|
pydantic-settings==2.6.*
|
||||||
@@ -29,6 +29,13 @@
|
|||||||
<artifactId>spring-boot-starter-web</artifactId>
|
<artifactId>spring-boot-starter-web</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Spring WebFlux : requis pour WebClient (streaming SSE vers le Brain).
|
||||||
|
RestTemplate (Web MVC) reste pour les appels synchrones one-shot. -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-webflux</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
<!-- Spring Boot Data JPA -->
|
<!-- Spring Boot Data JPA -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
|||||||
@@ -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<String, String> 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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<ChatMessage> messages,
|
||||||
|
Consumer<String> onToken,
|
||||||
|
Runnable onComplete,
|
||||||
|
Consumer<Throwable> 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<String> 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<String, String> 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<LoreNode> nodes = loreNodeRepository.findByLoreId(loreId);
|
||||||
|
List<Page> pages = pageRepository.findByLoreId(loreId);
|
||||||
|
List<Template> templates = templateRepository.findByLoreId(loreId);
|
||||||
|
|
||||||
|
Map<String, List<FolderPage>> folders = buildFoldersMap(nodes, pages, templates);
|
||||||
|
List<String> tags = extractUniqueTags(pages);
|
||||||
|
|
||||||
|
return LoreStructuralContext.builder()
|
||||||
|
.loreName(lore.getName())
|
||||||
|
.loreDescription(lore.getDescription())
|
||||||
|
.folders(folders)
|
||||||
|
.tags(tags)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, List<FolderPage>> buildFoldersMap(
|
||||||
|
List<LoreNode> nodes, List<Page> pages, List<Template> templates) {
|
||||||
|
|
||||||
|
Map<String, String> templateNameById = templates.stream()
|
||||||
|
.collect(Collectors.toMap(Template::getId, Template::getName, (a, b) -> a));
|
||||||
|
|
||||||
|
// LinkedHashMap : préserve l'ordre d'insertion pour un prompt lisible.
|
||||||
|
Map<String, List<FolderPage>> folders = new LinkedHashMap<>();
|
||||||
|
for (LoreNode node : nodes) {
|
||||||
|
folders.put(node.getName(), pagesInFolder(node.getId(), pages, templateNameById));
|
||||||
|
}
|
||||||
|
return folders;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<FolderPage> pagesInFolder(
|
||||||
|
String nodeId, List<Page> allPages, Map<String, String> templateNameById) {
|
||||||
|
return allPages.stream()
|
||||||
|
.filter(p -> nodeId.equals(p.getNodeId()))
|
||||||
|
.map(p -> FolderPage.builder()
|
||||||
|
.title(p.getTitle())
|
||||||
|
.templateName(templateNameById.getOrDefault(p.getTemplateId(), "?"))
|
||||||
|
.build())
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<String> extractUniqueTags(List<Page> pages) {
|
||||||
|
return pages.stream()
|
||||||
|
.filter(p -> p.getTags() != null)
|
||||||
|
.flatMap(p -> p.getTags().stream())
|
||||||
|
.distinct()
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package com.loremind.domain.generationcontext;
|
||||||
|
|
||||||
|
import lombok.Value;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Un message d'une conversation avec le LLM.
|
||||||
|
*
|
||||||
|
* Rôles acceptés : "user", "assistant", "system".
|
||||||
|
* Object de valeur immuable — cohérent avec le reste du domaine.
|
||||||
|
*/
|
||||||
|
@Value
|
||||||
|
public class ChatMessage {
|
||||||
|
|
||||||
|
String role;
|
||||||
|
String content;
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package com.loremind.domain.generationcontext;
|
||||||
|
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Value;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Object de valeur encapsulant une requête de chat streamé.
|
||||||
|
*
|
||||||
|
* Regroupe l'historique de la conversation et le contexte structurel du
|
||||||
|
* Lore — les deux informations dont l'IA a besoin pour répondre.
|
||||||
|
*/
|
||||||
|
@Value
|
||||||
|
@Builder
|
||||||
|
public class ChatRequest {
|
||||||
|
|
||||||
|
List<ChatMessage> messages;
|
||||||
|
LoreStructuralContext loreContext;
|
||||||
|
/** Optionnel : contexte d'une page précise en cours d'édition. Null = chat générique au Lore. */
|
||||||
|
PageContext pageContext;
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package com.loremind.domain.generationcontext;
|
||||||
|
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Value;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Object de valeur (immuable) représentant une demande de génération IA
|
||||||
|
* pour remplir une Page à partir d'un Template.
|
||||||
|
*
|
||||||
|
* Équivalent Java du PageGenerationContext Python (brain/app/domain/models.py).
|
||||||
|
* Entité pure du domaine : aucune dépendance technique.
|
||||||
|
*
|
||||||
|
* Immuable via @Value (Lombok) : pas de setters, tous les champs final.
|
||||||
|
* C'est un DTO de domaine entrant dans le port AiProvider.
|
||||||
|
*/
|
||||||
|
@Value
|
||||||
|
@Builder
|
||||||
|
public class GenerationContext {
|
||||||
|
|
||||||
|
String loreName;
|
||||||
|
String loreDescription;
|
||||||
|
String folderName; // Nom du LoreNode parent (ex: "PNJ", "Lieux")
|
||||||
|
String templateName;
|
||||||
|
List<String> templateFields; // Champs à générer (clés attendues dans la réponse)
|
||||||
|
String pageTitle;
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package com.loremind.domain.generationcontext;
|
||||||
|
|
||||||
|
import lombok.Value;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Résultat d'une génération IA : une map fieldName -> valeur générée.
|
||||||
|
*
|
||||||
|
* Équivalent Java du PageGenerationResult Python.
|
||||||
|
* Immuable : une fois reçu, pas de modification (l'UI pourra faire du merge,
|
||||||
|
* mais pas en mutant cet objet).
|
||||||
|
*/
|
||||||
|
@Value
|
||||||
|
public class GenerationResult {
|
||||||
|
|
||||||
|
Map<String, String> values;
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package com.loremind.domain.generationcontext;
|
||||||
|
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Singular;
|
||||||
|
import lombok.Value;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Carte structurelle d'un Lore pour nourrir l'IA sans tout lui envoyer.
|
||||||
|
*
|
||||||
|
* Équivalent Java du LoreStructuralContext Python. Pas de contenu des pages,
|
||||||
|
* uniquement la structure (dossiers, titres, templates, tags). Suffit pour
|
||||||
|
* que l'IA propose des suggestions cohérentes avec l'existant.
|
||||||
|
*
|
||||||
|
* La map `folders` est indexée par nom de dossier et mappe vers la liste
|
||||||
|
* des pages qu'il contient (liste vide autorisée pour les dossiers vides).
|
||||||
|
*/
|
||||||
|
@Value
|
||||||
|
@Builder
|
||||||
|
public class LoreStructuralContext {
|
||||||
|
|
||||||
|
String loreName;
|
||||||
|
String loreDescription;
|
||||||
|
Map<String, List<FolderPage>> folders;
|
||||||
|
@Singular List<String> tags;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Résumé minimaliste d'une page : juste son titre et son template.
|
||||||
|
* Pas de valeurs, pas de notes, pas de tags (pour garder le prompt léger).
|
||||||
|
*/
|
||||||
|
@Value
|
||||||
|
@Builder
|
||||||
|
public static class FolderPage {
|
||||||
|
String title;
|
||||||
|
String templateName;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package com.loremind.domain.generationcontext;
|
||||||
|
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Value;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contexte d'une page spécifique en cours d'édition.
|
||||||
|
*
|
||||||
|
* Complément du LoreStructuralContext : l'un donne la carte générale du
|
||||||
|
* Lore, l'autre zoome sur la page précise en cours de discussion. Permet
|
||||||
|
* à l'IA de focaliser ses suggestions sur les bons champs sans déborder
|
||||||
|
* sur d'autres pages/templates.
|
||||||
|
*
|
||||||
|
* Object de valeur immuable, pur domaine — aucune dépendance technique.
|
||||||
|
*/
|
||||||
|
@Value
|
||||||
|
@Builder
|
||||||
|
public class PageContext {
|
||||||
|
|
||||||
|
String title;
|
||||||
|
String templateName;
|
||||||
|
List<String> templateFields;
|
||||||
|
Map<String, String> values;
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package com.loremind.domain.generationcontext.ports;
|
||||||
|
|
||||||
|
import com.loremind.domain.generationcontext.ChatRequest;
|
||||||
|
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Port de sortie pour le chat streamé avec un LLM.
|
||||||
|
*
|
||||||
|
* Distinct de AiProvider (one-shot) par Interface Segregation Principle :
|
||||||
|
* le streaming est une capacité séparée avec un contrat propre. Un même
|
||||||
|
* adapter concret peut satisfaire les deux ports s'il le souhaite.
|
||||||
|
*
|
||||||
|
* API par callbacks (plutôt que Flux/Stream) pour garder le domaine libre
|
||||||
|
* de toute dépendance à Reactor. Les couches supérieures (controller SSE)
|
||||||
|
* s'adaptent naturellement à ce style.
|
||||||
|
*/
|
||||||
|
public interface AiChatProvider {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Streame la réponse du LLM en invoquant les callbacks au fil de l'eau.
|
||||||
|
*
|
||||||
|
* Cette méthode est bloquante : elle ne rend la main qu'après la fin
|
||||||
|
* du stream (appel à onComplete ou onError). L'appelant est responsable
|
||||||
|
* de l'exécuter dans un thread adapté (ex: thread dédié à la requête
|
||||||
|
* HTTP côté controller SSE).
|
||||||
|
*
|
||||||
|
* @param request messages + contexte Lore
|
||||||
|
* @param onToken invoqué à chaque token reçu du LLM (peut être appelé
|
||||||
|
* de nombreuses fois)
|
||||||
|
* @param onComplete invoqué une fois le stream terminé avec succès
|
||||||
|
* @param onError invoqué en cas d'erreur (Brain injoignable, timeout,
|
||||||
|
* réponse invalide). Exclusif avec onComplete.
|
||||||
|
*/
|
||||||
|
void streamChat(
|
||||||
|
ChatRequest request,
|
||||||
|
Consumer<String> onToken,
|
||||||
|
Runnable onComplete,
|
||||||
|
Consumer<Throwable> onError
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package com.loremind.domain.generationcontext.ports;
|
||||||
|
|
||||||
|
import com.loremind.domain.generationcontext.GenerationContext;
|
||||||
|
import com.loremind.domain.generationcontext.GenerationResult;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Port de sortie pour la génération IA.
|
||||||
|
*
|
||||||
|
* Le domaine ne connaît pas l'implémentation (HTTP vers Brain Python,
|
||||||
|
* appel direct à OpenAI, mock en test, etc.). Il manipule uniquement
|
||||||
|
* cette interface.
|
||||||
|
*
|
||||||
|
* C'est l'équivalent Java du Protocol LLMProvider côté Python —
|
||||||
|
* même pattern hexagonal des deux côtés de la frontière réseau.
|
||||||
|
*/
|
||||||
|
public interface AiProvider {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Génère les valeurs des champs d'une Page à partir du contexte fourni.
|
||||||
|
*
|
||||||
|
* @throws AiProviderException si le fournisseur IA est indisponible,
|
||||||
|
* renvoie une réponse invalide ou dépasse le timeout.
|
||||||
|
*/
|
||||||
|
GenerationResult generatePage(GenerationContext context) throws AiProviderException;
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package com.loremind.domain.generationcontext.ports;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exception de domaine signalant un échec du fournisseur IA.
|
||||||
|
*
|
||||||
|
* Équivalent Java de LLMProviderError (Python). Hérite de RuntimeException
|
||||||
|
* pour rester cohérent avec le reste du code (pas d'exceptions checked
|
||||||
|
* qui polluent les signatures de méthodes).
|
||||||
|
*
|
||||||
|
* L'Adapter (BrainAiClient) traduira toute erreur technique (timeout,
|
||||||
|
* 5xx, JSON invalide) en AiProviderException avant de la propager.
|
||||||
|
*/
|
||||||
|
public class AiProviderException extends RuntimeException {
|
||||||
|
|
||||||
|
public AiProviderException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public AiProviderException(String message, Throwable cause) {
|
||||||
|
super(message, cause);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
package com.loremind.infrastructure.ai;
|
||||||
|
|
||||||
|
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.generationcontext.ports.AiProviderException;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.core.ParameterizedTypeReference;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.http.codec.ServerSentEvent;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.reactive.function.client.WebClient;
|
||||||
|
import reactor.core.publisher.Flux;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adapter de sortie : implémente AiChatProvider en appelant
|
||||||
|
* le Brain Python via WebClient + SSE (Server-Sent Events).
|
||||||
|
*
|
||||||
|
* Responsabilités :
|
||||||
|
* 1. Traduire ChatRequest (domaine) -> JSON attendu par /chat/stream.
|
||||||
|
* 2. Consommer le flux SSE token par token.
|
||||||
|
* 3. Invoquer onToken / onComplete / onError au bon moment.
|
||||||
|
* 4. Traduire toute erreur technique en AiProviderException.
|
||||||
|
*
|
||||||
|
* Le domaine ne voit JAMAIS WebClient, Flux, ni la moindre URL.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class BrainAiChatClient implements AiChatProvider {
|
||||||
|
|
||||||
|
private static final String CHAT_STREAM_PATH = "/chat/stream";
|
||||||
|
private static final ParameterizedTypeReference<ServerSentEvent<String>> SSE_STRING_TYPE =
|
||||||
|
new ParameterizedTypeReference<>() {};
|
||||||
|
|
||||||
|
private final WebClient webClient;
|
||||||
|
|
||||||
|
public BrainAiChatClient(
|
||||||
|
WebClient.Builder builder,
|
||||||
|
@Value("${brain.base-url}") String baseUrl) {
|
||||||
|
this.webClient = builder.baseUrl(baseUrl).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void streamChat(
|
||||||
|
ChatRequest request,
|
||||||
|
Consumer<String> onToken,
|
||||||
|
Runnable onComplete,
|
||||||
|
Consumer<Throwable> onError) {
|
||||||
|
|
||||||
|
Map<String, Object> payload = toPayload(request);
|
||||||
|
|
||||||
|
Flux<ServerSentEvent<String>> flux = webClient.post()
|
||||||
|
.uri(CHAT_STREAM_PATH)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.accept(MediaType.TEXT_EVENT_STREAM)
|
||||||
|
.bodyValue(payload)
|
||||||
|
.retrieve()
|
||||||
|
.bodyToFlux(SSE_STRING_TYPE);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// blockLast() : transforme le flux réactif en appel bloquant conforme
|
||||||
|
// au contrat synchrone du port. L'appelant choisit le thread.
|
||||||
|
flux
|
||||||
|
.timeout(Duration.ofSeconds(120))
|
||||||
|
.doOnNext(sse -> handleEvent(sse, onToken, onError))
|
||||||
|
.blockLast();
|
||||||
|
onComplete.run();
|
||||||
|
} catch (Exception e) {
|
||||||
|
onError.accept(new AiProviderException(
|
||||||
|
"Erreur lors du streaming chat depuis le Brain.", e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dispatch selon le type d'événement SSE (data par défaut, done, error). */
|
||||||
|
private void handleEvent(
|
||||||
|
ServerSentEvent<String> sse,
|
||||||
|
Consumer<String> onToken,
|
||||||
|
Consumer<Throwable> onError) {
|
||||||
|
String event = sse.event(); // null si pas d'event: xxx -> c'est un data par défaut
|
||||||
|
String data = sse.data();
|
||||||
|
|
||||||
|
if ("error".equals(event)) {
|
||||||
|
onError.accept(new AiProviderException(
|
||||||
|
"Le Brain a signalé une erreur : " + data));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ("done".equals(event)) {
|
||||||
|
return; // la fin est gérée par blockLast + onComplete
|
||||||
|
}
|
||||||
|
// Défaut : événement data avec JSON {"token":"..."}.
|
||||||
|
String token = extractToken(data);
|
||||||
|
if (token != null && !token.isEmpty()) {
|
||||||
|
onToken.accept(token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse minimaliste du JSON {"token":"..."} sans pull Jackson ici.
|
||||||
|
* Si le format se complexifie, on remplacera par un DTO Jackson.
|
||||||
|
*/
|
||||||
|
private String extractToken(String json) {
|
||||||
|
if (json == null) return null;
|
||||||
|
int idx = json.indexOf("\"token\"");
|
||||||
|
if (idx < 0) return null;
|
||||||
|
int colon = json.indexOf(':', idx);
|
||||||
|
int firstQuote = json.indexOf('"', colon + 1);
|
||||||
|
int lastQuote = json.lastIndexOf('"');
|
||||||
|
if (firstQuote < 0 || lastQuote <= firstQuote) return null;
|
||||||
|
return json.substring(firstQuote + 1, lastQuote)
|
||||||
|
.replace("\\n", "\n")
|
||||||
|
.replace("\\\"", "\"")
|
||||||
|
.replace("\\\\", "\\");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Construction du payload JSON vers le Brain -------------------------
|
||||||
|
|
||||||
|
private Map<String, Object> toPayload(ChatRequest request) {
|
||||||
|
Map<String, Object> root = new LinkedHashMap<>();
|
||||||
|
root.put("messages", request.getMessages().stream()
|
||||||
|
.map(this::messageToMap)
|
||||||
|
.collect(Collectors.toList()));
|
||||||
|
root.put("lore_context", loreContextToMap(request.getLoreContext()));
|
||||||
|
// page_context est optionnel côté Brain (Pydantic l'accepte null).
|
||||||
|
// On ne l'ajoute au payload que s'il est effectivement fourni.
|
||||||
|
if (request.getPageContext() != null) {
|
||||||
|
root.put("page_context", pageContextToMap(request.getPageContext()));
|
||||||
|
}
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> pageContextToMap(PageContext pc) {
|
||||||
|
Map<String, Object> map = new LinkedHashMap<>();
|
||||||
|
map.put("title", pc.getTitle());
|
||||||
|
map.put("template_name", pc.getTemplateName());
|
||||||
|
map.put("template_fields", pc.getTemplateFields());
|
||||||
|
map.put("values", pc.getValues());
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> messageToMap(ChatMessage m) {
|
||||||
|
Map<String, Object> map = new LinkedHashMap<>();
|
||||||
|
map.put("role", m.getRole());
|
||||||
|
map.put("content", m.getContent());
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> loreContextToMap(LoreStructuralContext ctx) {
|
||||||
|
Map<String, Object> map = new LinkedHashMap<>();
|
||||||
|
map.put("lore_name", ctx.getLoreName());
|
||||||
|
map.put("lore_description", ctx.getLoreDescription());
|
||||||
|
|
||||||
|
Map<String, Object> foldersMap = new LinkedHashMap<>();
|
||||||
|
for (Map.Entry<String, List<FolderPage>> e : ctx.getFolders().entrySet()) {
|
||||||
|
foldersMap.put(e.getKey(), e.getValue().stream()
|
||||||
|
.map(this::folderPageToMap)
|
||||||
|
.collect(Collectors.toList()));
|
||||||
|
}
|
||||||
|
map.put("folders", foldersMap);
|
||||||
|
map.put("tags", ctx.getTags());
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> folderPageToMap(FolderPage fp) {
|
||||||
|
Map<String, Object> map = new LinkedHashMap<>();
|
||||||
|
map.put("title", fp.getTitle());
|
||||||
|
map.put("template_name", fp.getTemplateName());
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
package com.loremind.infrastructure.ai;
|
||||||
|
|
||||||
|
import com.loremind.domain.generationcontext.GenerationContext;
|
||||||
|
import com.loremind.domain.generationcontext.GenerationResult;
|
||||||
|
import com.loremind.domain.generationcontext.ports.AiProvider;
|
||||||
|
import com.loremind.domain.generationcontext.ports.AiProviderException;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.http.HttpEntity;
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.client.ResourceAccessException;
|
||||||
|
import org.springframework.web.client.RestClientResponseException;
|
||||||
|
import org.springframework.web.client.RestTemplate;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adapter de sortie : implémente le port AiProvider en appelant
|
||||||
|
* le Brain Python via HTTP (RestTemplate).
|
||||||
|
*
|
||||||
|
* Responsabilités exclusives de cette classe :
|
||||||
|
* 1. Traduire GenerationContext (domaine) -> BrainGeneratePageRequest (wire).
|
||||||
|
* 2. Exécuter l'appel HTTP POST /generate-page.
|
||||||
|
* 3. Traduire BrainGeneratePageResponse (wire) -> GenerationResult (domaine).
|
||||||
|
* 4. Traduire toute erreur technique en AiProviderException (exception de domaine).
|
||||||
|
*
|
||||||
|
* Le domaine ne voit JAMAIS RestTemplate, Jackson, ni la moindre URL.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class BrainAiClient implements AiProvider {
|
||||||
|
|
||||||
|
private static final String GENERATE_PAGE_PATH = "/generate-page";
|
||||||
|
|
||||||
|
private final RestTemplate restTemplate;
|
||||||
|
private final String baseUrl;
|
||||||
|
|
||||||
|
public BrainAiClient(
|
||||||
|
RestTemplate restTemplate,
|
||||||
|
@Value("${brain.base-url}") String baseUrl) {
|
||||||
|
this.restTemplate = restTemplate;
|
||||||
|
this.baseUrl = baseUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public GenerationResult generatePage(GenerationContext context) {
|
||||||
|
BrainGeneratePageRequest request = toBrainRequest(context);
|
||||||
|
BrainGeneratePageResponse response = callBrain(request);
|
||||||
|
return toDomainResult(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Traduction domaine -> wire -----------------------------------------
|
||||||
|
|
||||||
|
private BrainGeneratePageRequest toBrainRequest(GenerationContext context) {
|
||||||
|
return new BrainGeneratePageRequest(
|
||||||
|
context.getLoreName(),
|
||||||
|
context.getLoreDescription(),
|
||||||
|
context.getFolderName(),
|
||||||
|
context.getTemplateName(),
|
||||||
|
context.getTemplateFields(),
|
||||||
|
context.getPageTitle()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Appel HTTP + traduction d'erreurs ----------------------------------
|
||||||
|
|
||||||
|
private BrainGeneratePageResponse callBrain(BrainGeneratePageRequest request) {
|
||||||
|
HttpHeaders headers = new HttpHeaders();
|
||||||
|
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||||
|
HttpEntity<BrainGeneratePageRequest> entity = new HttpEntity<>(request, headers);
|
||||||
|
|
||||||
|
try {
|
||||||
|
BrainGeneratePageResponse response = restTemplate.postForObject(
|
||||||
|
baseUrl + GENERATE_PAGE_PATH,
|
||||||
|
entity,
|
||||||
|
BrainGeneratePageResponse.class
|
||||||
|
);
|
||||||
|
if (response == null || response.getValues() == null) {
|
||||||
|
throw new AiProviderException("Le Brain a renvoyé une réponse vide.");
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
} catch (ResourceAccessException e) {
|
||||||
|
// Timeout ou connexion impossible (Brain down)
|
||||||
|
throw new AiProviderException(
|
||||||
|
"Le Brain est injoignable (timeout ou service arrêté).", e);
|
||||||
|
} catch (RestClientResponseException e) {
|
||||||
|
// Code HTTP 4xx/5xx renvoyé par le Brain
|
||||||
|
throw new AiProviderException(
|
||||||
|
"Le Brain a répondu avec une erreur HTTP " + e.getStatusCode().value(), e);
|
||||||
|
} catch (AiProviderException e) {
|
||||||
|
throw e; // déjà traduite, ne pas ré-envelopper
|
||||||
|
} catch (Exception e) {
|
||||||
|
// Filet de sécurité (JSON invalide, etc.)
|
||||||
|
throw new AiProviderException(
|
||||||
|
"Erreur inattendue lors de l'appel au Brain.", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Traduction wire -> domaine -----------------------------------------
|
||||||
|
|
||||||
|
private GenerationResult toDomainResult(BrainGeneratePageResponse response) {
|
||||||
|
return new GenerationResult(Map.copyOf(response.getValues()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package com.loremind.infrastructure.ai;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Value;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO interne de l'Adapter : format JSON envoyé au Brain Python.
|
||||||
|
* Package-private : n'existe que pour la couche infrastructure.
|
||||||
|
*
|
||||||
|
* Le contrat HTTP côté Python utilise snake_case — on le matche ici
|
||||||
|
* pour éviter de configurer Jackson globalement (impact sur le reste du projet).
|
||||||
|
*/
|
||||||
|
@Value
|
||||||
|
@AllArgsConstructor
|
||||||
|
class BrainGeneratePageRequest {
|
||||||
|
|
||||||
|
@JsonProperty("lore_name")
|
||||||
|
String loreName;
|
||||||
|
|
||||||
|
@JsonProperty("lore_description")
|
||||||
|
String loreDescription;
|
||||||
|
|
||||||
|
@JsonProperty("folder_name")
|
||||||
|
String folderName;
|
||||||
|
|
||||||
|
@JsonProperty("template_name")
|
||||||
|
String templateName;
|
||||||
|
|
||||||
|
@JsonProperty("template_fields")
|
||||||
|
List<String> templateFields;
|
||||||
|
|
||||||
|
@JsonProperty("page_title")
|
||||||
|
String pageTitle;
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package com.loremind.infrastructure.ai;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO interne de l'Adapter : format JSON reçu du Brain Python.
|
||||||
|
*
|
||||||
|
* @Data + @NoArgsConstructor : nécessaire à Jackson pour la désérialisation.
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
class BrainGeneratePageResponse {
|
||||||
|
|
||||||
|
@JsonProperty("values")
|
||||||
|
private Map<String, String> values;
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package com.loremind.infrastructure.ai;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.boot.web.client.RestTemplateBuilder;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.web.client.RestTemplate;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration Spring fournissant un RestTemplate avec timeout adapté
|
||||||
|
* aux appels vers le Brain (LLM local parfois lent).
|
||||||
|
*
|
||||||
|
* Ce bean est réutilisable par tout futur Adapter HTTP du projet.
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
public class RestTemplateConfig {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public RestTemplate brainRestTemplate(
|
||||||
|
RestTemplateBuilder builder,
|
||||||
|
@Value("${brain.timeout-seconds}") long timeoutSeconds) {
|
||||||
|
return builder
|
||||||
|
.setConnectTimeout(Duration.ofSeconds(10))
|
||||||
|
.setReadTimeout(Duration.ofSeconds(timeoutSeconds))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
package com.loremind.infrastructure.web.controller;
|
||||||
|
|
||||||
|
import com.loremind.application.generationcontext.StreamChatForLoreUseCase;
|
||||||
|
import com.loremind.domain.generationcontext.ChatMessage;
|
||||||
|
import com.loremind.infrastructure.web.dto.generationcontext.ChatMessageDTO;
|
||||||
|
import com.loremind.infrastructure.web.dto.generationcontext.ChatStreamRequestDTO;
|
||||||
|
import org.springframework.core.task.AsyncTaskExecutor;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* REST Controller pour le chat IA streamé (Server-Sent Events).
|
||||||
|
*
|
||||||
|
* POST /api/ai/chat/stream → flux SSE de tokens
|
||||||
|
*
|
||||||
|
* Le streaming est lancé dans un thread séparé (AsyncTaskExecutor) pour
|
||||||
|
* ne pas bloquer le thread servlet pendant toute la durée de la génération.
|
||||||
|
* SseEmitter est thread-safe : les callbacks du port AiChatProvider peuvent
|
||||||
|
* écrire directement dessus depuis n'importe quel thread.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/ai")
|
||||||
|
public class AiChatController {
|
||||||
|
|
||||||
|
/** Timeout SSE long — les modèles LLM locaux peuvent générer pendant quelques minutes. */
|
||||||
|
private static final long SSE_TIMEOUT_MS = 5 * 60 * 1000L;
|
||||||
|
|
||||||
|
private final StreamChatForLoreUseCase streamChatForLoreUseCase;
|
||||||
|
private final AsyncTaskExecutor taskExecutor;
|
||||||
|
|
||||||
|
public AiChatController(
|
||||||
|
StreamChatForLoreUseCase streamChatForLoreUseCase,
|
||||||
|
AsyncTaskExecutor taskExecutor) {
|
||||||
|
this.streamChatForLoreUseCase = streamChatForLoreUseCase;
|
||||||
|
this.taskExecutor = taskExecutor;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping(value = "/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
|
||||||
|
public SseEmitter chatStream(@RequestBody ChatStreamRequestDTO body) {
|
||||||
|
SseEmitter emitter = new SseEmitter(SSE_TIMEOUT_MS);
|
||||||
|
|
||||||
|
List<ChatMessage> messages = toDomainMessages(body.getMessages());
|
||||||
|
|
||||||
|
taskExecutor.execute(() -> runStreaming(emitter, body.getLoreId(), body.getPageId(), messages));
|
||||||
|
|
||||||
|
return emitter;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Exécution du streaming dans un thread dédié ------------------------
|
||||||
|
|
||||||
|
private void runStreaming(SseEmitter emitter, String loreId, String pageId, List<ChatMessage> messages) {
|
||||||
|
try {
|
||||||
|
streamChatForLoreUseCase.execute(
|
||||||
|
loreId,
|
||||||
|
pageId,
|
||||||
|
messages,
|
||||||
|
token -> sendToken(emitter, token),
|
||||||
|
() -> complete(emitter),
|
||||||
|
error -> fail(emitter, error)
|
||||||
|
);
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
// Lore ou Page introuvable : on envoie un event error puis on termine proprement.
|
||||||
|
fail(emitter, e);
|
||||||
|
} catch (Exception e) {
|
||||||
|
fail(emitter, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Helpers SSE (un seul point d'écriture par type d'événement) --------
|
||||||
|
|
||||||
|
private void sendToken(SseEmitter emitter, String token) {
|
||||||
|
try {
|
||||||
|
emitter.send(SseEmitter.event()
|
||||||
|
.data("{\"token\":" + jsonEscape(token) + "}"));
|
||||||
|
} catch (IOException e) {
|
||||||
|
emitter.completeWithError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void complete(SseEmitter emitter) {
|
||||||
|
try {
|
||||||
|
emitter.send(SseEmitter.event().name("done").data("{}"));
|
||||||
|
emitter.complete();
|
||||||
|
} catch (IOException e) {
|
||||||
|
emitter.completeWithError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void fail(SseEmitter emitter, Throwable error) {
|
||||||
|
try {
|
||||||
|
String message = error.getMessage() != null ? error.getMessage() : error.getClass().getSimpleName();
|
||||||
|
emitter.send(SseEmitter.event()
|
||||||
|
.name("error")
|
||||||
|
.data("{\"message\":" + jsonEscape(message) + "}"));
|
||||||
|
emitter.complete();
|
||||||
|
} catch (IOException ioe) {
|
||||||
|
emitter.completeWithError(ioe);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Utilitaires --------------------------------------------------------
|
||||||
|
|
||||||
|
/** Encadre une chaîne de guillemets et échappe les caractères JSON dangereux. */
|
||||||
|
private String jsonEscape(String raw) {
|
||||||
|
if (raw == null) return "\"\"";
|
||||||
|
StringBuilder sb = new StringBuilder(raw.length() + 2);
|
||||||
|
sb.append('"');
|
||||||
|
for (int i = 0; i < raw.length(); i++) {
|
||||||
|
char c = raw.charAt(i);
|
||||||
|
switch (c) {
|
||||||
|
case '"': sb.append("\\\""); break;
|
||||||
|
case '\\': sb.append("\\\\"); break;
|
||||||
|
case '\n': sb.append("\\n"); break;
|
||||||
|
case '\r': sb.append("\\r"); break;
|
||||||
|
case '\t': sb.append("\\t"); break;
|
||||||
|
default:
|
||||||
|
if (c < 0x20) {
|
||||||
|
sb.append(String.format("\\u%04x", (int) c));
|
||||||
|
} else {
|
||||||
|
sb.append(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sb.append('"');
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<ChatMessage> toDomainMessages(List<ChatMessageDTO> dtos) {
|
||||||
|
if (dtos == null) return List.of();
|
||||||
|
return dtos.stream()
|
||||||
|
.map(dto -> new ChatMessage(dto.getRole(), dto.getContent()))
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package com.loremind.infrastructure.web.controller;
|
||||||
|
|
||||||
|
import com.loremind.application.generationcontext.GeneratePageValuesUseCase;
|
||||||
|
import com.loremind.domain.generationcontext.ports.AiProviderException;
|
||||||
|
import com.loremind.infrastructure.web.dto.generationcontext.GenerationSuggestionsDTO;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* REST Controller pour la génération IA d'une Page.
|
||||||
|
*
|
||||||
|
* POST /api/pages/{id}/generate → suggestions de valeurs (non persistées)
|
||||||
|
*
|
||||||
|
* Endpoint séparé de PageController par SRP : il expose le GenerationContext,
|
||||||
|
* pas le CRUD de Page. URL RESTful conservée (action sur une ressource Page).
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/pages")
|
||||||
|
public class PageGenerationController {
|
||||||
|
|
||||||
|
private final GeneratePageValuesUseCase generatePageValuesUseCase;
|
||||||
|
|
||||||
|
public PageGenerationController(GeneratePageValuesUseCase generatePageValuesUseCase) {
|
||||||
|
this.generatePageValuesUseCase = generatePageValuesUseCase;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Demande à l'IA de suggérer des valeurs pour les champs dynamiques
|
||||||
|
* d'une Page. Ne modifie PAS la Page en base.
|
||||||
|
*
|
||||||
|
* Codes retour :
|
||||||
|
* 200 — suggestions renvoyées
|
||||||
|
* 404 — page introuvable
|
||||||
|
* 422 — template sans champs (rien à générer)
|
||||||
|
* 502 — Brain Python indisponible ou en erreur
|
||||||
|
* 500 — incohérence BDD (template/lore/dossier introuvable)
|
||||||
|
*/
|
||||||
|
@PostMapping("/{id}/generate")
|
||||||
|
public ResponseEntity<GenerationSuggestionsDTO> generate(@PathVariable String id) {
|
||||||
|
try {
|
||||||
|
Map<String, String> values = generatePageValuesUseCase.execute(id);
|
||||||
|
return ResponseEntity.ok(new GenerationSuggestionsDTO(values));
|
||||||
|
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
// Page introuvable — faute de l'appelant
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
|
||||||
|
} catch (AiProviderException e) {
|
||||||
|
// Brain down / timeout / réponse invalide
|
||||||
|
return ResponseEntity.status(HttpStatus.BAD_GATEWAY).build();
|
||||||
|
|
||||||
|
} catch (IllegalStateException e) {
|
||||||
|
// Distinction fine : template sans champs (422) vs autre incohérence BDD (500)
|
||||||
|
if (e.getMessage() != null && e.getMessage().contains("aucun champ")) {
|
||||||
|
return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY).build();
|
||||||
|
}
|
||||||
|
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package com.loremind.infrastructure.web.dto.generationcontext;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO HTTP pour un message d'une conversation.
|
||||||
|
* Rôles acceptés : "user", "assistant", "system".
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class ChatMessageDTO {
|
||||||
|
|
||||||
|
private String role;
|
||||||
|
private String content;
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package com.loremind.infrastructure.web.dto.generationcontext;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO HTTP de requête pour POST /api/ai/chat/stream.
|
||||||
|
*
|
||||||
|
* Le Core charge lui-même le Structural Context à partir de {loreId}.
|
||||||
|
* Le frontend envoie uniquement l'historique de la conversation éphémère.
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
public class ChatStreamRequestDTO {
|
||||||
|
|
||||||
|
private String loreId;
|
||||||
|
/**
|
||||||
|
* Optionnel : si fourni, l'IA reçoit aussi un PageContext focalisé sur
|
||||||
|
* cette page précise (titre, template, champs, valeurs actuelles).
|
||||||
|
* Sans pageId, le chat reste générique au Lore.
|
||||||
|
*/
|
||||||
|
private String pageId;
|
||||||
|
private List<ChatMessageDTO> messages;
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package com.loremind.infrastructure.web.dto.generationcontext;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO exposé au frontend pour la génération IA d'une Page.
|
||||||
|
* Format : { "values": { "fieldName1": "suggestion1", ... } }
|
||||||
|
*
|
||||||
|
* On renvoie la map telle quelle — pas de tri, pas de filtrage côté serveur.
|
||||||
|
* L'UI décide comment merger avec les valeurs déjà saisies par l'utilisateur.
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class GenerationSuggestionsDTO {
|
||||||
|
|
||||||
|
private Map<String, String> values;
|
||||||
|
}
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
# Configuration du serveur
|
# Configuration du serveur
|
||||||
server.port=8080
|
server.port=8080
|
||||||
|
|
||||||
|
# On garde Tomcat (Web MVC) comme serveur primaire, malgré la présence
|
||||||
|
# de WebFlux (utilisé uniquement pour WebClient côté adapter SSE vers le Brain).
|
||||||
|
spring.main.web-application-type=servlet
|
||||||
|
|
||||||
# Configuration de la base de données PostgreSQL
|
# Configuration de la base de données PostgreSQL
|
||||||
spring.datasource.url=jdbc:postgresql://localhost:5432/loremind
|
spring.datasource.url=jdbc:postgresql://localhost:5432/loremind
|
||||||
spring.datasource.username=ietm64
|
spring.datasource.username=ietm64
|
||||||
@@ -18,3 +22,7 @@ spring.web.cors.allowed-origins=http://localhost:4200
|
|||||||
spring.web.cors.allowed-methods=GET,POST,PUT,DELETE,OPTIONS
|
spring.web.cors.allowed-methods=GET,POST,PUT,DELETE,OPTIONS
|
||||||
spring.web.cors.allowed-headers=*
|
spring.web.cors.allowed-headers=*
|
||||||
spring.web.cors.allow-credentials=true
|
spring.web.cors.allow-credentials=true
|
||||||
|
|
||||||
|
# Configuration du Brain (service IA Python)
|
||||||
|
brain.base-url=http://localhost:8000
|
||||||
|
brain.timeout-seconds=120
|
||||||
|
|||||||
254
docs/plan.md
254
docs/plan.md
@@ -213,11 +213,204 @@ Tous des `List<String>` d'IDs de LoreNode :
|
|||||||
- [ ] Composant Angular réutilisable : `app-lore-link-picker` (autocomplete + liste de chips)
|
- [ ] Composant Angular réutilisable : `app-lore-link-picker` (autocomplete + liste de chips)
|
||||||
- [ ] Endpoint `GET /api/lore-nodes?ids=a,b,c` (résolution multi-IDs) côté Java
|
- [ ] Endpoint `GET /api/lore-nodes?ids=a,b,c` (résolution multi-IDs) côté Java
|
||||||
|
|
||||||
#### Phase 3 : Backend Python (Priorité basse - PLUS TARD)
|
#### Phase 3 : Backend Python — Brain IA (DÉMARRÉE le 19 avril 2026)
|
||||||
- [ ] Initialisation de la structure
|
|
||||||
- [ ] Configuration Ollama en local
|
Stack retenue : **FastAPI + Ollama local + Architecture Hexagonale** (Ports/Adapters via `Protocol` PEP 544). Le Brain est l'executor cognitif : il reçoit des demandes du Core Java, construit un prompt, appelle un LLM et renvoie un résultat structuré. Le Core Java reste le chef d'orchestre (règle `.windsurfrules` ligne 21).
|
||||||
- [ ] Adaptateur OllamaProvider
|
|
||||||
- [ ] Routes API pour la génération
|
##### Étape b1 — Squelette FastAPI ✅
|
||||||
|
- [x] Structure `brain/app/` + venv + `requirements.txt` minimal (fastapi, uvicorn).
|
||||||
|
- [x] Endpoint `GET /health` (sonde de vie) et Swagger UI auto sur `/docs`.
|
||||||
|
- [x] `.gitignore` (venv, __pycache__, .env).
|
||||||
|
|
||||||
|
##### Étape b2 — Refactor hexagonal ✅
|
||||||
|
- [x] Config Pydantic Settings (`.env.example` + `app/core/config.py` + `get_settings()` singleton via `@lru_cache`).
|
||||||
|
- [x] Port `LLMProvider` (Protocol PEP 544) + exception domaine `LLMProviderError` dans `app/domain/ports.py`.
|
||||||
|
- [x] Adapter `OllamaLLMProvider` dans `app/infrastructure/ollama_adapter.py` — isole tout le code `httpx` et le protocole Ollama.
|
||||||
|
- [x] Factory `get_llm_provider()` dans `main.py` = **unique point d'inversion de dépendance** (changer d'1 ligne pour switch vers OpenAI/Claude demain).
|
||||||
|
- [x] Controller `POST /generate` fin : reçoit le port via `Depends`, ignore l'Adapter concret.
|
||||||
|
- [x] Fiche academy `docs/academy/hexagonal-python.md` (théorie + analogie JDR + Python vs Java + quiz 5 QCM).
|
||||||
|
- Modèle par défaut : `gemma4:e2b` (validé avec sortie "Borin le forgeron nain" en ~2s). Swap possible via `LLM_MODEL` dans `.env`.
|
||||||
|
|
||||||
|
##### Étape b3 — Génération structurée JSON ✅
|
||||||
|
- [x] **b3.1** — Modèles de domaine `PageGenerationContext` / `PageGenerationResult` en `@dataclass(frozen=True)` dans `app/domain/models.py` (domaine sans dépendance Pydantic).
|
||||||
|
- [x] **b3.1** — Port `LLMProvider.generate()` enrichi d'un kwarg `output_format: str | None` (pass-through vers `format: "json"` d'Ollama).
|
||||||
|
- [x] **b3.2** — Use case `GeneratePageUseCase` dans `app/application/generate_page.py` : construction prompt système (français, orienté MJ de JDR) + appel LLM avec `output_format="json"` + parsing JSON défensif (filtrage sur `template_fields`, champs manquants → chaîne vide, cast `str` systématique).
|
||||||
|
- [x] **b3.3** — Endpoint `POST /generate-page` + DTOs Pydantic `GeneratePageRequestDTO` / `GeneratePageResponseDTO` en frontière HTTP + factory `get_generate_page_use_case()` pour l'injection du use case.
|
||||||
|
|
||||||
|
##### Étape b4 — Branchement Core Java ↔ Brain ✅ (19 avril 2026, soir)
|
||||||
|
|
||||||
|
> ✅ **Chaîne complète opérationnelle** : clic "Assistant IA" dans `page-edit` → Angular → Java (`PageGenerationController` → `GeneratePageValuesUseCase` → `BrainAiClient`) → Python (`/generate-page`) → Ollama → retour JSON → merge dans les textareas. Zéro persistance côté génération, l'utilisateur valide et sauvegarde manuellement. Une nouvelle fiche academy `docs/academy/bounded-context.md` a été ajoutée pour formaliser le 3ᵉ Bounded Context (GenerationContext).
|
||||||
|
>
|
||||||
|
> **Démarrage de la stack complète pour tester :**
|
||||||
|
> ```bash
|
||||||
|
> # Terminal 1 — Ollama (normalement déjà en service système)
|
||||||
|
> # http://localhost:11434, modèle `gemma4:e2b` tiré
|
||||||
|
>
|
||||||
|
> # Terminal 2 — Brain Python
|
||||||
|
> cd brain && source .venv/Scripts/activate && uvicorn app.main:app --reload --port 8000
|
||||||
|
>
|
||||||
|
> # Terminal 3 — Core Java
|
||||||
|
> cd core && mvn spring-boot:run
|
||||||
|
>
|
||||||
|
> # Terminal 4 — Frontend Angular
|
||||||
|
> cd web && npm start
|
||||||
|
> ```
|
||||||
|
|
||||||
|
###### b4.1 — Domaine DDD côté Java (GenerationContext) ✅
|
||||||
|
- [x] Package `domain/generationcontext/` créé.
|
||||||
|
- [x] `GenerationContext` (immuable `@Value @Builder`) + `GenerationResult` (immuable `@Value`).
|
||||||
|
- [x] Port `AiProvider` (interface pure, zéro annotation Spring) + exception domaine `AiProviderException` (RuntimeException).
|
||||||
|
|
||||||
|
###### b4.2 — Adapter HTTP Java → Brain Python ✅
|
||||||
|
- [x] `BrainAiClient` dans `infrastructure/ai/` — implémente `AiProvider`.
|
||||||
|
- [x] **Revirement technique assumé** : `RestTemplate` retenu plutôt que `WebClient`. Raisons : déjà présent dans `spring-boot-starter-web` (zéro nouvelle dépendance), usage synchrone suffisant pour un bouton "Assistant IA" (pas de streaming au MVP), plus simple à lire pour un développeur qui n'a pas encore rencontré le paradigme réactif Reactor. Le passage à `WebClient` reste possible sans toucher au domaine (c'est précisément le bénéfice de l'hexagonal).
|
||||||
|
- [x] Config `brain.base-url` + `brain.timeout-seconds` dans `application.properties`, bean `RestTemplate` dédié dans `RestTemplateConfig` (connect 10s, read 120s).
|
||||||
|
- [x] DTOs package-private `BrainGeneratePageRequest` / `BrainGeneratePageResponse` avec `@JsonProperty` snake_case (pas de config Jackson globale — isolation au package `infrastructure/ai`).
|
||||||
|
- [x] Traduction d'erreurs : `ResourceAccessException` (timeout/Brain down) / `RestClientResponseException` (4xx/5xx) → `AiProviderException`. Filet de sécurité pour toute autre `Exception`.
|
||||||
|
|
||||||
|
###### b4.3 — Use case Java `GeneratePageValuesUseCase` ✅
|
||||||
|
- [x] `application/generationcontext/GeneratePageValuesUseCase.java` — injection constructeur des 5 ports (PageRepository, TemplateRepository, LoreRepository, LoreNodeRepository, AiProvider).
|
||||||
|
- [x] `execute(pageId)` : 4 lookups (Page → Template → Lore → LoreNode), validation template.fields non-vide, construction du `GenerationContext`, appel `AiProvider.generatePage`, retour direct de `result.values`.
|
||||||
|
- [x] **Décision produit respectée** : zéro persistance. Exceptions différenciées : `IllegalArgumentException` (page introuvable) vs `IllegalStateException` (incohérence BDD ou template sans champs).
|
||||||
|
|
||||||
|
###### b4.4 — REST endpoint Java ✅
|
||||||
|
- [x] **Écart assumé vs plan initial** : endpoint placé dans un nouveau `PageGenerationController` dédié (pas dans `PageController`) — SRP strict, alignement sur le Bounded Context `generationcontext`. URL RESTful conservée : `POST /api/pages/{id}/generate`.
|
||||||
|
- [x] DTO `GenerationSuggestionsDTO { Map<String, String> values }` dans `infrastructure/web/dto/generationcontext/`.
|
||||||
|
- [x] Gestion d'erreurs inline (pas d'`@ControllerAdvice` — cohérent avec le style du reste du projet) : 200, 404, 422 (template sans champs, détecté sur le message), 502 (`AiProviderException`), 500 (autre `IllegalStateException`).
|
||||||
|
|
||||||
|
###### b4.5 — Frontend Angular : bouton "Assistant IA" branché ✅
|
||||||
|
- [x] `PageService.generateValues(pageId)` → `POST /api/pages/{id}/generate`, retourne `Record<string, string>`.
|
||||||
|
- [x] `page-edit` : état `aiLoading` + `aiError`, méthode `runAssistantAI()`, libellé du bouton qui passe à "Génération…" pendant l'appel, bouton désactivé si template sans champs ou appel en cours.
|
||||||
|
- [x] Merge soft simplifié : toute suggestion non-vide écrase (l'utilisateur a demandé la régénération), suggestion vide laisse la valeur courante intacte.
|
||||||
|
- [x] Banner d'erreur dismissable au-dessus du formulaire : message différencié 502 (Brain down) vs autre.
|
||||||
|
- [x] Pas de sauvegarde auto — l'utilisateur valide et clique "Sauvegarder" pour persister via le PUT existant.
|
||||||
|
|
||||||
|
###### b4.6 — Améliorations post-MVP (backlog, pas bloquant)
|
||||||
|
- [ ] Température LLM configurable côté UI (slider "créativité" mappé sur le paramètre `temperature` d'Ollama).
|
||||||
|
- [ ] Historique des générations par page (BDD côté Java + vue "revert to previous suggestion").
|
||||||
|
- [ ] Retry automatique avec backoff côté `BrainAiClient` en cas d'erreur transitoire.
|
||||||
|
- [ ] Prompt personnalisable par Lore (ex: ton "sombre et épique" vs "aventure familiale") — stocké sur l'entité `Lore` côté Java, transmis dans le `GenerationContext`.
|
||||||
|
|
||||||
|
##### Étape b5 — Chat IA conversationnel avec Structural Context ✅ (19 avril 2026, soir)
|
||||||
|
|
||||||
|
> ✅ **UX "IA qui écrit sous tes yeux"** livrée de bout en bout. Drawer chat à droite de `page-edit` (pattern validé sur les maquettes `lore/Assistance IA dans une page.png` et `campagne/Assistance IA.png`). L'IA voit la structure du Lore (dossiers, pages, templates, tags) sans recevoir le contenu. Conversation éphémère (perdue à la fermeture du drawer). Intégration limitée au Lore pour l'instant — Campagne viendra quand on voudra l'étendre.
|
||||||
|
|
||||||
|
###### b5.1 — Backend Python : endpoint `/chat/stream` SSE ✅
|
||||||
|
- [x] Dataclasses `ChatMessage` + `LoreStructuralContext` dans `domain/models.py` (immuables, sans Pydantic).
|
||||||
|
- [x] Protocol `LLMChatProvider` dans `domain/ports.py` — distinct de `LLMProvider` par ISP (Interface Segregation Principle). `OllamaLLMProvider` satisfait les deux par duck typing.
|
||||||
|
- [x] `OllamaLLMProvider.stream_chat()` : consomme `/api/chat` d'Ollama en mode `stream=True`, parse le NDJSON ligne par ligne, yield les tokens non-vides. Le formatage SSE est la responsabilité du controller, pas de l'adapter.
|
||||||
|
- [x] `ChatUseCase` dans `application/chat.py` : construit un system prompt riche avec le Structural Context (carte des dossiers/pages/templates/tags), délègue au port.
|
||||||
|
- [x] Endpoint `POST /chat/stream` avec `StreamingResponse(media_type="text/event-stream")`. Format de flux : `data: {"token":"..."}`, `event: done`, `event: error`.
|
||||||
|
|
||||||
|
###### b5.2 — Core Java : port `AiChatProvider` + adapter `WebClient` SSE ✅
|
||||||
|
- [x] Ajout de `spring-boot-starter-webflux` au `pom.xml` (requis pour `WebClient`, seul outil Spring capable de consommer SSE côté client) + `spring.main.web-application-type=servlet` dans `application.properties` pour forcer Tomcat malgré WebFlux.
|
||||||
|
- [x] Domaine `generationcontext/` : `ChatMessage`, `LoreStructuralContext` (+ inner `FolderPage`), `ChatRequest`, port `AiChatProvider`.
|
||||||
|
- [x] **Choix pédagogique : API par callbacks** (`Consumer<String> onToken`, `Runnable onComplete`, `Consumer<Throwable> onError`) plutôt que `Flux<String>`. Raisons : zéro dépendance Reactor dans le domaine, plus simple à comprendre pour un développeur qui n'a pas rencontré le paradigme réactif, mappage naturel vers `SseEmitter` côté controller.
|
||||||
|
- [x] Adapter `BrainAiChatClient` : `WebClient.retrieve().bodyToFlux(ServerSentEvent)`, dispatch `doOnNext` → callbacks, `blockLast()` pour rester synchrone, timeout 120s, traduction d'erreurs en `AiProviderException`.
|
||||||
|
|
||||||
|
###### b5.3 — Core Java : endpoint `POST /api/ai/chat/stream` SSE ✅
|
||||||
|
- [x] Use case `StreamChatForLoreUseCase` dans `application/generationcontext/` : charge `Lore` + `LoreNode[]` + `Page[]` + `Template[]` (4 lookups), construit le `LoreStructuralContext`, délègue au port. 4 ports injectés côté LoreContext + 1 côté GenerationContext.
|
||||||
|
- [x] `AiChatController` expose `POST /api/ai/chat/stream` (`produces = text/event-stream`). Le streaming tourne dans un thread séparé via `AsyncTaskExecutor` pour ne pas bloquer le servlet. `SseEmitter` (timeout 5 min) thread-safe : les callbacks du port peuvent écrire dessus depuis n'importe quel thread.
|
||||||
|
- [x] DTOs `ChatMessageDTO` + `ChatStreamRequestDTO` dans `infrastructure/web/dto/generationcontext/`. Helper `jsonEscape()` interne (pas de pull Jackson ici).
|
||||||
|
|
||||||
|
###### b5.4 — Frontend : composant `app-ai-chat-drawer` ✅
|
||||||
|
- [x] Service `AiChatService.streamChat()` dans `web/src/app/services/ai-chat.service.ts` : `fetch()` + `ReadableStream` + décodage ligne-par-ligne SSE. Retourne un `Observable<ChatStreamEvent>` qui emit `{type:'token'}` par fragment, complete sur `event: done`, error sur `event: error` ou échec réseau. Annule proprement via `AbortController` à l'unsubscribe.
|
||||||
|
- [x] **Pas d'`EventSource`** : l'API navigateur native ne supporte que GET sans body — on a besoin de POST avec JSON (messages + loreId).
|
||||||
|
- [x] Composant standalone `AiChatDrawerComponent` dans `shared/ai-chat-drawer/` : `@Input` `loreId`/`isOpen`/`welcomeMessage`/`quickSuggestions[]`/`primaryAction`, `@Output` `close`/`primaryActionClick`. État local : `messages[]`, `currentAssistantText` (buffer de streaming), `isStreaming`, `errorMessage`. Conversation éphémère perdue à la fermeture (choix MVP assumé).
|
||||||
|
- [x] UX fidèle aux maquettes : bulles user (droite violet) / assistant (gauche sombre), welcome message, typing-indicator avant le premier token, caret clignotant pendant le streaming, suggestions rapides en bas, input + bouton envoyer.
|
||||||
|
|
||||||
|
###### b5.5 — Intégration dans `page-edit` ✅
|
||||||
|
- [x] Bouton "Assistant IA" du header change de rôle : il ouvre désormais le drawer (`toggleChat()`) au lieu d'appeler le one-shot directement.
|
||||||
|
- [x] Le one-shot b4 reste accessible via `primaryAction` du drawer (bouton violet pleine largeur "Remplir automatiquement tous les champs") : clic → ferme le drawer + déclenche `runAssistantAI()` → textareas se remplissent. Le meilleur des deux mondes, sans duplication de code.
|
||||||
|
- [x] Suggestions rapides hardcodées (MVP) : "Étoffe l'histoire", "Suggère des liens avec d'autres pages du Lore", "Propose une intrigue secondaire".
|
||||||
|
- [x] Bouton "Assistant IA" stylé `active` quand le drawer est ouvert (fond gris + bordure violette) — feedback visuel clair.
|
||||||
|
|
||||||
|
###### b5.6 — Fiche academy `streaming-sse-rag.md` ✅
|
||||||
|
- [x] Théorie : SSE vs WebSocket vs polling avec analogie JDR (pigeon voyageur qui revient par fragments).
|
||||||
|
- [x] Structural Context vs RAG sémantique : pourquoi on n'a PAS encore de DB vectorielle.
|
||||||
|
- [x] Code réel extrait des 3 étages de la stack (Python / Java / Angular).
|
||||||
|
- [x] Section "Le savais-tu ?" sur la nature debug-friendly du format SSE (`curl -N` suffit).
|
||||||
|
- [x] Quiz 5 QCM.
|
||||||
|
|
||||||
|
###### b5.7 — À faire plus tard (étendre au reste de l'app)
|
||||||
|
- [ ] Intégration du drawer dans `arc-edit`, `chapter-edit`, `scene-edit` (Campagne). Nécessite un nouveau port `AiChatProvider.streamChatForCampaign(campaignId, messages)` qui charge Campagne courante + Lore associé (asymétrie demandée : une Campagne voit son Lore, un Lore ne voit PAS ses campagnes).
|
||||||
|
- [ ] Persistance optionnelle de la conversation (entité `Conversation` côté Java, historique reprenable).
|
||||||
|
|
||||||
|
##### Étape b6 — IA dans la création de page (wizard) ✅ (20 avril 2026, nuit)
|
||||||
|
|
||||||
|
> ✅ **Mode wizard livré.** Sur `page-create`, bouton "✨ Créer avec l'IA" à côté du "Créer la page" classique. Au clic, le drawer chat s'ouvre avec un prompt système contextualisé au template (nom + liste exacte des champs + règles de cohérence) qui force l'IA à terminer chaque réponse par un bloc JSON `<values>{...}</values>`. L'utilisateur dialogue jusqu'à être satisfait puis clique "Appliquer et créer la page" → la page est créée en 2 étapes (POST coquille + PUT values) et navigation vers l'édition.
|
||||||
|
|
||||||
|
###### b6.1 — Flux côté `page-create` ✅
|
||||||
|
- [x] Bouton "✨ Créer avec l'IA" ajouté entre "Annuler" et "Créer la page". Désactivé tant que titre + template + dossier ne sont pas renseignés (même `canSubmit` que le bouton classique).
|
||||||
|
- [x] Au clic : le drawer s'ouvre (`chatOpen = true`) avec un `welcomeMessage` contextualisé : *"Super, on va créer une page 'PNJ' ! Décrivez-la-moi en quelques mots…"* (généré dynamiquement à partir du nom du template choisi).
|
||||||
|
- [x] Création en 2 étapes (POST puis PUT) pour appliquer les `values` — le backend `POST /api/pages` n'accepte pas encore `values` en payload. Choix pragmatique (zéro modification backend) documenté dans le code.
|
||||||
|
- [x] Erreurs gérées : *"L'assistant n'a pas encore répondu"*, *"Impossible d'extraire les valeurs"*, *"Page créée mais impossible d'appliquer les valeurs"*. Affichées en banner rouge sous le formulaire.
|
||||||
|
|
||||||
|
###### b6.2 — Parsing JSON de la réponse assistant ✅
|
||||||
|
- [x] Le system prompt wizard (construit côté Angular dans `page-create.component.ts` getter `wizardSystemPrompt`) demande à l'IA de terminer CHAQUE réponse par un bloc `<values>{...}</values>` avec les clés exactes du template.
|
||||||
|
- [x] Parsing côté Angular : regex `/<values>\s*([\s\S]*?)\s*<\/values>/i` + `JSON.parse` avec try/catch + coercion des valeurs non-string en string.
|
||||||
|
- [x] Chaque fin de réponse assistant alimente `lastWizardReply` via le nouveau `@Output() assistantReply` du drawer.
|
||||||
|
|
||||||
|
###### b6.3 — Évolutions du drawer (réutilisable) ✅
|
||||||
|
- [x] Nouveau `@Input() systemPromptAddon: string | null` — injecté comme message `role: 'system'` **invisible côté UI** en tête du payload envoyé au backend à chaque tour. Permet au parent de contextualiser la conversation sans polluer l'historique visuel.
|
||||||
|
- [x] Nouveau `@Output() assistantReply = EventEmitter<string>()` — émis à chaque complétion d'un message assistant. Le parent l'utilise pour extraire le bloc `<values>` du wizard.
|
||||||
|
- [x] Le composant reste **unique** (pas de composant "wizard" séparé) : la config se fait entièrement via inputs/outputs. SRP respecté (le drawer ne connaît rien du wizard, juste du chat).
|
||||||
|
|
||||||
|
##### Étape b7 — Anti-hallucination ✅ (20 avril 2026, nuit)
|
||||||
|
|
||||||
|
> ✅ **Nuance centrale encodée** : l'IA peut (et doit) inventer des éléments originaux, mais ne peut pas faire référence à des éléments du Lore comme s'ils existaient si on ne les lui a pas montrés. Appliqué via température abaissée + system prompts durcis.
|
||||||
|
|
||||||
|
###### b7.1 — Température configurable par use case ✅
|
||||||
|
- [x] `LLMProvider.generate()` et `LLMChatProvider.stream_chat()` enrichis d'un kwarg `temperature: float | None = None` dans `brain/app/domain/ports.py` (docstring explicite la recommandation LoreMind).
|
||||||
|
- [x] `OllamaLLMProvider` propage via `options.temperature` dans les payloads `/api/generate` et `/api/chat` (la clé `options` est la convention Ollama pour les hyperparamètres).
|
||||||
|
- [x] `GeneratePageUseCase` : constante `_DEFAULT_TEMPERATURE = 0.4` (remplissage factuel, peu créatif).
|
||||||
|
- [x] `ChatUseCase` : constante `_DEFAULT_TEMPERATURE = 0.7` (conversation créative mais cohérente).
|
||||||
|
|
||||||
|
###### b7.2 — System prompts durcis ✅
|
||||||
|
- [x] `ChatUseCase._BASE_SYSTEM` : section "Règles de cohérence (IMPORTANT)" ajoutée avec la nuance centrale (✅ inventer OK, ❌ référencer l'inexistant KO, ❌ dates/chiffres précis inventés).
|
||||||
|
- [x] `GeneratePageUseCase._SYSTEM_INSTRUCTIONS` : même section, adaptée au remplissage factuel. Invite explicitement à rester vague (*"il y a longtemps"*, *"un bourg voisin"*) quand une précision externe manque plutôt que d'inventer.
|
||||||
|
|
||||||
|
###### b7.3 — DTO `temperature` optionnel — REPORTÉ au backlog
|
||||||
|
- [ ] Exposition du paramètre dans les DTOs Pydantic `/generate-page` et `/chat/stream` (override depuis Java/front). **Non fait volontairement** — YAGNI tant que personne ne demande l'override. Les constantes des use cases suffisent. À ajouter quand un slider "créativité" apparaîtra côté UI (backlog b4.6).
|
||||||
|
|
||||||
|
##### Étape b8 — Contextualisation page courante (serveur) ✅ (20 avril 2026, nuit)
|
||||||
|
|
||||||
|
> ✅ **PageContext injecté côté backend**. Sur `page-edit`, le drawer transmet le `pageId` → le Core charge la Page + son Template et construit un `PageContext` (titre, template, champs, valeurs actuelles) → envoyé en `page_context` au Brain Python → injecté dans le system prompt comme bloc "PAGE EN COURS D'ÉDITION" avec instruction de focalisation exclusive. L'IA ne déborde plus sur d'autres pages/templates.
|
||||||
|
|
||||||
|
###### b8.1 — Brain Python ✅
|
||||||
|
- [x] Nouveau dataclass `PageContext` dans `domain/models.py` (title, template_name, template_fields, values).
|
||||||
|
- [x] `ChatUseCase.stream()` accepte un `page_context: PageContext | None = None` optionnel. Rétro-compat totale (sans argument = comportement b5).
|
||||||
|
- [x] `_build_system_prompt` ajoute un bloc "--- PAGE EN COURS D'ÉDITION ---" quand `page_context` est fourni, listant titre + template + champs + valeurs actuelles + instruction de focalisation exclusive.
|
||||||
|
- [x] DTO Pydantic `PageContextDTO` + champ optionnel `page_context: PageContextDTO | None = None` sur `ChatStreamRequestDTO`. Mapping DTO → domain dans `main.py`.
|
||||||
|
|
||||||
|
###### b8.2 — Core Java ✅
|
||||||
|
- [x] Nouveau value object `PageContext` dans `domain/generationcontext/` (parallèle architectural de `LoreStructuralContext`). Lombok @Value/@Builder.
|
||||||
|
- [x] `ChatRequest` enrichi d'un champ `pageContext` nullable (JavaDoc explicite le "null = chat générique").
|
||||||
|
- [x] `StreamChatForLoreUseCase.execute()` prend désormais un `pageId` nullable. Nouvelle méthode privée `buildPageContext(pageId)` charge Page + Template via les repos existants (zéro nouveau port). Gestion des pages orphelines (template absent → PageContext minimal sans champs, pas d'exception).
|
||||||
|
- [x] `BrainAiChatClient.toPayload()` sérialise `page_context` en snake_case uniquement si fourni (payload léger par défaut).
|
||||||
|
- [x] DTO `ChatStreamRequestDTO` gagne un `pageId` optionnel + `AiChatController` le propage au use case.
|
||||||
|
|
||||||
|
###### b8.3 — Angular ✅
|
||||||
|
- [x] `AiChatService.streamChat(loreId, messages, pageId?)` : nouveau 3ᵉ argument optionnel, inclus dans le payload JSON uniquement s'il est truthy.
|
||||||
|
- [x] `AiChatDrawerComponent` gagne un `@Input() pageId: string | null = null` propagé au service.
|
||||||
|
- [x] `page-edit.component.html` passe `[pageId]="pageId"` au drawer — le composant avait déjà cet ID (depuis la route).
|
||||||
|
- [x] `page-create` (wizard b6) **volontairement** ne passe pas de `pageId` : la page n'existe pas encore. Le wizard continue de fonctionner via son `systemPromptAddon` sans aucune modification — les deux mécanismes cohabitent proprement.
|
||||||
|
|
||||||
|
###### b7.4 — Fiche academy `anti-hallucination.md` ✅
|
||||||
|
- [x] Théorie : pourquoi les LLM hallucinent (prédiction probabiliste sans vérification).
|
||||||
|
- [x] Analogie JDR : le "MJ qui improvise" — improvisation créative (OK, nouveau PNJ) vs improvisation incohérente (KO, référence à un PNJ inexistant comme session 2).
|
||||||
|
- [x] Les 5 leviers détaillés (température, prompt strict, contexte riche, modèle plus gros, chain-of-thought) avec tableau coût/effet.
|
||||||
|
- [x] Application concrète à LoreMind : ce qu'on a retenu (1+2+3), ce qu'on garde en backlog (4+5).
|
||||||
|
- [x] Section "Le savais-tu ?" sur le fait que `temperature=0` n'est pas 100% déterministe (parallélisme GPU).
|
||||||
|
- [x] Quiz 5 QCM.
|
||||||
|
|
||||||
|
##### Dette technique Brain (non bloquante, à reprendre plus tard)
|
||||||
|
- [ ] **Client `httpx` réutilisé** via FastAPI `lifespan` — actuellement un nouveau client est créé à chaque requête dans `OllamaLLMProvider.generate`. Impact : pool de connexions perdu entre requêtes. À corriger quand le débit augmente.
|
||||||
|
- [ ] **Tests pytest** : créer un `FakeLLMProvider` et tester `GeneratePageUseCase` en isolation. L'hexagonal a été mis en place précisément pour ça — il serait dommage de ne pas en tirer parti.
|
||||||
|
- [ ] **Logging structuré** (`loguru` ou `logging` standard avec `JsonFormatter`) à la place des prints implicites pour faciliter le debug en conditions réelles.
|
||||||
|
- [ ] **Endpoint `GET /info`** exposant le modèle actuellement configuré (utile pour diagnostiquer "ce que voit le serveur" sans SSHer dans le container).
|
||||||
|
- [ ] **Validation Pydantic** plus stricte : `max_length` sur `prompt`, `max_items` sur `template_fields` (ex: 20 max), longueur du `page_title`.
|
||||||
|
- [ ] **Gestion `output_format` autres que `"json"`** : aujourd'hui on passe la valeur brute à Ollama. Si le Brain doit supporter un adapter qui ne comprend que certains formats, valider côté port.
|
||||||
|
|
||||||
## Structure des dossiers
|
## Structure des dossiers
|
||||||
|
|
||||||
@@ -285,6 +478,57 @@ Ces points sont à garder en tête pour de futures refactorisations. Pas bloquan
|
|||||||
- **Gestion des migrations DB** — actuellement `spring.jpa.hibernate.ddl-auto=update` (auto-alter). Acceptable en dev, **inutilisable en prod** (perte de données possible). À remplacer par Flyway ou Liquibase avant la mise en prod (chaque changement de schéma devra être versionné en fichier SQL).
|
- **Gestion des migrations DB** — actuellement `spring.jpa.hibernate.ddl-auto=update` (auto-alter). Acceptable en dev, **inutilisable en prod** (perte de données possible). À remplacer par Flyway ou Liquibase avant la mise en prod (chaque changement de schéma devra être versionné en fichier SQL).
|
||||||
|
|
||||||
## Dernière mise à jour
|
## Dernière mise à jour
|
||||||
|
20 avril 2026 (nuit, session 2) — **Phase 3 étape b8 bouclée : contextualisation page courante injectée côté serveur**.
|
||||||
|
|
||||||
|
**PageContext serveur (b8.1 → b8.3)** :
|
||||||
|
- **Python** : nouveau dataclass `PageContext`, `ChatUseCase.stream()` accepte un param optionnel, system prompt gagne un bloc "PAGE EN COURS D'ÉDITION" avec instruction de focalisation exclusive. DTO Pydantic ajouté.
|
||||||
|
- **Java** : value object `PageContext` symétrique au `LoreStructuralContext`. `StreamChatForLoreUseCase` accepte un `pageId` nullable, charge Page + Template via les ports existants (zéro nouveau port). `BrainAiChatClient` sérialise en snake_case. DTO + controller propagent.
|
||||||
|
- **Angular** : service + drawer enrichis d'un `pageId?` optionnel. `page-edit` le passe au drawer. Le wizard de `page-create` ne le passe PAS (page inexistante) — les deux mécanismes (systemPromptAddon pour wizard, pageId pour édition) cohabitent proprement.
|
||||||
|
- **Résout** le bug *"l'IA propose des idées pour d'autres templates"* : maintenant l'IA reçoit explicitement le template + ses champs + les valeurs actuelles de la page éditée, avec injonction de ne pas déborder.
|
||||||
|
|
||||||
|
20 avril 2026 (nuit) — **Phase 3 étapes b6 + b7 bouclées : wizard création de page + anti-hallucination**.
|
||||||
|
|
||||||
|
**Wizard création de page (b6.1 → b6.3)** :
|
||||||
|
- **page-create** : bouton "✨ Créer avec l'IA" à côté du "Créer la page" classique. Au clic, drawer chat en mode wizard avec system prompt contextualisé au template choisi (nom + champs exacts + règle du bloc `<values>` obligatoire en fin de réponse).
|
||||||
|
- **Drawer enrichi** : nouveau `@Input() systemPromptAddon` (message `role:'system'` invisible préfixé au payload à chaque tour) + `@Output() assistantReply` (émis à chaque complétion assistant, alimente le parsing du wizard).
|
||||||
|
- **Parsing `<values>`** : regex + JSON.parse côté Angular, fallback gracieux sur erreur avec messages explicites.
|
||||||
|
- **Création en 2 étapes** (POST coquille + PUT values) — choix pragmatique : zéro modification backend nécessaire.
|
||||||
|
|
||||||
|
**Anti-hallucination (b7.1 → b7.4)** :
|
||||||
|
- **Température différenciée par use case** : `0.4` pour le one-shot factuel, `0.7` pour le chat créatif. Paramètre `temperature` ajouté aux ports `LLMProvider` + `LLMChatProvider`, propagé via `options.temperature` dans les payloads Ollama.
|
||||||
|
- **System prompts durcis** avec la nuance clé (✅ inventer des éléments originaux = OK, ❌ référencer comme existant un élément absent de la carte = KO). Prompt wizard côté Angular + prompts chat/one-shot côté Python Brain.
|
||||||
|
- **Academy** : fiche `docs/academy/anti-hallucination.md` avec analogie JDR (le MJ qui improvise bien vs mal), 5 leviers classés coût/effet, application concrète à LoreMind, quiz 5 QCM.
|
||||||
|
- DTO `temperature` optionnel **reporté au backlog** (YAGNI tant qu'aucune UI d'override n'existe).
|
||||||
|
|
||||||
|
19 avril 2026 (soir, session 3) — **Phase 3 étape b5 bouclée : chat IA conversationnel streamé + Structural Context**.
|
||||||
|
|
||||||
|
**Chat IA conversationnel (b5.1 → b5.6)** :
|
||||||
|
- **Python (b5.1)** : endpoint `POST /chat/stream` (SSE), port `LLMChatProvider` (ISP), use case `ChatUseCase` avec injection du Structural Context dans le system prompt.
|
||||||
|
- **Java domaine/adapter (b5.2)** : port `AiChatProvider` par **callbacks** (choix pédagogique pour éviter Reactor dans le domaine), adapter `BrainAiChatClient` via `WebClient` (ajout de `spring-boot-starter-webflux` au `pom.xml` + `spring.main.web-application-type=servlet` pour rester en Tomcat).
|
||||||
|
- **Java REST (b5.3)** : `AiChatController` expose `POST /api/ai/chat/stream`, streaming dans `AsyncTaskExecutor`, `SseEmitter` thread-safe, use case `StreamChatForLoreUseCase` qui charge Lore+nodes+pages+templates pour construire le Structural Context.
|
||||||
|
- **Angular (b5.4)** : service `AiChatService` avec `fetch()` + `ReadableStream` (pas `EventSource` qui ne supporte que GET), composant standalone réutilisable `AiChatDrawerComponent` avec bulles user/assistant, typing indicator, caret clignotant, suggestions rapides, `primaryAction` optionnelle.
|
||||||
|
- **Intégration page-edit (b5.5)** : bouton "Assistant IA" toggle le drawer, one-shot b4 relocalisé en `primaryAction` ("Remplir automatiquement"). Suggestions rapides hardcodées MVP.
|
||||||
|
- **Academy (b5.6)** : fiche `docs/academy/streaming-sse-rag.md` avec analogie JDR (pigeon voyageur), comparaison Full-dump/Structural/RAG sémantique, code des 3 étages, quiz 5 QCM.
|
||||||
|
- **Restera à étendre (b5.7)** : Campagne (asymétrique), page-create en mode wizard, éventuelle persistance de conversations.
|
||||||
|
|
||||||
|
19 avril 2026 (soir, session 2) — **Phase 3 étape b4 bouclée : chaîne IA de bout en bout opérationnelle**.
|
||||||
|
|
||||||
|
**Branchement Core Java ↔ Brain Python (b4.1 → b4.5)** :
|
||||||
|
- **Domaine** (b4.1) : 3ᵉ Bounded Context `generationcontext` créé (`GenerationContext`, `GenerationResult`, port `AiProvider`, `AiProviderException`). Zéro dépendance technique.
|
||||||
|
- **Adapter HTTP** (b4.2) : `BrainAiClient` + DTOs package-private snake_case + `RestTemplateConfig` (timeout 120s). Choix assumé de `RestTemplate` plutôt que `WebClient` pour la simplicité de lecture. Config `brain.base-url` + `brain.timeout-seconds` dans `application.properties`.
|
||||||
|
- **Use case** (b4.3) : `GeneratePageValuesUseCase` orchestre LoreContext (chargement Page/Template/Lore/LoreNode) et GenerationContext (appel IA). 5 ports injectés, 0 Adapter référencé. Zéro persistance assumée.
|
||||||
|
- **REST** (b4.4) : `POST /api/pages/{id}/generate` dans un `PageGenerationController` dédié (SRP vs colocation dans `PageController`). Gestion d'erreurs : 200/404/422/502/500.
|
||||||
|
- **Frontend** (b4.5) : `PageService.generateValues()` + état `aiLoading`/`aiError` dans `page-edit` + merge soft (écrase sur suggestion non-vide, préserve sur vide) + banner d'erreur dismissable.
|
||||||
|
- **Academy** : nouvelle fiche `docs/academy/bounded-context.md` avec analogie JDR (3 mondes de règles), bénéfices, exemple appliqué et quiz 5 QCM.
|
||||||
|
|
||||||
|
19 avril 2026 (soir) — **Phase 3 démarrée : Brain Python opérationnel jusqu'à b3.2**.
|
||||||
|
|
||||||
|
**Brain LoreMind (`brain/`)** :
|
||||||
|
- Squelette FastAPI + Swagger `/docs` (étape b1).
|
||||||
|
- Architecture hexagonale complète (étape b2) : Port `LLMProvider` (Protocol PEP 544), adapter `OllamaLLMProvider`, controller fin, injection par `Depends`. Fiche academy `docs/academy/hexagonal-python.md` rédigée.
|
||||||
|
- Génération structurée en cours (étape b3) : modèles de domaine `PageGenerationContext` / `PageGenerationResult`, port enrichi d'un kwarg `output_format`, use case `GeneratePageUseCase` avec prompt système français + parsing JSON défensif. Reste l'endpoint HTTP `POST /generate-page` (b3.3) pour exposer le use case.
|
||||||
|
- Validation manuelle : `gemma4:e2b` répond en ~2s sur un prompt simple. Le modèle est swappable via `.env` (`LLM_MODEL`).
|
||||||
|
|
||||||
19 avril 2026 - **Phase 5C en cours : compteurs + breadcrumb livrés**.
|
19 avril 2026 - **Phase 5C en cours : compteurs + breadcrumb livrés**.
|
||||||
|
|
||||||
**Breadcrumb (fil d'Ariane) dans `page-edit`** :
|
**Breadcrumb (fil d'Ariane) dans `page-edit`** :
|
||||||
|
|||||||
@@ -52,15 +52,37 @@
|
|||||||
|
|
||||||
<!-- Aide contextuelle -->
|
<!-- Aide contextuelle -->
|
||||||
<div class="info-box">
|
<div class="info-box">
|
||||||
💡 Une fois créée, vous pourrez remplir les champs du template et utiliser l'Assistant IA pour développer le contenu.
|
💡 Option 1 : <strong>Créer la page</strong> vide, puis remplir les champs manuellement.<br>
|
||||||
|
💡 Option 2 : <strong>Créer avec l'IA</strong> pour dialoguer avec un assistant qui pré-remplira les champs.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Erreur wizard (parsing <values> ou échec HTTP) -->
|
||||||
|
<div class="wizard-error" *ngIf="wizardError" role="alert">{{ wizardError }}</div>
|
||||||
|
|
||||||
<!-- Actions -->
|
<!-- Actions -->
|
||||||
<div class="actions-row">
|
<div class="actions-row">
|
||||||
<button type="button" class="btn-secondary" (click)="cancel()">Annuler</button>
|
<button type="button" class="btn-secondary" (click)="cancel()">Annuler</button>
|
||||||
|
<button type="button" class="btn-ai" (click)="openWizard()" [disabled]="!canSubmit"
|
||||||
|
title="Ouvrir l'assistant IA pour pré-remplir les champs">
|
||||||
|
<lucide-icon [img]="Sparkles" [size]="14"></lucide-icon>
|
||||||
|
Créer avec l'IA
|
||||||
|
</button>
|
||||||
<button type="submit" class="btn-primary" [disabled]="!canSubmit">Créer la page</button>
|
<button type="submit" class="btn-primary" [disabled]="!canSubmit">Créer la page</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Drawer chat IA en mode wizard -->
|
||||||
|
<app-ai-chat-drawer
|
||||||
|
[loreId]="loreId"
|
||||||
|
[isOpen]="chatOpen"
|
||||||
|
[welcomeMessage]="wizardWelcome"
|
||||||
|
[systemPromptAddon]="wizardSystemPrompt"
|
||||||
|
[quickSuggestions]="wizardSuggestions"
|
||||||
|
[primaryAction]="wizardPrimaryAction"
|
||||||
|
(close)="closeWizard()"
|
||||||
|
(assistantReply)="onWizardReply($event)"
|
||||||
|
(primaryActionClick)="applyWizardAndCreate()">
|
||||||
|
</app-ai-chat-drawer>
|
||||||
|
|||||||
@@ -157,3 +157,30 @@
|
|||||||
|
|
||||||
&:hover { background: #363650; }
|
&:hover { background: #363650; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-ai {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
padding: 0.65rem 1.1rem;
|
||||||
|
background: transparent;
|
||||||
|
color: #a5b4fc;
|
||||||
|
border: 1px solid #6c63ff;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s, opacity 0.15s;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) { background: #1f1d3a; }
|
||||||
|
&:disabled { opacity: 0.4; cursor: not-allowed; border-color: #2a2a3d; color: #6b7280; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard-error {
|
||||||
|
background: #3f1f1f;
|
||||||
|
color: #fca5a5;
|
||||||
|
border: 1px solid #7f1d1d;
|
||||||
|
padding: 0.65rem 0.9rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Component, OnInit, OnDestroy } from '@angular/core';
|
|||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||||
import { LucideAngularModule, FileText } from 'lucide-angular';
|
import { LucideAngularModule, FileText, Sparkles } from 'lucide-angular';
|
||||||
import { LoreService } from '../../services/lore.service';
|
import { LoreService } from '../../services/lore.service';
|
||||||
import { TemplateService } from '../../services/template.service';
|
import { TemplateService } from '../../services/template.service';
|
||||||
import { PageService } from '../../services/page.service';
|
import { PageService } from '../../services/page.service';
|
||||||
@@ -11,6 +11,7 @@ import { PageTitleService } from '../../services/page-title.service';
|
|||||||
import { LoreNode } from '../../services/lore.model';
|
import { LoreNode } from '../../services/lore.model';
|
||||||
import { Template } from '../../services/template.model';
|
import { Template } from '../../services/template.model';
|
||||||
import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.helper';
|
import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.helper';
|
||||||
|
import { AiChatDrawerComponent, ChatPrimaryAction } from '../../shared/ai-chat-drawer/ai-chat-drawer.component';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Écran de création d'une Page.
|
* Écran de création d'une Page.
|
||||||
@@ -26,12 +27,13 @@ import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.hel
|
|||||||
@Component({
|
@Component({
|
||||||
selector: 'app-page-create',
|
selector: 'app-page-create',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, ReactiveFormsModule, RouterModule, LucideAngularModule],
|
imports: [CommonModule, ReactiveFormsModule, RouterModule, LucideAngularModule, AiChatDrawerComponent],
|
||||||
templateUrl: './page-create.component.html',
|
templateUrl: './page-create.component.html',
|
||||||
styleUrls: ['./page-create.component.scss']
|
styleUrls: ['./page-create.component.scss']
|
||||||
})
|
})
|
||||||
export class PageCreateComponent implements OnInit, OnDestroy {
|
export class PageCreateComponent implements OnInit, OnDestroy {
|
||||||
readonly FileText = FileText;
|
readonly FileText = FileText;
|
||||||
|
readonly Sparkles = Sparkles;
|
||||||
|
|
||||||
form: FormGroup;
|
form: FormGroup;
|
||||||
loreId = '';
|
loreId = '';
|
||||||
@@ -42,6 +44,23 @@ export class PageCreateComponent implements OnInit, OnDestroy {
|
|||||||
/** Template actuellement sélectionné dans la grille. */
|
/** Template actuellement sélectionné dans la grille. */
|
||||||
selectedTemplateId: string | null = null;
|
selectedTemplateId: string | null = null;
|
||||||
|
|
||||||
|
// --- Mode wizard IA (étape b6) -----------------------------------------
|
||||||
|
|
||||||
|
/** Drawer chat ouvert ? */
|
||||||
|
chatOpen = false;
|
||||||
|
/** Dernière réponse complète de l'assistant — on y cherchera le bloc <values>. */
|
||||||
|
private lastWizardReply: string | null = null;
|
||||||
|
/** Erreur de parsing du bloc <values> — affichée sous le drawer. */
|
||||||
|
wizardError: string | null = null;
|
||||||
|
/** Action primaire du wizard : applique les valeurs extraites et crée la page. */
|
||||||
|
readonly wizardPrimaryAction: ChatPrimaryAction = { label: 'Appliquer et créer la page' };
|
||||||
|
/** Suggestions rapides orientées "affiner le résultat" (mode wizard). */
|
||||||
|
readonly wizardSuggestions: string[] = [
|
||||||
|
'Rends la description plus courte',
|
||||||
|
'Ajoute un trait distinctif marquant',
|
||||||
|
'Donne un ton plus sombre'
|
||||||
|
];
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private fb: FormBuilder,
|
private fb: FormBuilder,
|
||||||
private route: ActivatedRoute,
|
private route: ActivatedRoute,
|
||||||
@@ -88,6 +107,10 @@ export class PageCreateComponent implements OnInit, OnDestroy {
|
|||||||
return this.form.valid && !!this.selectedTemplateId;
|
return this.form.valid && !!this.selectedTemplateId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get selectedTemplate(): Template | null {
|
||||||
|
return this.templates.find(t => t.id === this.selectedTemplateId) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
submit(): void {
|
submit(): void {
|
||||||
if (!this.canSubmit) return;
|
if (!this.canSubmit) return;
|
||||||
const raw = this.form.value;
|
const raw = this.form.value;
|
||||||
@@ -106,6 +129,119 @@ export class PageCreateComponent implements OnInit, OnDestroy {
|
|||||||
this.router.navigate(['/lore', this.loreId]);
|
this.router.navigate(['/lore', this.loreId]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Mode wizard IA (étape b6) -----------------------------------------
|
||||||
|
|
||||||
|
openWizard(): void {
|
||||||
|
if (!this.canSubmit) return;
|
||||||
|
this.wizardError = null;
|
||||||
|
this.lastWizardReply = null;
|
||||||
|
this.chatOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
closeWizard(): void {
|
||||||
|
this.chatOpen = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mémorise la dernière réponse de l'assistant — on y cherchera le bloc <values>. */
|
||||||
|
onWizardReply(reply: string): void {
|
||||||
|
this.lastWizardReply = reply;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clic sur "Appliquer et créer la page" :
|
||||||
|
* 1. Extraire le bloc JSON <values>...</values> de la dernière réponse.
|
||||||
|
* 2. Créer la page avec titre + template + nodeId + values.
|
||||||
|
* 3. Naviguer vers l'édition pour que l'utilisateur finalise.
|
||||||
|
*/
|
||||||
|
applyWizardAndCreate(): void {
|
||||||
|
if (!this.canSubmit || !this.lastWizardReply) {
|
||||||
|
this.wizardError = "L'assistant n'a pas encore répondu. Décrivez d'abord votre idée.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const values = this.extractValuesBlock(this.lastWizardReply);
|
||||||
|
if (!values) {
|
||||||
|
this.wizardError = "Impossible d'extraire les valeurs. Demandez à l'assistant de proposer à nouveau.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.wizardError = null;
|
||||||
|
const raw = this.form.value;
|
||||||
|
// Le backend POST /api/pages ne prend pas `values` — on crée d'abord la
|
||||||
|
// coquille, puis on PUT immédiatement avec les valeurs extraites.
|
||||||
|
// 2 roundtrips, mais zéro modification backend nécessaire.
|
||||||
|
this.pageService.create({
|
||||||
|
loreId: this.loreId,
|
||||||
|
nodeId: raw.nodeId,
|
||||||
|
templateId: this.selectedTemplateId!,
|
||||||
|
title: raw.title
|
||||||
|
}).subscribe({
|
||||||
|
next: (created) => {
|
||||||
|
const updated = { ...created, values };
|
||||||
|
this.pageService.update(created.id!, updated).subscribe({
|
||||||
|
next: () => this.router.navigate(['/lore', this.loreId, 'pages', created.id]),
|
||||||
|
error: () => this.wizardError = 'Page créée, mais impossible d\'appliquer les valeurs.'
|
||||||
|
});
|
||||||
|
},
|
||||||
|
error: () => this.wizardError = 'Erreur lors de la création de la page.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Prompt système injecté dans le backend pour le mode wizard. */
|
||||||
|
get wizardSystemPrompt(): string | null {
|
||||||
|
const tpl = this.selectedTemplate;
|
||||||
|
if (!tpl || !this.canSubmit) return null;
|
||||||
|
const title = this.form.value.title as string;
|
||||||
|
const fieldsList = tpl.fields.length ? tpl.fields.map(f => `"${f}"`).join(', ') : '(aucun champ)';
|
||||||
|
const exampleJson = tpl.fields.length
|
||||||
|
? '{\n ' + tpl.fields.map(f => `"${f}": "valeur proposée"`).join(',\n ') + '\n}'
|
||||||
|
: '{}';
|
||||||
|
|
||||||
|
return `MODE WIZARD — CRÉATION DE PAGE
|
||||||
|
|
||||||
|
L'utilisateur crée une nouvelle page intitulée "${title}" à partir du template "${tpl.name}".
|
||||||
|
Les champs à proposer sont : ${fieldsList}.
|
||||||
|
|
||||||
|
Règles de cohérence :
|
||||||
|
- Tu PEUX inventer des éléments originaux (personnages, lieux, objets, intrigues) — c'est ton rôle.
|
||||||
|
- Tu ne peux PAS faire référence à un élément comme s'il existait déjà dans l'univers, sauf s'il apparaît EXACTEMENT dans la carte du Lore fournie plus haut.
|
||||||
|
- Si l'utilisateur évoque un élément absent de la carte, suggère de le créer plutôt que d'inventer des détails fictifs à son sujet.
|
||||||
|
|
||||||
|
Format de réponse OBLIGATOIRE :
|
||||||
|
Après avoir dialogué (1-3 phrases), termine CHAQUE réponse par un bloc JSON entre balises <values>, sans rien ajouter après :
|
||||||
|
|
||||||
|
<values>
|
||||||
|
${exampleJson}
|
||||||
|
</values>
|
||||||
|
|
||||||
|
Les clés du JSON doivent correspondre EXACTEMENT aux noms de champs indiqués. Laisse "" si tu manques d'info pour un champ.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Welcome message contextualisé au template choisi. */
|
||||||
|
get wizardWelcome(): string {
|
||||||
|
const tpl = this.selectedTemplate;
|
||||||
|
if (!tpl) return 'Décrivez ce que vous souhaitez créer.';
|
||||||
|
return `Super, on va créer une page "${tpl.name}" ! Décrivez-la-moi en quelques mots — contexte, rôle, traits marquants — et je proposerai des valeurs pour chaque champ.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extrait le bloc <values>{...}</values> de la réponse assistant et parse en objet.
|
||||||
|
* Retourne null si absent ou JSON invalide.
|
||||||
|
*/
|
||||||
|
private extractValuesBlock(reply: string): Record<string, string> | null {
|
||||||
|
const match = reply.match(/<values>\s*([\s\S]*?)\s*<\/values>/i);
|
||||||
|
if (!match) return null;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(match[1]) as Record<string, unknown>;
|
||||||
|
// On coerce toute valeur non-string en string (l'IA peut parfois produire des nombres).
|
||||||
|
const result: Record<string, string> = {};
|
||||||
|
for (const [k, v] of Object.entries(parsed)) {
|
||||||
|
result[k] = v == null ? '' : String(v);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
this.layoutService.hide();
|
this.layoutService.hide();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,9 +8,13 @@
|
|||||||
<p class="subtitle">{{ template?.name || 'Page' }}</p>
|
<p class="subtitle">{{ template?.name || 'Page' }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<button type="button" class="btn-ai" disabled title="Bientôt disponible">
|
<button type="button" class="btn-ai"
|
||||||
|
(click)="toggleChat()"
|
||||||
|
[disabled]="aiLoading"
|
||||||
|
[class.active]="chatOpen"
|
||||||
|
title="Ouvrir l'Assistant IA (chat ou remplissage automatique)">
|
||||||
<lucide-icon [img]="Sparkles" [size]="14"></lucide-icon>
|
<lucide-icon [img]="Sparkles" [size]="14"></lucide-icon>
|
||||||
Assistant IA
|
{{ aiLoading ? 'Génération…' : 'Assistant IA' }}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="btn-danger" (click)="delete()">Supprimer</button>
|
<button type="button" class="btn-danger" (click)="delete()">Supprimer</button>
|
||||||
<button type="button" class="btn-primary" (click)="save()" [disabled]="!title.trim()">
|
<button type="button" class="btn-primary" (click)="save()" [disabled]="!title.trim()">
|
||||||
@@ -19,6 +23,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<div *ngIf="aiError" class="ai-error-banner" role="alert">
|
||||||
|
<span>{{ aiError }}</span>
|
||||||
|
<button type="button" class="ai-error-dismiss" (click)="aiError = null" aria-label="Fermer">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<form class="edit-form">
|
<form class="edit-form">
|
||||||
|
|
||||||
<!-- Identité ----------------------------------------------------- -->
|
<!-- Identité ----------------------------------------------------- -->
|
||||||
@@ -88,3 +97,14 @@
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Drawer chat IA (hors du .page pour pouvoir couvrir le viewport côté droit) -->
|
||||||
|
<app-ai-chat-drawer
|
||||||
|
[loreId]="loreId"
|
||||||
|
[pageId]="pageId"
|
||||||
|
[isOpen]="chatOpen"
|
||||||
|
[quickSuggestions]="chatQuickSuggestions"
|
||||||
|
[primaryAction]="chatPrimaryAction"
|
||||||
|
(close)="chatOpen = false"
|
||||||
|
(primaryActionClick)="onChatFillRequested()">
|
||||||
|
</app-ai-chat-drawer>
|
||||||
|
|||||||
@@ -119,4 +119,35 @@
|
|||||||
|
|
||||||
&:disabled { opacity: 0.5; cursor: not-allowed; }
|
&:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
&:hover:not(:disabled) { background: #1f2937; }
|
&:hover:not(:disabled) { background: #1f2937; }
|
||||||
|
&.active {
|
||||||
|
background: #1f2937;
|
||||||
|
border-color: #6c63ff;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-error-banner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
background: #3f1f1f;
|
||||||
|
color: #fca5a5;
|
||||||
|
border: 1px solid #7f1d1d;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.7rem 1rem;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
|
||||||
|
.ai-error-dismiss {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: #fca5a5;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0 0.25rem;
|
||||||
|
|
||||||
|
&:hover { color: white; }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.hel
|
|||||||
import { ChipsInputComponent } from '../../shared/chips-input/chips-input.component';
|
import { ChipsInputComponent } from '../../shared/chips-input/chips-input.component';
|
||||||
import { LoreLinkPickerComponent } from '../../shared/lore-link-picker/lore-link-picker.component';
|
import { LoreLinkPickerComponent } from '../../shared/lore-link-picker/lore-link-picker.component';
|
||||||
import { BreadcrumbComponent, BreadcrumbItem } from '../../shared/breadcrumb/breadcrumb.component';
|
import { BreadcrumbComponent, BreadcrumbItem } from '../../shared/breadcrumb/breadcrumb.component';
|
||||||
|
import { AiChatDrawerComponent, ChatPrimaryAction } from '../../shared/ai-chat-drawer/ai-chat-drawer.component';
|
||||||
import { Lore } from '../../services/lore.model';
|
import { Lore } from '../../services/lore.model';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -34,7 +35,7 @@ import { Lore } from '../../services/lore.model';
|
|||||||
@Component({
|
@Component({
|
||||||
selector: 'app-page-edit',
|
selector: 'app-page-edit',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, FormsModule, LucideAngularModule, ChipsInputComponent, LoreLinkPickerComponent, BreadcrumbComponent],
|
imports: [CommonModule, FormsModule, LucideAngularModule, ChipsInputComponent, LoreLinkPickerComponent, BreadcrumbComponent, AiChatDrawerComponent],
|
||||||
templateUrl: './page-edit.component.html',
|
templateUrl: './page-edit.component.html',
|
||||||
styleUrls: ['./page-edit.component.scss']
|
styleUrls: ['./page-edit.component.scss']
|
||||||
})
|
})
|
||||||
@@ -61,6 +62,21 @@ export class PageEditComponent implements OnInit, OnDestroy {
|
|||||||
/** IDs des pages liées (Phase 5B). */
|
/** IDs des pages liées (Phase 5B). */
|
||||||
relatedPageIds: string[] = [];
|
relatedPageIds: string[] = [];
|
||||||
|
|
||||||
|
/** Phase 5D — état de l'Assistant IA (one-shot). */
|
||||||
|
aiLoading = false;
|
||||||
|
aiError: string | null = null;
|
||||||
|
|
||||||
|
/** Phase b5 — drawer chat IA (conversationnel). */
|
||||||
|
chatOpen = false;
|
||||||
|
/** Action primaire dans le chat : déclenche le one-shot b4 (remplissage automatique). */
|
||||||
|
readonly chatPrimaryAction: ChatPrimaryAction = { label: 'Remplir automatiquement tous les champs' };
|
||||||
|
/** Suggestions rapides hardcodées (MVP). */
|
||||||
|
readonly chatQuickSuggestions: string[] = [
|
||||||
|
"Étoffe l'histoire de cette page",
|
||||||
|
'Suggère des liens avec d\'autres pages du Lore',
|
||||||
|
'Propose une intrigue secondaire'
|
||||||
|
];
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private route: ActivatedRoute,
|
private route: ActivatedRoute,
|
||||||
private router: Router,
|
private router: Router,
|
||||||
@@ -169,6 +185,59 @@ export class PageEditComponent implements OnInit, OnDestroy {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Chat IA conversationnel (Phase b5) --------------------------------
|
||||||
|
|
||||||
|
toggleChat(): void {
|
||||||
|
this.chatOpen = !this.chatOpen;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Appelé depuis le drawer quand l'utilisateur clique sur l'action primaire. */
|
||||||
|
onChatFillRequested(): void {
|
||||||
|
this.chatOpen = false; // on ferme le drawer : le résultat apparaîtra dans les textareas
|
||||||
|
this.runAssistantAI();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assistant IA (Phase 5D) — demande au Brain des suggestions de valeurs
|
||||||
|
* pour les champs dynamiques du template.
|
||||||
|
*
|
||||||
|
* Merge soft : on n'écrase pas une valeur déjà saisie par l'utilisateur
|
||||||
|
* si la suggestion est vide. L'utilisateur garde le contrôle final avant
|
||||||
|
* de cliquer "Sauvegarder".
|
||||||
|
*/
|
||||||
|
runAssistantAI(): void {
|
||||||
|
if (this.aiLoading || !this.template?.fields?.length) return;
|
||||||
|
this.aiLoading = true;
|
||||||
|
this.aiError = null;
|
||||||
|
this.pageService.generateValues(this.pageId).subscribe({
|
||||||
|
next: (suggestions) => {
|
||||||
|
this.mergeSuggestions(suggestions);
|
||||||
|
this.aiLoading = false;
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
this.aiLoading = false;
|
||||||
|
this.aiError = err?.status === 502
|
||||||
|
? "L'assistant IA est injoignable. V\u00e9rifiez que le service Brain tourne."
|
||||||
|
: "\u00c9chec de la g\u00e9n\u00e9ration IA. R\u00e9essayez dans un instant.";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fusionne les suggestions dans les valeurs courantes.
|
||||||
|
* Merge soft :
|
||||||
|
* - Suggestion non-vide → on applique (l'utilisateur a demandé la génération).
|
||||||
|
* - Suggestion vide → on NE touche PAS à la valeur courante (l'IA n'a rien à proposer pour ce champ).
|
||||||
|
*/
|
||||||
|
private mergeSuggestions(suggestions: Record<string, string>): void {
|
||||||
|
for (const field of this.template?.fields ?? []) {
|
||||||
|
const suggestion = suggestions[field];
|
||||||
|
if (suggestion && suggestion.trim()) {
|
||||||
|
this.values[field] = suggestion;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
delete(): void {
|
delete(): void {
|
||||||
if (!this.page) return;
|
if (!this.page) return;
|
||||||
if (!confirm(`Supprimer la page "${this.page.title}" ?`)) return;
|
if (!confirm(`Supprimer la page "${this.page.title}" ?`)) return;
|
||||||
|
|||||||
171
web/src/app/services/ai-chat.service.ts
Normal file
171
web/src/app/services/ai-chat.service.ts
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Un message d'une conversation IA (vue front).
|
||||||
|
* Aligné sur le DTO ChatMessageDTO côté Java.
|
||||||
|
*/
|
||||||
|
export interface ChatMessage {
|
||||||
|
role: 'user' | 'assistant' | 'system';
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Événements émis par le flux SSE durant un chat streamé.
|
||||||
|
* - token : un fragment de texte vient d'arriver (à concaténer dans la bulle).
|
||||||
|
* - done : le stream s'est terminé proprement (l'observable va compléter).
|
||||||
|
* - error : une erreur s'est produite côté serveur (l'observable va erreur-compléter).
|
||||||
|
*/
|
||||||
|
export type ChatStreamEvent =
|
||||||
|
| { type: 'token'; value: string }
|
||||||
|
| { type: 'done' }
|
||||||
|
| { type: 'error'; message: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service qui encapsule l'appel SSE au backend Java (POST /api/ai/chat/stream).
|
||||||
|
*
|
||||||
|
* On n'utilise pas EventSource (API navigateur natif) car elle ne supporte
|
||||||
|
* que GET sans body. On fait donc un fetch() avec un ReadableStream qu'on
|
||||||
|
* décode ligne par ligne pour extraire les événements SSE.
|
||||||
|
*/
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class AiChatService {
|
||||||
|
private readonly endpoint = 'http://localhost:8080/api/ai/chat/stream';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Streame la réponse de l'IA pour un historique de messages donné.
|
||||||
|
* L'Observable :
|
||||||
|
* - émet `{type: 'token', value}` à chaque fragment reçu ;
|
||||||
|
* - se complete quand `event: done` arrive ;
|
||||||
|
* - erreur-complete (via `throwError`) quand `event: error` arrive ou qu'une erreur réseau survient.
|
||||||
|
*
|
||||||
|
* Annuler la subscription annule proprement le fetch (AbortController).
|
||||||
|
*/
|
||||||
|
streamChat(
|
||||||
|
loreId: string,
|
||||||
|
messages: ChatMessage[],
|
||||||
|
pageId?: string | null
|
||||||
|
): Observable<ChatStreamEvent> {
|
||||||
|
return new Observable<ChatStreamEvent>((subscriber) => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
// Payload : pageId inclus uniquement s'il est fourni et non vide, pour
|
||||||
|
// garder le comportement "chat générique au Lore" par défaut.
|
||||||
|
const body: Record<string, unknown> = { loreId, messages };
|
||||||
|
if (pageId) body['pageId'] = pageId;
|
||||||
|
|
||||||
|
fetch(this.endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'text/event-stream'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
signal: controller.signal
|
||||||
|
})
|
||||||
|
.then(async (response) => {
|
||||||
|
if (!response.ok || !response.body) {
|
||||||
|
subscriber.error(new Error(`HTTP ${response.status}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.consumeSseStream(response.body, subscriber);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (controller.signal.aborted) return; // annulation volontaire, silencieuse
|
||||||
|
subscriber.error(err);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => controller.abort();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Consomme un ReadableStream SSE ligne par ligne.
|
||||||
|
* Format attendu (un événement = un bloc séparé par `\n\n`) :
|
||||||
|
* event: done (optionnel, défaut = 'message')
|
||||||
|
* data: {...} (une ou plusieurs lignes, concaténées avec '\n')
|
||||||
|
* <ligne vide> (séparateur d'événements)
|
||||||
|
*/
|
||||||
|
private async consumeSseStream(
|
||||||
|
body: ReadableStream<Uint8Array>,
|
||||||
|
subscriber: { next: (e: ChatStreamEvent) => void; error: (e: unknown) => void; complete: () => void }
|
||||||
|
): Promise<void> {
|
||||||
|
const reader = body.getReader();
|
||||||
|
const decoder = new TextDecoder('utf-8');
|
||||||
|
let buffer = '';
|
||||||
|
|
||||||
|
// Événement SSE en cours de construction (accumulé entre lignes vides).
|
||||||
|
let currentEvent: string | null = null;
|
||||||
|
let currentData = '';
|
||||||
|
|
||||||
|
const dispatchCurrentEvent = () => {
|
||||||
|
const eventName = currentEvent ?? 'message';
|
||||||
|
if (eventName === 'error') {
|
||||||
|
const message = this.safeParseMessage(currentData);
|
||||||
|
subscriber.error(new Error(message));
|
||||||
|
} else if (eventName === 'done') {
|
||||||
|
subscriber.next({ type: 'done' });
|
||||||
|
subscriber.complete();
|
||||||
|
} else {
|
||||||
|
// Événement 'message' (défaut) : JSON {"token": "..."}
|
||||||
|
const token = this.safeParseToken(currentData);
|
||||||
|
if (token) subscriber.next({ type: 'token', value: token });
|
||||||
|
}
|
||||||
|
currentEvent = null;
|
||||||
|
currentData = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
|
||||||
|
// On découpe par lignes ; la dernière (potentiellement incomplète) reste dans buffer.
|
||||||
|
let newlineIdx: number;
|
||||||
|
while ((newlineIdx = buffer.indexOf('\n')) >= 0) {
|
||||||
|
const line = buffer.slice(0, newlineIdx).replace(/\r$/, '');
|
||||||
|
buffer = buffer.slice(newlineIdx + 1);
|
||||||
|
|
||||||
|
if (line === '') {
|
||||||
|
// Ligne vide = fin d'un événement SSE : on dispatch ce qu'on a accumulé.
|
||||||
|
if (currentEvent !== null || currentData !== '') {
|
||||||
|
dispatchCurrentEvent();
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (line.startsWith('event:')) {
|
||||||
|
currentEvent = line.slice(6).trim();
|
||||||
|
} else if (line.startsWith('data:')) {
|
||||||
|
const chunk = line.slice(5).replace(/^ /, '');
|
||||||
|
currentData = currentData ? `${currentData}\n${chunk}` : chunk;
|
||||||
|
}
|
||||||
|
// Autres champs SSE (id:, retry:) ignorés pour le MVP.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fin de stream côté réseau sans event: done explicite → on complete quand même.
|
||||||
|
if (currentEvent !== null || currentData !== '') dispatchCurrentEvent();
|
||||||
|
subscriber.complete();
|
||||||
|
} catch (err) {
|
||||||
|
subscriber.error(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private safeParseToken(json: string): string | null {
|
||||||
|
try {
|
||||||
|
const obj = JSON.parse(json) as { token?: string };
|
||||||
|
return typeof obj.token === 'string' ? obj.token : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private safeParseMessage(json: string): string {
|
||||||
|
try {
|
||||||
|
const obj = JSON.parse(json) as { message?: string };
|
||||||
|
return obj.message ?? 'Erreur inconnue côté serveur.';
|
||||||
|
} catch {
|
||||||
|
return json || 'Erreur inconnue côté serveur.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
|
import { map } from 'rxjs/operators';
|
||||||
import { Page, PageCreate } from './page.model';
|
import { Page, PageCreate } from './page.model';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -45,4 +46,17 @@ export class PageService {
|
|||||||
const params = new HttpParams().set('q', q);
|
const params = new HttpParams().set('q', q);
|
||||||
return this.http.get<Page[]>(`${this.apiUrl}/search`, { params });
|
return this.http.get<Page[]>(`${this.apiUrl}/search`, { params });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Demande à l'IA (Brain Python, via le Core Java) des suggestions de valeurs
|
||||||
|
* pour les champs dynamiques de la page. Ne modifie PAS la page en base —
|
||||||
|
* l'appelant est responsable de fusionner les valeurs et de sauvegarder.
|
||||||
|
*
|
||||||
|
* Peut prendre plusieurs dizaines de secondes selon le modèle LLM.
|
||||||
|
*/
|
||||||
|
generateValues(pageId: string): Observable<Record<string, string>> {
|
||||||
|
return this.http
|
||||||
|
.post<{ values: Record<string, string> }>(`${this.apiUrl}/${pageId}/generate`, {})
|
||||||
|
.pipe(map(res => res.values ?? {}));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
<aside class="drawer" [class.drawer-open]="isOpen" aria-label="Assistant IA">
|
||||||
|
|
||||||
|
<header class="drawer-header">
|
||||||
|
<h2>Assistant IA</h2>
|
||||||
|
<button type="button" class="close-btn" (click)="onClose()" aria-label="Fermer">
|
||||||
|
<lucide-icon [img]="X" [size]="18"></lucide-icon>
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div #messagesContainer class="messages">
|
||||||
|
<!-- Message d'accueil (non-stocké dans `messages`, toujours visible tant que la conversation est vide). -->
|
||||||
|
<div class="msg msg-assistant" *ngIf="messages.length === 0 && !currentAssistantText">
|
||||||
|
{{ welcomeMessage }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Historique -->
|
||||||
|
<ng-container *ngFor="let m of messages">
|
||||||
|
<div class="msg" [class.msg-user]="m.role === 'user'" [class.msg-assistant]="m.role === 'assistant'">
|
||||||
|
{{ m.content }}
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<!-- Bulle en cours de streaming -->
|
||||||
|
<div class="msg msg-assistant msg-streaming" *ngIf="currentAssistantText">
|
||||||
|
{{ currentAssistantText }}<span class="caret"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Indicateur pendant la phase "en train de réfléchir" (avant le premier token) -->
|
||||||
|
<div class="typing-indicator" *ngIf="isStreaming && !currentAssistantText" aria-live="polite">
|
||||||
|
<span></span><span></span><span></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Erreur locale au drawer -->
|
||||||
|
<div class="msg msg-error" *ngIf="errorMessage" role="alert">
|
||||||
|
{{ errorMessage }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Action primaire (optionnelle) : ne passe PAS par le chat -->
|
||||||
|
<div class="primary-action" *ngIf="primaryAction">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="primary-btn"
|
||||||
|
(click)="onPrimaryAction()"
|
||||||
|
[disabled]="isStreaming">
|
||||||
|
<lucide-icon [img]="Wand2" [size]="14"></lucide-icon>
|
||||||
|
{{ primaryAction.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Suggestions rapides -->
|
||||||
|
<div class="quick-suggestions" *ngIf="quickSuggestions.length">
|
||||||
|
<p class="quick-label">Suggestions rapides :</p>
|
||||||
|
<div class="quick-list">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="quick-btn"
|
||||||
|
*ngFor="let s of quickSuggestions"
|
||||||
|
(click)="useQuickSuggestion(s)"
|
||||||
|
[disabled]="isStreaming">
|
||||||
|
<lucide-icon [img]="Lightbulb" [size]="12"></lucide-icon>
|
||||||
|
{{ s }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Zone de saisie -->
|
||||||
|
<form class="input-row" (ngSubmit)="send()">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
[(ngModel)]="input"
|
||||||
|
name="chatInput"
|
||||||
|
placeholder="Posez une question..."
|
||||||
|
[disabled]="isStreaming"
|
||||||
|
autocomplete="off" />
|
||||||
|
<button type="submit" class="send-btn" [disabled]="!input.trim() || isStreaming" aria-label="Envoyer">
|
||||||
|
<lucide-icon [img]="Send" [size]="16"></lucide-icon>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
</aside>
|
||||||
261
web/src/app/shared/ai-chat-drawer/ai-chat-drawer.component.scss
Normal file
261
web/src/app/shared/ai-chat-drawer/ai-chat-drawer.component.scss
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
:host {
|
||||||
|
// Le drawer lui-même gère son positionnement fixed. Rien à prévoir côté host.
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
width: 380px;
|
||||||
|
height: 100vh;
|
||||||
|
background: #0f0f1a;
|
||||||
|
border-left: 1px solid #1e1e3a;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
transform: translateX(100%);
|
||||||
|
transition: transform 0.25s ease;
|
||||||
|
z-index: 1000;
|
||||||
|
box-shadow: -4px 0 20px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-open {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Header --------------------------------------------------------------
|
||||||
|
|
||||||
|
.drawer-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
border-bottom: 1px solid #1e1e3a;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: #9ca3af;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.25rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: white;
|
||||||
|
background: #1e1e3a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Zone messages -------------------------------------------------------
|
||||||
|
|
||||||
|
.messages {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg {
|
||||||
|
max-width: 85%;
|
||||||
|
padding: 0.6rem 0.85rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg-assistant {
|
||||||
|
align-self: flex-start;
|
||||||
|
background: #1a1a2e;
|
||||||
|
color: #e5e7eb;
|
||||||
|
border: 1px solid #2a2a3d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg-user {
|
||||||
|
align-self: flex-end;
|
||||||
|
background: #6c63ff;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg-streaming {
|
||||||
|
// Le caret clignotant à la fin donne la sensation de "l'IA est en train d'écrire"
|
||||||
|
.caret {
|
||||||
|
display: inline-block;
|
||||||
|
width: 6px;
|
||||||
|
height: 1em;
|
||||||
|
background: #a5b4fc;
|
||||||
|
margin-left: 2px;
|
||||||
|
vertical-align: text-bottom;
|
||||||
|
animation: blink 1s step-end infinite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg-error {
|
||||||
|
align-self: stretch;
|
||||||
|
background: #3f1f1f;
|
||||||
|
color: #fca5a5;
|
||||||
|
border: 1px solid #7f1d1d;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes blink {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Indicateur "typing" -------------------------------------------------
|
||||||
|
|
||||||
|
.typing-indicator {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.3rem;
|
||||||
|
padding: 0.6rem 0.85rem;
|
||||||
|
align-self: flex-start;
|
||||||
|
|
||||||
|
span {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
background: #6c63ff;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: bounce 1.2s infinite ease-in-out both;
|
||||||
|
}
|
||||||
|
span:nth-child(2) { animation-delay: 0.15s; }
|
||||||
|
span:nth-child(3) { animation-delay: 0.3s; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes bounce {
|
||||||
|
0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; }
|
||||||
|
40% { transform: scale(1); opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Action primaire -----------------------------------------------------
|
||||||
|
|
||||||
|
.primary-action {
|
||||||
|
padding: 0.75rem 1.25rem 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
.primary-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.65rem 0.9rem;
|
||||||
|
background: #6c63ff;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s, opacity 0.15s;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) { background: #5a52e0; }
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Suggestions rapides -------------------------------------------------
|
||||||
|
|
||||||
|
.quick-suggestions {
|
||||||
|
padding: 0.75rem 1.25rem 0;
|
||||||
|
border-top: 1px solid #1e1e3a;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
.quick-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #9ca3af;
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
padding: 0.35rem 0.6rem;
|
||||||
|
background: #1a1a2e;
|
||||||
|
border: 1px solid #2a2a3d;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #d1d5db;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: #2a2a3d;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Zone de saisie ------------------------------------------------------
|
||||||
|
|
||||||
|
.input-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.75rem 1.25rem 1rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
input {
|
||||||
|
flex: 1;
|
||||||
|
background: #1a1a2e;
|
||||||
|
border: 1px solid #2a2a3d;
|
||||||
|
color: white;
|
||||||
|
padding: 0.55rem 0.75rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
font-family: inherit;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #6c63ff;
|
||||||
|
}
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.send-btn {
|
||||||
|
background: #6c63ff;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
width: 38px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s, opacity 0.15s;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) { background: #5a52e0; }
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
182
web/src/app/shared/ai-chat-drawer/ai-chat-drawer.component.ts
Normal file
182
web/src/app/shared/ai-chat-drawer/ai-chat-drawer.component.ts
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
import { Component, ElementRef, EventEmitter, Input, Output, ViewChild, OnDestroy } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { LucideAngularModule, X, Send, Sparkles, Lightbulb, Wand2 } from 'lucide-angular';
|
||||||
|
import { Subscription } from 'rxjs';
|
||||||
|
import { AiChatService, ChatMessage } from '../../services/ai-chat.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Action primaire optionnelle rendue en gros bouton au-dessus des suggestions.
|
||||||
|
* Utilisée pour les actions "spéciales" qui NE passent PAS par le chat
|
||||||
|
* (ex: "Remplir automatiquement tous les champs" → déclenche le one-shot b4).
|
||||||
|
*/
|
||||||
|
export interface ChatPrimaryAction {
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drawer de chat IA réutilisable — panneau fixe à droite de l'écran.
|
||||||
|
*
|
||||||
|
* Usage minimal :
|
||||||
|
* <app-ai-chat-drawer
|
||||||
|
* [loreId]="loreId"
|
||||||
|
* [isOpen]="chatOpen"
|
||||||
|
* [quickSuggestions]="['Développe l'histoire', ...]"
|
||||||
|
* (close)="chatOpen = false">
|
||||||
|
* </app-ai-chat-drawer>
|
||||||
|
*
|
||||||
|
* Contrainte de design : conversation éphémère (on perd tout à la fermeture
|
||||||
|
* ou à la destruction du composant — choix MVP assumé).
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'app-ai-chat-drawer',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, FormsModule, LucideAngularModule],
|
||||||
|
templateUrl: './ai-chat-drawer.component.html',
|
||||||
|
styleUrls: ['./ai-chat-drawer.component.scss']
|
||||||
|
})
|
||||||
|
export class AiChatDrawerComponent implements OnDestroy {
|
||||||
|
readonly X = X;
|
||||||
|
readonly Send = Send;
|
||||||
|
readonly Sparkles = Sparkles;
|
||||||
|
readonly Lightbulb = Lightbulb;
|
||||||
|
readonly Wand2 = Wand2;
|
||||||
|
|
||||||
|
@Input() loreId = '';
|
||||||
|
/**
|
||||||
|
* Optionnel : ID d'une page précise en cours d'édition. Si fourni, le
|
||||||
|
* backend focalise l'IA sur cette page (template, champs, valeurs) via
|
||||||
|
* un bloc "PAGE EN COURS" dans le system prompt. Sans cet ID, le chat
|
||||||
|
* reste générique au Lore.
|
||||||
|
*/
|
||||||
|
@Input() pageId: string | null = null;
|
||||||
|
@Input() isOpen = false;
|
||||||
|
/** Texte accueil affiché au premier ouverture (avant tout échange). */
|
||||||
|
@Input() welcomeMessage = 'Bonjour ! Je peux vous aider à développer cette page. Que souhaitez-vous créer ?';
|
||||||
|
/** Suggestions rapides cliquables en bas (hardcodées par le parent, MVP). */
|
||||||
|
@Input() quickSuggestions: string[] = [];
|
||||||
|
/** Action primaire optionnelle (ex: "Remplir automatiquement") — ne passe PAS par le chat. */
|
||||||
|
@Input() primaryAction: ChatPrimaryAction | null = null;
|
||||||
|
/**
|
||||||
|
* Instructions système supplémentaires injectées en tête de la conversation
|
||||||
|
* envoyée au backend, INVISIBLES côté UI. Usage : mode wizard, où on veut
|
||||||
|
* contextualiser l'IA (template cible, format JSON attendu) sans polluer
|
||||||
|
* l'historique visuel.
|
||||||
|
*/
|
||||||
|
@Input() systemPromptAddon: string | null = null;
|
||||||
|
|
||||||
|
@Output() close = new EventEmitter<void>();
|
||||||
|
/** Émis au clic sur l'action primaire — le parent gère entièrement (one-shot, etc.). */
|
||||||
|
@Output() primaryActionClick = new EventEmitter<void>();
|
||||||
|
/** Émis à chaque fin de réponse assistant — utile pour parser côté parent (ex: bloc <values> du wizard). */
|
||||||
|
@Output() assistantReply = new EventEmitter<string>();
|
||||||
|
|
||||||
|
@ViewChild('messagesContainer') messagesContainer?: ElementRef<HTMLDivElement>;
|
||||||
|
|
||||||
|
/** Conversation en cours (user + assistant). Le welcome n'est pas dedans — rendu séparément. */
|
||||||
|
messages: ChatMessage[] = [];
|
||||||
|
/** Texte en cours de streaming (écrit token par token, pas encore poussé dans `messages`). */
|
||||||
|
currentAssistantText = '';
|
||||||
|
/** Champ de saisie. */
|
||||||
|
input = '';
|
||||||
|
/** Stream en cours ? Désactive le bouton envoyer + les suggestions rapides. */
|
||||||
|
isStreaming = false;
|
||||||
|
/** Dernier message d'erreur (affiché dans une bannière locale au drawer). */
|
||||||
|
errorMessage: string | null = null;
|
||||||
|
|
||||||
|
private streamSub: Subscription | null = null;
|
||||||
|
|
||||||
|
constructor(private readonly chatService: AiChatService) {}
|
||||||
|
|
||||||
|
// --- Handlers UI --------------------------------------------------------
|
||||||
|
|
||||||
|
onClose(): void {
|
||||||
|
this.abortStream();
|
||||||
|
this.close.emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Envoi explicite depuis le formulaire (Entrée ou bouton envoyer). */
|
||||||
|
send(): void {
|
||||||
|
const text = this.input.trim();
|
||||||
|
if (!text || this.isStreaming) return;
|
||||||
|
this.sendUserMessage(text);
|
||||||
|
this.input = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Envoi depuis une suggestion rapide (bouton cliquable en bas). */
|
||||||
|
useQuickSuggestion(suggestion: string): void {
|
||||||
|
if (this.isStreaming) return;
|
||||||
|
this.sendUserMessage(suggestion);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clic sur l'action primaire — on délègue entièrement au parent. */
|
||||||
|
onPrimaryAction(): void {
|
||||||
|
if (this.isStreaming) return;
|
||||||
|
this.primaryActionClick.emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Logique envoi + streaming -----------------------------------------
|
||||||
|
|
||||||
|
private sendUserMessage(text: string): void {
|
||||||
|
this.errorMessage = null;
|
||||||
|
this.messages.push({ role: 'user', content: text });
|
||||||
|
this.currentAssistantText = '';
|
||||||
|
this.isStreaming = true;
|
||||||
|
this.scrollToBottom();
|
||||||
|
|
||||||
|
// Construit la liste effectivement envoyée au backend : systemPromptAddon
|
||||||
|
// (si fourni) préfixé, puis l'historique visible. Le system n'est PAS stocké
|
||||||
|
// dans this.messages → reste invisible côté UI.
|
||||||
|
const payload = this.systemPromptAddon
|
||||||
|
? [{ role: 'system' as const, content: this.systemPromptAddon }, ...this.messages]
|
||||||
|
: this.messages;
|
||||||
|
|
||||||
|
this.streamSub = this.chatService.streamChat(this.loreId, payload, this.pageId).subscribe({
|
||||||
|
next: (event) => {
|
||||||
|
if (event.type === 'token') {
|
||||||
|
this.currentAssistantText += event.value;
|
||||||
|
this.scrollToBottom();
|
||||||
|
}
|
||||||
|
// 'done' : l'Observable va compléter → géré par complete()
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
this.isStreaming = false;
|
||||||
|
this.errorMessage = err?.message ?? 'Erreur inconnue.';
|
||||||
|
this.currentAssistantText = '';
|
||||||
|
},
|
||||||
|
complete: () => {
|
||||||
|
// On fige le texte streamé en message assistant réel, puis on reset le buffer.
|
||||||
|
const reply = this.currentAssistantText;
|
||||||
|
if (reply) {
|
||||||
|
this.messages.push({ role: 'assistant', content: reply });
|
||||||
|
this.assistantReply.emit(reply);
|
||||||
|
}
|
||||||
|
this.currentAssistantText = '';
|
||||||
|
this.isStreaming = false;
|
||||||
|
this.scrollToBottom();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private abortStream(): void {
|
||||||
|
this.streamSub?.unsubscribe();
|
||||||
|
this.streamSub = null;
|
||||||
|
this.isStreaming = false;
|
||||||
|
this.currentAssistantText = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scroll différé au prochain tick : donne à Angular le temps de rendre
|
||||||
|
* le nouveau contenu avant qu'on mesure/ajuste la position du scroll.
|
||||||
|
*/
|
||||||
|
private scrollToBottom(): void {
|
||||||
|
queueMicrotask(() => {
|
||||||
|
const el = this.messagesContainer?.nativeElement;
|
||||||
|
if (el) el.scrollTop = el.scrollHeight;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.abortStream();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user