Mise en ligne de la version 0.2.0
All checks were successful
Build & Push Images / build (brain) (push) Successful in 46s
Build & Push Images / build (core) (push) Successful in 1m21s
Build & Push Images / build (web) (push) Successful in 1m25s

This commit is contained in:
2026-04-21 14:25:17 +02:00
parent ebee8e106b
commit ba8a503b3e
300 changed files with 35329 additions and 1 deletions

View File

186
brain/app/domain/models.py Normal file
View File

@@ -0,0 +1,186 @@
"""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, field
@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 PageSummary:
"""Résumé enrichi d'une page du Lore, projeté pour alimenter le prompt.
Depuis b9 : on ne se contente plus du nom + template, on embarque aussi
les valeurs des champs dynamiques (tronquées côté Core Java à 500 car.),
les tags, et les titres des pages liées (les IDs techniques sont déjà
résolus en titres lisibles côté Java — voir LoreStructuralContextBuilder).
Les notes privées du MJ restent volontairement absentes ici (confinées
à leur page d'édition via PageContext quand l'utilisateur y travaille).
"""
title: str
template_name: str
values: dict[str, str]
tags: list[str]
related_page_titles: list[str]
@dataclass(frozen=True)
class LoreStructuralContext:
"""Carte structurelle enrichie d'un Lore pour nourrir l'IA.
Depuis b9 : chaque page expose son contenu (values, tags, liens) via
PageSummary. Le prompt n'est plus qu'une table des matières — c'est
une encyclopédie condensée que le LLM peut directement citer.
Le dict `folders` est indexé par nom de dossier et mappe vers la liste
des pages qu'il contient (PageSummary).
"""
lore_name: str
lore_description: str | None
folders: dict[str, list[PageSummary]]
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]
@dataclass(frozen=True)
class SceneBranchHint:
"""Indice d'une branche narrative vers une autre scène du même chapitre.
Le Core Java résout déjà `targetSceneId` en nom humain avant l'envoi :
l'IA ne voit donc jamais d'UUID, seulement des noms qu'elle peut citer.
"""
label: str
target_scene_name: str
condition: str | None = None
@dataclass(frozen=True)
class SceneSummary:
"""Résumé d'une scène : nom + description courte + illustrations + branches."""
name: str
description: str | None
# Depuis l'etape 6 : permet a l'IA de savoir qu'une scene a des illustrations
# attachees. 0 par defaut pour retrocompat si le Core n'envoie rien.
illustration_count: int = 0
# Connexions narratives sortantes (livre dont vous etes le heros).
branches: list[SceneBranchHint] = field(default_factory=list)
@dataclass(frozen=True)
class ChapterSummary:
"""Résumé d'un chapitre : nom + description courte + ses scènes."""
name: str
description: str | None
scenes: list[SceneSummary]
illustration_count: int = 0
@dataclass(frozen=True)
class ArcSummary:
"""Résumé d'un arc narratif : nom + description courte + ses chapitres."""
name: str
description: str | None
chapters: list[ChapterSummary]
illustration_count: int = 0
@dataclass(frozen=True)
class CampaignStructuralContext:
"""Carte narrative enrichie d'une Campagne pour nourrir l'IA.
Jumeau de LoreStructuralContext côté Campaign. On décrit l'arbre
arcs → chapitres → scènes en donnant le NOM + une DESCRIPTION courte
(synopsis) à chaque niveau. Les champs longs (notes MJ, narration
joueur, combat) restent réservés à l'entité focus via
NarrativeEntityContext. Ordre narratif préservé dans la liste `arcs`.
"""
campaign_name: str
campaign_description: str | None
arcs: list[ArcSummary]
@dataclass(frozen=True)
class NarrativeEntityContext:
"""Contexte d'une entité narrative précise en cours d'édition.
Équivalent de PageContext côté Campaign. Focalise l'IA sur un Arc,
Chapter ou Scene en particulier. `entity_type` ∈ {"arc","chapter","scene"}.
Les `fields` sont une map ordonnée nomChamp → valeurActuelle (chaîne
vide si non renseigné).
"""
entity_type: str
title: str
fields: 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.
"""