Files
LoreMind/brain/app/domain/models.py
IETM_FIXE\ietm6 6d00543a59
Some checks failed
Build & Push Images / build (web) (push) Has been cancelled
Build & Push Images / build-switcher (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (brain) (push) Has been cancelled
Ajout de 2 fonctionnalitées principales : import PDF que ce soit pour les règles ou les campagnes directement.
Fonctionnalité de comparaison PDF / campagne pour faire un mix et demander des conseils à l'IA
2026-06-04 13:55:27 +02:00

429 lines
13 KiB
Python

"""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 RoomBranchHint:
"""Indice d'une sortie entre pièces (donjon). target_room_name déjà résolu côté Core."""
label: str
target_room_name: str
condition: str | None = None
@dataclass(frozen=True)
class RoomSummary:
"""Pièce d'un lieu explorable. Projection plate pour le prompt IA (pas de notes MJ)."""
name: str
floor: int | None = None
description: str | None = None
enemies: str | None = None
branches: list[RoomBranchHint] = field(default_factory=list)
@dataclass(frozen=True)
class SceneSummary:
"""Résumé d'une scène : nom + description courte + illustrations + branches + pièces."""
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)
# Pièces du lieu explorable (vide = scène classique).
rooms: list[RoomSummary] = 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]
characters: list["CharacterSummary"] = field(default_factory=list)
npcs: list["NpcSummary"] = 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)
class NpcSummary:
"""Résumé d'un PNJ : symétrique à CharacterSummary.
Permet à l'IA de connaître les PNJ d'une campagne (nom + snippet) sans
injecter leurs fiches complètes. Évolution prévue : entity_type="npc"
pour focus sur la fiche complète.
"""
name: str
snippet: str
@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]
@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]
@dataclass(frozen=True)
class JournalEntrySummary:
"""Une entrée du journal d'une Session.
`source_session_name` n'est renseigné que pour les entrées issues de
sessions précédentes (option 3 : continuité narrative entre séances).
"""
type: str
content: str
occurred_at: str | None
source_session_name: str | None = None
@dataclass(frozen=True)
class QuestSummary:
"""Résumé d'une quête (Chapter dans un Arc HUB) pour le system prompt.
Volontairement sans notes MJ ni statut texte : c'est déjà classé côté Core
dans available_quests / in_progress_quests / locked_quest_titles.
"""
name: str
arc_name: str
description: str | None = None
@dataclass(frozen=True)
class SessionContext:
"""Contexte d'une Session de jeu en cours (Play Context).
Combine plusieurs niveaux :
- `entries` : journal COMPLET de la session courante (cappé ~80 entrées)
- `previous_events` : EVENTs marquants des sessions précédentes (continuité)
- `available_quests` / `in_progress_quests` : quêtes du Hub ouvertes
- `locked_quest_titles` : titres seuls des quêtes verrouillées (anti-spoiler)
- `active_flags` : noms des flags de campagne actuellement à true
"""
session_name: str
active: bool
started_at: str | None
entries: list[JournalEntrySummary]
previous_events: list[JournalEntrySummary]
available_quests: list[QuestSummary] = field(default_factory=list)
in_progress_quests: list[QuestSummary] = field(default_factory=list)
locked_quest_titles: list[str] = field(default_factory=list)
active_flags: list[str] = field(default_factory=list)
# ─────────────────────── Import de PDF (règles → GameSystem) ───────────────────────
@dataclass(frozen=True)
class ExtractedPage:
"""Texte extrait d'UNE page de PDF, avec la trace de la méthode utilisée.
`used_ocr=True` signale que la page n'avait pas de couche texte exploitable
(born-digital absent) et a donc été rasterisée puis passée à l'OCR. Permet
au CLI/diagnostic de dire à l'utilisateur si son PDF est "texte" ou "scan".
"""
index: int # 0-based
text: str
used_ocr: bool
@dataclass(frozen=True)
class ExtractedDocument:
"""Résultat brut de l'extraction d'un PDF : une entrée par page."""
pages: list[ExtractedPage]
@property
def page_count(self) -> int:
return len(self.pages)
@property
def ocr_page_count(self) -> int:
return sum(1 for p in self.pages if p.used_ocr)
@property
def full_text(self) -> str:
"""Concatène le texte de toutes les pages, séparées par un saut double."""
return "\n\n".join(p.text for p in self.pages if p.text.strip())
@dataclass(frozen=True)
class RulesImportResult:
"""Proposition structurée de règles : sections markdown indexées par titre.
`sections` = {titre H2 → contenu markdown}. C'est une PROPOSITION : rien
n'est persisté côté Core tant que l'utilisateur n'a pas validé/édité.
`page_count` / `ocr_page_count` remontent au diagnostic d'extraction.
"""
sections: dict[str, str]
page_count: int
ocr_page_count: int
def to_markdown(self) -> str:
"""Assemble les sections en un markdown monolithique (## titre + contenu).
Format aligné sur `GameSystem.rulesMarkdown` côté Core (découpé par H2).
"""
blocks = [f"## {title}\n\n{content.strip()}" for title, content in self.sections.items()]
return "\n\n".join(blocks).strip() + "\n"
# ─────────────────────── Import de PDF de campagne (arbre arc→chapitre→scène) ──────────────
@dataclass(frozen=True)
class RoomProposal:
"""Pièce d'un lieu explorable (donjon) proposée pour une scène."""
name: str
description: str
enemies: str = ""
loot: str = ""
@dataclass(frozen=True)
class SceneProposal:
"""Scène proposée. `rooms` non vide => donjon/lieu explorable.
On capture aussi, quand le livre les fournit, le texte d'encadré « à lire aux
joueurs » (`player_narration`) et les secrets/développement MJ (`gm_notes`).
"""
name: str
description: str
player_narration: str = ""
gm_notes: str = ""
rooms: list[RoomProposal] = field(default_factory=list)
@dataclass(frozen=True)
class ChapterProposal:
"""Chapitre proposé : nom + synopsis + ses scènes."""
name: str
description: str
scenes: list[SceneProposal] = field(default_factory=list)
@dataclass(frozen=True)
class ArcProposal:
"""Arc proposé : nom + synopsis + type (LINEAR/HUB) + ses chapitres."""
name: str
description: str
arc_type: str = "LINEAR"
chapters: list[ChapterProposal] = field(default_factory=list)
@dataclass(frozen=True)
class CampaignImportResult:
"""Proposition d'arborescence narrative extraite d'un PDF de campagne.
PROPOSITION non persistée : l'UI laisse l'utilisateur réviser/éditer l'arbre
avant la création effective des arcs/chapitres/scènes côté Core.
"""
arcs: list[ArcProposal]
page_count: int
ocr_page_count: int
def counts(self) -> tuple[int, int, int]:
"""(nb arcs, nb chapitres, nb scènes) — pour le diagnostic / la progression."""
chapters = sum(len(a.chapters) for a in self.arcs)
scenes = sum(len(c.scenes) for a in self.arcs for c in a.chapters)
return len(self.arcs), chapters, scenes