Ajout de la partie "Système de jeu" avec toute la partie stockage de règles de notre jeu.
Ajout de possibilité de stocker des fiches de personnages associés à une campagne également (personnages joueurs pour le moment)
This commit is contained in:
@@ -20,6 +20,8 @@ from app.domain.models import (
|
||||
CampaignStructuralContext,
|
||||
ChatMessage,
|
||||
ChapterSummary,
|
||||
CharacterSummary,
|
||||
GameSystemContext,
|
||||
LoreStructuralContext,
|
||||
NarrativeEntityContext,
|
||||
PageContext,
|
||||
@@ -63,16 +65,17 @@ class ChatUseCase:
|
||||
page_context: PageContext | None = None,
|
||||
campaign_context: CampaignStructuralContext | None = None,
|
||||
narrative_entity: NarrativeEntityContext | None = None,
|
||||
game_system_context: GameSystemContext | None = None,
|
||||
) -> AsyncIterator[str]:
|
||||
"""Streame les tokens de la réponse assistant pour le dernier message user.
|
||||
|
||||
Les 4 contextes sont tous optionnels, mais au moins l'un des deux
|
||||
Les contextes sont tous optionnels, mais au moins l'un des deux
|
||||
"niveaux haut" (lore_context ou campaign_context) doit être fourni
|
||||
pour que le prompt ait du sens. Le controller (main.py) applique
|
||||
cette règle à la frontière HTTP.
|
||||
"""
|
||||
system_prompt = self._build_system_prompt(
|
||||
lore_context, page_context, campaign_context, narrative_entity
|
||||
lore_context, page_context, campaign_context, narrative_entity, game_system_context
|
||||
)
|
||||
async for token in self._llm.stream_chat(
|
||||
messages,
|
||||
@@ -87,12 +90,13 @@ class ChatUseCase:
|
||||
page_context: PageContext | None = None,
|
||||
campaign_context: CampaignStructuralContext | None = None,
|
||||
narrative_entity: NarrativeEntityContext | None = None,
|
||||
game_system_context: GameSystemContext | None = None,
|
||||
) -> str:
|
||||
"""Version publique — utilisée par le controller HTTP pour compter
|
||||
les tokens du system prompt avant de streamer (jauge de contexte).
|
||||
"""
|
||||
return self._build_system_prompt(
|
||||
lore_context, page_context, campaign_context, narrative_entity
|
||||
lore_context, page_context, campaign_context, narrative_entity, game_system_context
|
||||
)
|
||||
|
||||
# --- Construction du system prompt --------------------------------------
|
||||
@@ -103,12 +107,15 @@ class ChatUseCase:
|
||||
page: PageContext | None,
|
||||
campaign: CampaignStructuralContext | None,
|
||||
narrative: NarrativeEntityContext | None,
|
||||
game_system: GameSystemContext | None = None,
|
||||
) -> str:
|
||||
sections = [_BASE_SYSTEM]
|
||||
if lore is not None:
|
||||
sections.append(self._format_lore(lore))
|
||||
if campaign is not None:
|
||||
sections.append(self._format_campaign(campaign, lore_present=lore is not None))
|
||||
if game_system is not None:
|
||||
sections.append(self._format_game_system(game_system))
|
||||
if page is not None:
|
||||
sections.append(self._format_page(page))
|
||||
if narrative is not None:
|
||||
@@ -190,14 +197,40 @@ class ChatUseCase:
|
||||
if lore_present
|
||||
else "\n(Cette campagne n'est associée à aucun univers — tu peux proposer des éléments d'ambiance libres.)"
|
||||
)
|
||||
characters_block = ChatUseCase._format_characters(ctx.characters)
|
||||
return (
|
||||
"--- CAMPAGNE COURANTE ---\n"
|
||||
f"Nom : {ctx.campaign_name}{desc}{lore_note}\n\n"
|
||||
f"Nom : {ctx.campaign_name}{desc}{lore_note}\n"
|
||||
f"{characters_block}\n"
|
||||
"Structure narrative (les flèches → indiquent des transitions de scène "
|
||||
"déclenchées par un choix des joueurs) :\n"
|
||||
f"{arcs_block}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _format_characters(characters: list[CharacterSummary]) -> str:
|
||||
"""Bloc PJ — liste nom + snippet. Rappel anti-hallucination IA.
|
||||
|
||||
Si la campagne n'a aucun PJ, on le signale explicitement : l'IA ne
|
||||
doit pas inventer "les héros" ou leurs noms dans ses suggestions.
|
||||
"""
|
||||
if not characters:
|
||||
return (
|
||||
"\nPersonnages joueurs : aucune fiche pour l'instant. Ne suppose "
|
||||
"ni noms ni classes pour les PJ tant que le MJ ne les a pas créés.\n"
|
||||
)
|
||||
lines = ["\nPersonnages joueurs (PJ) :"]
|
||||
for c in characters:
|
||||
if c.snippet:
|
||||
lines.append(f"- **{c.name}** — {c.snippet}")
|
||||
else:
|
||||
lines.append(f"- **{c.name}** (fiche vide)")
|
||||
lines.append(
|
||||
"Pour une fiche complète (stats, backstory), n'invente rien : "
|
||||
"demande au MJ d'ouvrir l'éditeur du PJ pour te donner les détails."
|
||||
)
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
@staticmethod
|
||||
def _format_arcs(arcs: list[ArcSummary]) -> str:
|
||||
if not arcs:
|
||||
@@ -248,12 +281,46 @@ class ChatUseCase:
|
||||
noun = "illustration" if count == 1 else "illustrations"
|
||||
return f" [{count} {noun}]"
|
||||
|
||||
# --- Bloc Système de JDR ------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _format_game_system(gs: GameSystemContext) -> str:
|
||||
"""Bloc des règles du système de JDR de la campagne.
|
||||
|
||||
Les sections ont été filtrées côté Core selon l'intent (combat,
|
||||
classes, lore...). Si aucune section n'a matché, on affiche juste
|
||||
le nom du système comme rappel de cadre.
|
||||
"""
|
||||
desc = f"\nDescription : {gs.system_description}" if gs.system_description else ""
|
||||
if not gs.sections:
|
||||
return (
|
||||
"--- SYSTÈME DE JDR ---\n"
|
||||
f"Nom : {gs.system_name}{desc}\n"
|
||||
"(Aucune section de règles pertinente pour ce type de génération — "
|
||||
"reste cohérent avec l'univers et les conventions du système.)"
|
||||
)
|
||||
sections_block = "\n\n".join(
|
||||
f"### {title}\n{content}" for title, content in gs.sections.items()
|
||||
)
|
||||
return (
|
||||
"--- SYSTÈME DE JDR ---\n"
|
||||
f"Nom : {gs.system_name}{desc}\n\n"
|
||||
"Respecte scrupuleusement les règles et conventions ci-dessous quand "
|
||||
"tu proposes des stats, classes, rencontres, mécaniques ou éléments "
|
||||
"d'ambiance. Les noms propres (classes, sorts, monstres) doivent "
|
||||
"venir de ces règles — n'en invente pas d'autres.\n\n"
|
||||
f"{sections_block}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _format_narrative_entity(ne: NarrativeEntityContext) -> str:
|
||||
"""Bloc équivalent à _format_page mais pour Arc/Chapter/Scene."""
|
||||
type_label = {"arc": "ARC", "chapter": "CHAPITRE", "scene": "SCÈNE"}.get(
|
||||
ne.entity_type.lower(), ne.entity_type.upper()
|
||||
)
|
||||
type_label = {
|
||||
"arc": "ARC",
|
||||
"chapter": "CHAPITRE",
|
||||
"scene": "SCÈNE",
|
||||
"character": "FICHE DE PERSONNAGE",
|
||||
}.get(ne.entity_type.lower(), ne.entity_type.upper())
|
||||
if ne.fields:
|
||||
fields_block = "\n".join(
|
||||
f'- "{key}" : {value or "(vide)"}'
|
||||
|
||||
@@ -169,6 +169,20 @@ class CampaignStructuralContext:
|
||||
campaign_name: str
|
||||
campaign_description: str | None
|
||||
arcs: list[ArcSummary]
|
||||
characters: list["CharacterSummary"] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CharacterSummary:
|
||||
"""Résumé d'un PJ : nom + snippet court extrait du markdown de la fiche.
|
||||
|
||||
La fiche complète n'est JAMAIS dans ce résumé — elle n'arrive que si le PJ
|
||||
est l'entité focus (via NarrativeEntityContext entity_type="character").
|
||||
Ça plafonne le coût token à ~40 tokens/PJ quel que soit le détail des fiches.
|
||||
"""
|
||||
|
||||
name: str
|
||||
snippet: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -184,3 +198,20 @@ class NarrativeEntityContext:
|
||||
entity_type: str
|
||||
title: str
|
||||
fields: dict[str, str]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GameSystemContext:
|
||||
"""Règles d'un système de JDR (D&D, Nimble, homebrew...) injectées
|
||||
dans le system prompt pour que l'IA respecte les mécaniques du jeu.
|
||||
|
||||
Les sections ont été présélectionnées côté Core selon l'intent
|
||||
(SCENE → combat/PNJ, CHAPTER → combat/classes, ARC → lore/factions,
|
||||
GENERIC → toutes). Indexées par titre H2 original.
|
||||
|
||||
Campagne uniquement au MVP : jamais présent sur un chat Lore.
|
||||
"""
|
||||
|
||||
system_name: str
|
||||
system_description: str | None
|
||||
sections: dict[str, str]
|
||||
|
||||
@@ -22,7 +22,9 @@ from app.domain.models import (
|
||||
ArcSummary,
|
||||
CampaignStructuralContext,
|
||||
ChapterSummary,
|
||||
CharacterSummary,
|
||||
ChatMessage,
|
||||
GameSystemContext,
|
||||
LoreStructuralContext,
|
||||
NarrativeEntityContext,
|
||||
PageContext,
|
||||
@@ -196,22 +198,42 @@ class ArcSummaryDTO(BaseModel):
|
||||
illustration_count: int = 0
|
||||
|
||||
|
||||
class CharacterSummaryDTO(BaseModel):
|
||||
"""Résumé d'un PJ : nom + snippet. Pas de fiche complète au niveau résumé."""
|
||||
|
||||
name: str
|
||||
snippet: str = ""
|
||||
|
||||
|
||||
class CampaignContextDTO(BaseModel):
|
||||
"""Carte narrative enrichie : arcs → chapitres → scènes avec synopsis."""
|
||||
|
||||
campaign_name: str
|
||||
campaign_description: str | None = None
|
||||
arcs: list[ArcSummaryDTO] = Field(default_factory=list)
|
||||
characters: list[CharacterSummaryDTO] = Field(default_factory=list)
|
||||
|
||||
|
||||
class NarrativeEntityDTO(BaseModel):
|
||||
"""Entité narrative (arc/chapter/scene) en cours d'édition — focus optionnel."""
|
||||
"""Entité narrative (arc/chapter/scene/character) en cours d'édition — focus optionnel."""
|
||||
|
||||
entity_type: str = Field(pattern="^(arc|chapter|scene)$")
|
||||
entity_type: str = Field(pattern="^(arc|chapter|scene|character)$")
|
||||
title: str
|
||||
fields: dict[str, str] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class GameSystemContextDTO(BaseModel):
|
||||
"""Règles de JDR présélectionnées par le Core (filtrées par intent).
|
||||
|
||||
Les sections sont un dict titre_H2 → contenu_markdown. Peuvent être
|
||||
vides si aucune section ne matchait l'intent de génération courant.
|
||||
"""
|
||||
|
||||
system_name: str
|
||||
system_description: str | None = None
|
||||
sections: dict[str, str] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class ChatStreamRequestDTO(BaseModel):
|
||||
"""Requête de chat streamé : historique + contextes structurels.
|
||||
|
||||
@@ -226,6 +248,7 @@ class ChatStreamRequestDTO(BaseModel):
|
||||
page_context: PageContextDTO | None = None
|
||||
campaign_context: CampaignContextDTO | None = None
|
||||
narrative_entity: NarrativeEntityDTO | None = None
|
||||
game_system_context: GameSystemContextDTO | None = None
|
||||
|
||||
def has_scope(self) -> bool:
|
||||
"""Vrai si au moins un contexte racine (Lore ou Campagne) est fourni."""
|
||||
@@ -352,6 +375,7 @@ async def chat_stream(
|
||||
page_context = _to_page_context(body.page_context)
|
||||
campaign_context = _to_campaign_context(body.campaign_context)
|
||||
narrative_entity = _to_narrative_entity(body.narrative_entity)
|
||||
game_system_context = _to_game_system_context(body.game_system_context)
|
||||
|
||||
# --- Comptage tokens pour la jauge de contexte frontend ---
|
||||
# On construit le system prompt une fois ici pour le compter — le use case
|
||||
@@ -363,6 +387,7 @@ async def chat_stream(
|
||||
page_context=page_context,
|
||||
campaign_context=campaign_context,
|
||||
narrative_entity=narrative_entity,
|
||||
game_system_context=game_system_context,
|
||||
)
|
||||
# Dernier message = "current" (souvent user), le reste = historique accumulé.
|
||||
current_msg = messages[-1] if messages else None
|
||||
@@ -386,6 +411,7 @@ async def chat_stream(
|
||||
page_context=page_context,
|
||||
campaign_context=campaign_context,
|
||||
narrative_entity=narrative_entity,
|
||||
game_system_context=game_system_context,
|
||||
):
|
||||
# json.dumps avec ensure_ascii=False pour préserver les accents
|
||||
yield f"data: {json.dumps({'token': token}, ensure_ascii=False)}\n\n"
|
||||
@@ -523,10 +549,15 @@ def _to_campaign_context(dto: CampaignContextDTO | None) -> CampaignStructuralCo
|
||||
)
|
||||
for arc in dto.arcs
|
||||
]
|
||||
characters = [
|
||||
CharacterSummary(name=c.name, snippet=c.snippet)
|
||||
for c in dto.characters
|
||||
]
|
||||
return CampaignStructuralContext(
|
||||
campaign_name=dto.campaign_name,
|
||||
campaign_description=dto.campaign_description,
|
||||
arcs=arcs,
|
||||
characters=characters,
|
||||
)
|
||||
|
||||
|
||||
@@ -742,3 +773,13 @@ def _to_narrative_entity(dto: NarrativeEntityDTO | None) -> NarrativeEntityConte
|
||||
title=dto.title,
|
||||
fields=dict(dto.fields),
|
||||
)
|
||||
|
||||
|
||||
def _to_game_system_context(dto: GameSystemContextDTO | None) -> GameSystemContext | None:
|
||||
if dto is None:
|
||||
return None
|
||||
return GameSystemContext(
|
||||
system_name=dto.system_name,
|
||||
system_description=dto.system_description,
|
||||
sections=dict(dto.sections),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user