Ajout de la partie IA

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

2
.gitignore vendored
View File

@@ -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
View File

@@ -0,0 +1,4 @@
.venv/
__pycache__/
*.pyc
.env

0
brain/app/__init__.py Normal file
View File

View File

View File

@@ -0,0 +1,120 @@
"""Use case : chat conversationnel LoreMind avec Structural Context.
Construit un system prompt riche à partir du contexte structurel du Lore
(noms des dossiers, titres des pages, templates, tags) puis délègue au port
`LLMChatProvider` pour le streaming token par token.
Ne charge PAS les valeurs des pages — l'IA doit être au courant de ce qui
existe, pas être noyée sous le contenu. Pattern "Structural Context", plus
simple que le RAG sémantique tant que le Lore reste de taille humaine.
"""
from typing import AsyncIterator
from app.domain.models import ChatMessage, LoreStructuralContext, PageContext
from app.domain.ports import LLMChatProvider
# Température moyenne : chat conversationnel créatif mais cohérent.
# Plus élevée que le one-shot (0.4) car on veut de la variété d'idées,
# mais sans partir en délire halluciné (1.0+).
_DEFAULT_TEMPERATURE = 0.7
_BASE_SYSTEM = """Tu es un assistant d'écriture pour un Maître de Jeu de JDR.
Tu dialogues avec le MJ pour l'aider à enrichir son univers.
Règles de ton :
- Réponds en français, ton chaleureux et créatif.
- Sois concis : listes à puces courtes plutôt que longs paragraphes.
- Propose des idées qui s'intègrent dans l'univers existant ci-dessous.
Règles de cohérence (IMPORTANT) :
- Tu PEUX et DOIS inventer des éléments originaux (personnages, lieux, objets, intrigues, créatures) — c'est ton rôle d'assistant créatif.
- Tu ne peux PAS faire référence à un élément du Lore du MJ comme s'il existait déjà, SAUF s'il apparaît EXACTEMENT (même orthographe) dans la section "Organisation" ci-dessous.
- Si l'utilisateur mentionne un nom que tu ne vois pas dans l'organisation, ne fais surtout pas semblant de le connaître : dis clairement "Je ne vois pas [nom] dans ton univers actuel, veux-tu qu'on le crée ?" plutôt que d'inventer des détails à son sujet.
- Évite les précisions inventées qu'on ne peut pas vérifier : dates exactes, chiffres de population, hiérarchies politiques complexes, généalogies détaillées. Préfère des formulations ouvertes que le MJ validera ("il y a longtemps", "de nombreux", "la haute noblesse")."""
class ChatUseCase:
"""Orchestre un tour de conversation avec le LLM + contexte Lore."""
def __init__(self, llm: LLMChatProvider) -> None:
self._llm = llm
async def stream(
self,
messages: list[ChatMessage],
context: LoreStructuralContext,
page_context: PageContext | None = None,
) -> AsyncIterator[str]:
"""Streame les tokens de la réponse assistant pour le dernier message user.
Si `page_context` est fourni, le system prompt gagne une section
"PAGE EN COURS" qui oriente l'IA vers cette page précise (titre,
template, champs, valeurs actuelles). Sans ce contexte, le chat
reste générique au Lore (comportement avant b8).
"""
system_prompt = self._build_system_prompt(context, page_context)
async for token in self._llm.stream_chat(
messages,
system_prompt=system_prompt,
temperature=_DEFAULT_TEMPERATURE,
):
yield token
def _build_system_prompt(
self,
ctx: LoreStructuralContext,
page_ctx: PageContext | None,
) -> str:
desc = f"\nDescription : {ctx.lore_description}" if ctx.lore_description else ""
folders_block = self._format_folders(ctx.folders)
tags_line = ", ".join(ctx.tags) if ctx.tags else "(aucun)"
prompt = (
f"{_BASE_SYSTEM}\n\n"
f"--- UNIVERS COURANT ---\n"
f"Nom : {ctx.lore_name}{desc}\n\n"
f"Organisation :\n{folders_block}\n\n"
f"Tags déjà utilisés : {tags_line}"
)
if page_ctx is not None:
prompt += "\n\n" + self._format_page_context(page_ctx)
return prompt
@staticmethod
def _format_page_context(pc: PageContext) -> str:
"""Bloc "PAGE EN COURS" — oriente l'IA vers la page précise éditée."""
if pc.template_fields:
fields_block = "\n".join(
f'- "{f}" : {pc.values.get(f) or "(vide)"}'
for f in pc.template_fields
)
else:
fields_block = "(aucun champ défini dans ce template)"
return (
f"--- PAGE EN COURS D'ÉDITION ---\n"
f"Titre : {pc.title}\n"
f"Template : {pc.template_name}\n"
f"Champs et valeurs actuelles :\n{fields_block}\n\n"
f"IMPORTANT : concentre-toi EXCLUSIVEMENT sur cette page. "
f"Si l'utilisateur te demande de proposer des idées, elles doivent "
f"concerner UNIQUEMENT les champs listés ci-dessus. Ne déborde pas "
f"vers d'autres pages ou d'autres templates du Lore, même si ça te "
f"semblerait pertinent."
)
@staticmethod
def _format_folders(folders: dict[str, list[tuple[str, str]]]) -> str:
if not folders:
return "(Lore vide pour l'instant)"
lines: list[str] = []
for folder_name, pages in folders.items():
lines.append(f"- {folder_name} (dossier)")
if not pages:
lines.append(" (vide)")
else:
for title, template in pages:
lines.append(f" - {title} [template: {template}]")
return "\n".join(lines)

View 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
}

View File

29
brain/app/core/config.py Normal file
View 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()

View File

View 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
View 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.
"""

View File

View 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
View 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
View File

@@ -0,0 +1,4 @@
fastapi==0.115.*
uvicorn[standard]==0.32.*
httpx==0.27.*
pydantic-settings==2.6.*

View File

@@ -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>

View File

@@ -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.");
}
}
}

View File

@@ -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());
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -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;
}

View File

@@ -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
);
}

View File

@@ -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;
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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()));
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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();
}
}

View File

@@ -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());
}
}

View File

@@ -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();
}
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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

View File

@@ -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`** :

View File

@@ -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 &lt;values&gt; 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>

View File

@@ -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;
}

View File

@@ -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();
} }

View File

@@ -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>

View File

@@ -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; }
}
} }

View File

@@ -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;

View 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.';
}
}
}

View File

@@ -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 ?? {}));
}
} }

View File

@@ -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>

View 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;
}
}
}

View 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();
}
}