diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..8ca0b1d --- /dev/null +++ b/.env.example @@ -0,0 +1,40 @@ +# ========================================================================== +# Configuration LoreMindMJ - copier en .env et adapter +# ========================================================================== + +# --- Registry Gitea (ou celui de l'editeur) ---------------------------- +REGISTRY=git.igmlcreation.fr +TAG=latest + +# --- Port d'acces web ---------------------------------------------------- +WEB_PORT=8081 + +# --- PostgreSQL (IMPORTANT : change POSTGRES_PASSWORD) ------------------- +POSTGRES_DB=loremind +POSTGRES_USER=loremind +POSTGRES_PASSWORD=change-me-please + +# --- Securite : admin HTTP Basic pour /api/settings/** ------------------- +# Sans ces variables, l'app refuse de demarrer. Generez un mot de passe fort. +ADMIN_USERNAME=admin +ADMIN_PASSWORD=change-me-please + +# --- Securite : secret partage Core <-> Brain ---------------------------- +# Empeche un attaquant d'atteindre le Brain directement. Generez 32+ chars +# aleatoires, ex : openssl rand -hex 32 +BRAIN_INTERNAL_SECRET=change-me-use-openssl-rand-hex-32 + +# --- MinIO (stockage objet images) --------------------------------------- +MINIO_USER=minioadmin +MINIO_PASSWORD=minioadmin + +# --- Provider LLM : "ollama" (local) ou "onemin" (cloud 1min.ai) --------- +LLM_PROVIDER=ollama + +# Ollama (si LLM_PROVIDER=ollama) +OLLAMA_BASE_URL=http://host.docker.internal:11434 +LLM_MODEL=gemma4:26b + +# 1min.ai (si LLM_PROVIDER=onemin) +ONEMIN_API_KEY= +ONEMIN_MODEL=gpt-4o-mini diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..bae8ebb --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,44 @@ +name: Build & Push Images + +on: + push: + tags: + - 'v*' + +env: + REGISTRY: git.igmlcreation.fr + REGISTRY_USER: ietm64 + +jobs: + build: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + component: [brain, core, web] + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to Gitea Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ env.REGISTRY_USER }} + password: ${{ secrets.DOCKER_PAT }} + + - name: Extract version + id: meta + run: echo "version=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT + + - name: Build & push ${{ matrix.component }} + uses: docker/build-push-action@v5 + with: + context: ./${{ matrix.component }} + push: true + tags: | + ${{ env.REGISTRY }}/${{ env.REGISTRY_USER }}/${{ matrix.component }}:latest + ${{ env.REGISTRY }}/${{ env.REGISTRY_USER }}/${{ matrix.component }}:${{ steps.meta.outputs.version }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d170b91 --- /dev/null +++ b/.gitignore @@ -0,0 +1,92 @@ +# ============================================================================ +# Secrets & runtime config — NE JAMAIS committer +# ============================================================================ +.env +.env.local +.env.*.local +brain/data/settings.json +*.key +*.pem + +# ============================================================================ +# Java / Spring Boot / Maven +# ============================================================================ +target/ +*.class +hs_err_pid* +pom.xml.tag +pom.xml.releaseBackup +pom.xml.versionsBackup +pom.xml.next +release.properties +dependency-reduced-pom.xml +buildNumber.properties +.mvn/timing.properties +.mvn/wrapper/maven-wrapper.jar + +# ============================================================================ +# Python / FastAPI (Brain) +# ============================================================================ +__pycache__/ +*.py[cod] +*$py.class +*.egg-info/ +.venv/ +venv/ +env/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.coverage +htmlcov/ + +# ============================================================================ +# Angular / Node (Web) +# ============================================================================ +node_modules/ +dist/ +.angular/ +.cache/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* +coverage/ + +# ============================================================================ +# IDE / Editeurs +# ============================================================================ +.idea/ +*.iml +.vscode/ +.run/ +*.swp +*.swo +*~ + +# ============================================================================ +# OS +# ============================================================================ +.DS_Store +Thumbs.db + +# ============================================================================ +# Logs +# ============================================================================ +*.log + +# ============================================================================ +# Assistants IA / Outils locaux +# ============================================================================ +.claude/ +.windsurfrules + +# ============================================================================ +# Documentation hors-code (conservee hors du repo) +# ============================================================================ +docs/ + +# ============================================================================ +# Docker Compose override (dev uniquement, non-distribue aux end users) +# ============================================================================ +docker-compose.override.yml diff --git a/INSTALL.md b/INSTALL.md new file mode 100644 index 0000000..5075d60 --- /dev/null +++ b/INSTALL.md @@ -0,0 +1,75 @@ +# Installation de LoreMindMJ + +## Prerequis + +- **Docker Desktop** ([Windows](https://www.docker.com/products/docker-desktop/) / [Mac](https://www.docker.com/products/docker-desktop/)) + ou **Docker Engine + Compose v2** (Linux). +- (Optionnel) **[Ollama](https://ollama.com/)** si tu veux un LLM local. + Sinon, une cle API [1min.ai](https://1min.ai) suffit. + +## Installation (5 minutes) + +1. Telecharge `docker-compose.yml` et `.env.example` depuis la [derniere release](https://git.igmlcreation.fr/ietm64/LoreMindMJ/releases) dans un dossier a toi. + +2. Renomme `.env.example` en `.env` et ouvre-le dans un editeur texte. Trois variables sont **obligatoires** : + - `POSTGRES_PASSWORD` : mot de passe de la base (choisis-en un). + - `ADMIN_PASSWORD` : protege l'ecran Parametres de l'appli. Tu le taperas dans une popup du navigateur. + - `BRAIN_INTERNAL_SECRET` : secret interne partage entre les services. Genere une valeur aleatoire : + ``` + openssl rand -hex 32 + ``` + (Sous Windows sans openssl : utilise un generateur en ligne type "random hex string 64 chars".) + + Sans ces trois variables, `docker compose up` refusera de demarrer — c'est volontaire pour eviter un deploiement non-securise par defaut. + +3. Dans un terminal, place-toi dans le dossier et lance : + ``` + docker compose up -d + ``` + Le premier demarrage telecharge les images (~500 Mo) et initialise la base. Compte 1-2 minutes. + +4. Ouvre http://localhost:8081 dans ton navigateur. Bon jeu ! + +## Mise a jour + +``` +docker compose pull +docker compose up -d +``` + +Les donnees (base Postgres, images MinIO, settings Brain) sont dans des volumes Docker et survivent aux mises a jour. + +## LLM : Ollama ou 1min.ai ? + +**Ollama (local, gratuit)** — Edite `.env` : +``` +LLM_PROVIDER=ollama +LLM_MODEL=gemma4:26b +``` +Telecharge le modele au prealable : `ollama pull gemma4:26b`. + +**1min.ai (cloud, paye)** — Edite `.env` : +``` +LLM_PROVIDER=onemin +ONEMIN_API_KEY=sk-... +ONEMIN_MODEL=open-mistral-nemo +``` + +Tu peux aussi changer tout ca a chaud depuis l'ecran Parametres de l'appli. + +## Problemes frequents + +- **Port 8081 deja pris** : change `WEB_PORT=8082` (ou autre) dans `.env`. +- **Ollama injoignable** : verifie qu'Ollama tourne (`ollama serve`) et que le modele est bien telecharge. +- **"set ADMIN_PASSWORD in .env" / "set BRAIN_INTERNAL_SECRET in .env"** au lancement : tu as oublie une des variables obligatoires de l'etape 2. +- **Popup "Ce site vous demande de vous connecter" sur l'ecran Parametres** : c'est normal. Utilise `admin` (ou ce que tu as mis dans `ADMIN_USERNAME`) et ton `ADMIN_PASSWORD`. +- **Tout casser et repartir de zero** : `docker compose down -v` supprime les volumes (attention, perte de donnees). + +## Sauvegarde + +Les donnees sont dans les volumes Docker : `loremindmj_postgres-data`, `loremindmj_minio-data`, `loremindmj_brain-data`. + +Sauvegarde rapide de la base : +``` +docker compose exec postgres pg_dump -U loremind loremind > backup.sql +``` diff --git a/README.md b/README.md index df1209f..1463469 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,70 @@ # LoreMind -Outil pour Maîtres du Jeu : gestion de lore et de campagne assistée par LLM. \ No newline at end of file +Application web d'aide aux Maîtres de Jeu (JDR) pour centraliser la gestion de l'univers (Lore) et le suivi des campagnes, avec un moteur IA intégré pour générer du contenu structuré. + +## Fonctionnalités + +- Gestion centralisée du Lore : Lieux, Factions, PNJ, et tous les éléments de votre univers +- Suivi de campagnes : Sessions, actions des joueurs, chronologie +- Moteur IA intégré : Génération automatique de contenu (PNJ, Villes, Quêtes) à partir de templates +- Export vers FoundryVTT : Transfert structuré des données vers votre VTT préféré (en développement) + +## Captures d'écran + +### Page d'accueil +![Accueil](docs/maquettes/général/Accueil.png) + +### Recherche +![Recherche](docs/maquettes/général/Ecran de recherche.png) + +## Stack Technologique + +LoreMind utilise une architecture distribuée pour séparer les responsabilités : + +- **Frontend** : Angular (Interface utilisateur, affichage du lore, formulaires de templates) +- **Backend Core** : Java (Spring Boot) - Orchestration, persistance, export VTT +- **Backend IA** : Python - Traitement des LLM et génération de contenu +- **Base de données** : PostgreSQL avec JSONB pour les templates flexibles + +## Architecture + +### Backend Java (Domain-Driven Design & Hexagonal) + +Le Backend Core respecte strictement : +- **Domain-Driven Design (DDD)** : Séparation en Bounded Contexts autonomes +- **Architecture Hexagonale (Ports et Adaptateurs)** : Domaine pur sans dépendances techniques + +#### Bounded Contexts +- **LoreContext** : Gestion de l'encyclopédie de l'univers +- **CampaignContext** : Suivi des sessions et chronologie +- **GenerationContext** : Gestion des requêtes IA et templates + +#### Couches +- **Domaine (Core)** : Entités métier pures et interfaces (Ports) +- **Application** : Orchestration des flux (Use Cases) +- **Infrastructure** : Implémentation technique (Adapters) + +## Installation + +Pour installer LoreMind chez vous (Docker requis), suivez le guide **[INSTALL.md](INSTALL.md)** — 3 étapes, 5 minutes chrono : + +1. Télécharger `docker-compose.yml` + `.env.example` depuis la [dernière release](https://git.igmlcreation.fr/ietm64/LoreMindMJ/releases) +2. Renommer `.env.example` → `.env` et changer `POSTGRES_PASSWORD` +3. `docker compose up -d` → ouvrir http://localhost:8081 + +Mise à jour : `docker compose pull && docker compose up -d`. + +## Développement (contributeurs) + +Pour builder les images localement depuis les sources : + +```bash +git clone https://git.igmlcreation.fr/ietm64/LoreMindMJ.git +cd LoreMindMJ +# Créer un docker-compose.override.yml local (voir docs de contrib) +docker compose up -d --build +``` + +## License + +[À définir] diff --git a/brain/.dockerignore b/brain/.dockerignore new file mode 100644 index 0000000..f5d17c7 --- /dev/null +++ b/brain/.dockerignore @@ -0,0 +1,6 @@ +data/ +__pycache__/ +*.pyc +.env +.venv/ +venv/ diff --git a/brain/.env.example b/brain/.env.example new file mode 100644 index 0000000..4e8d1d2 --- /dev/null +++ b/brain/.env.example @@ -0,0 +1,13 @@ +# Configuration du Brain LoreMind +# Copier en .env et ajuster selon l'environnement local + +# Adresse du serveur Ollama +OLLAMA_BASE_URL=http://localhost:11434 + +# Modèle LLM à utiliser +# Petit / rapide pour dev : gemma4:e2b +# Grand / qualité : gemma4:27b +LLM_MODEL=gemma4:e2b + +# Timeout en secondes (les grands modèles sont parfois lents) +LLM_TIMEOUT_SECONDS=120 diff --git a/brain/.gitignore b/brain/.gitignore new file mode 100644 index 0000000..a98520d --- /dev/null +++ b/brain/.gitignore @@ -0,0 +1,4 @@ +.venv/ +__pycache__/ +*.pyc +.env diff --git a/brain/Dockerfile b/brain/Dockerfile new file mode 100644 index 0000000..c3c4379 --- /dev/null +++ b/brain/Dockerfile @@ -0,0 +1,16 @@ +FROM python:3.12-slim +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends curl \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY app ./app + +RUN mkdir -p /app/data +VOLUME ["/app/data"] + +EXPOSE 8000 +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/brain/app/__init__.py b/brain/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/brain/app/application/__init__.py b/brain/app/application/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/brain/app/application/chat.py b/brain/app/application/chat.py new file mode 100644 index 0000000..4466b3e --- /dev/null +++ b/brain/app/application/chat.py @@ -0,0 +1,258 @@ +"""Use case : chat conversationnel LoreMind avec Structural Context. + +Construit un system prompt riche à partir de 4 contextes possibles +(Lore, Page focalisée, Campagne, entité narrative focalisée) puis délègue +au port `LLMChatProvider` pour le streaming token par token. + +Ne charge PAS le contenu détaillé des pages — l'IA doit savoir ce qui +existe, pas être noyée sous le texte. Pattern "Structural Context", plus +simple que le RAG sémantique tant que les univers restent de taille humaine. + +Combinaisons supportées : + - lore seul → chat Lore (page-edit / page-create) + - lore + page_context → chat Lore focalisé page + - campaign (+lore si liée) + optional narrative_entity → chat Campagne +""" +from typing import AsyncIterator + +from app.domain.models import ( + ArcSummary, + CampaignStructuralContext, + ChatMessage, + ChapterSummary, + LoreStructuralContext, + NarrativeEntityContext, + PageContext, + PageSummary, +) +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 et ses campagnes. + +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 le contexte existant ci-dessous. + +Règles de cohérence (IMPORTANT) : +- Tu PEUX et DOIS inventer des éléments originaux (personnages, lieux, objets, intrigues, créatures, scènes) — c'est ton rôle d'assistant créatif. +- Tu ne peux PAS faire référence à un élément du MJ (du Lore, des arcs, chapitres ou scènes) comme s'il existait déjà, SAUF s'il apparaît EXACTEMENT (même orthographe) dans l'une des sections de contexte ci-dessous. +- Si l'utilisateur mentionne un nom que tu ne vois pas dans le contexte, ne fais surtout pas semblant de le connaître : dis clairement "Je ne vois pas [nom] dans le contexte 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 + contextes structurels.""" + + def __init__(self, llm: LLMChatProvider) -> None: + self._llm = llm + + async def stream( + self, + messages: list[ChatMessage], + *, + lore_context: LoreStructuralContext | None = None, + page_context: PageContext | None = None, + campaign_context: CampaignStructuralContext | None = None, + narrative_entity: NarrativeEntityContext | 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 + "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 + ) + async for token in self._llm.stream_chat( + messages, + system_prompt=system_prompt, + temperature=_DEFAULT_TEMPERATURE, + ): + yield token + + # --- Construction du system prompt -------------------------------------- + + def _build_system_prompt( + self, + lore: LoreStructuralContext | None, + page: PageContext | None, + campaign: CampaignStructuralContext | None, + narrative: NarrativeEntityContext | 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 page is not None: + sections.append(self._format_page(page)) + if narrative is not None: + sections.append(self._format_narrative_entity(narrative)) + return "\n\n".join(sections) + + # --- Blocs Lore --------------------------------------------------------- + + @staticmethod + def _format_lore(ctx: LoreStructuralContext) -> str: + desc = f"\nDescription : {ctx.lore_description}" if ctx.lore_description else "" + folders_block = ChatUseCase._format_folders(ctx.folders) + tags_line = ", ".join(ctx.tags) if ctx.tags else "(aucun)" + return ( + "--- UNIVERS (Lore) ---\n" + f"Nom : {ctx.lore_name}{desc}\n\n" + f"Organisation :\n{folders_block}\n\n" + f"Tags déjà utilisés : {tags_line}" + ) + + @staticmethod + def _format_folders(folders: dict[str, list[PageSummary]]) -> str: + """Rend chaque page avec son contenu exploitable par le LLM. + + Depuis b9 : affiche en plus des champs values/tags/pages liées sous + forme d'une fiche indentée par page, et seulement si l'info existe + (prompt compact quand une page est vierge). + """ + 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)") + continue + for ps in pages: + lines.append(f" - {ps.title} [template: {ps.template_name}]") + for field_name, value in ps.values.items(): + lines.append(f" · {field_name} : {value}") + if ps.tags: + lines.append(f" · tags : {', '.join(ps.tags)}") + if ps.related_page_titles: + lines.append( + " · liée à : " + ", ".join(ps.related_page_titles) + ) + return "\n".join(lines) + + @staticmethod + def _format_page(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 ( + "--- 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" + "IMPORTANT : concentre-toi EXCLUSIVEMENT sur cette page. " + "Si l'utilisateur te demande de proposer des idées, elles doivent " + "concerner UNIQUEMENT les champs listés ci-dessus. Ne déborde pas " + "vers d'autres pages ou d'autres templates du Lore, même si ça te " + "semblerait pertinent." + ) + + # --- Blocs Campagne ----------------------------------------------------- + + @staticmethod + def _format_campaign(ctx: CampaignStructuralContext, *, lore_present: bool) -> str: + desc = f"\nDescription : {ctx.campaign_description}" if ctx.campaign_description else "" + arcs_block = ChatUseCase._format_arcs(ctx.arcs) + lore_note = ( + "\n(Cette campagne est liée à l'univers ci-dessus : tu peux t'appuyer dessus.)" + if lore_present + else "\n(Cette campagne n'est associée à aucun univers — tu peux proposer des éléments d'ambiance libres.)" + ) + return ( + "--- CAMPAGNE COURANTE ---\n" + f"Nom : {ctx.campaign_name}{desc}{lore_note}\n\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_arcs(arcs: list[ArcSummary]) -> str: + if not arcs: + return "(Aucun arc créé pour l'instant.)" + lines: list[str] = [] + for arc in arcs: + lines.append(f"- {arc.name} (arc){ChatUseCase._illustration_hint(arc.illustration_count)}") + if arc.description: + lines.append(f" Synopsis : {arc.description}") + if not arc.chapters: + lines.append(" (aucun chapitre)") + continue + for chapter in arc.chapters: + lines.extend(ChatUseCase._format_chapter_block(chapter)) + return "\n".join(lines) + + @staticmethod + def _format_chapter_block(chapter: ChapterSummary) -> list[str]: + hint = ChatUseCase._illustration_hint(chapter.illustration_count) + block = [f" - {chapter.name} (chapitre){hint}"] + if chapter.description: + block.append(f" Synopsis : {chapter.description}") + if not chapter.scenes: + block.append(" (aucune scène)") + else: + for scene in chapter.scenes: + sc_hint = ChatUseCase._illustration_hint(scene.illustration_count) + block.append(f" - {scene.name} (scène){sc_hint}") + if scene.description: + block.append(f" Description : {scene.description}") + for br in scene.branches: + cond = f" (si : {br.condition})" if br.condition else "" + block.append( + f' → "{br.label}" vers {br.target_scene_name}{cond}' + ) + return block + + @staticmethod + def _illustration_hint(count: int) -> str: + """Rend " [N illustrations]" si count > 0, sinon chaine vide. + + Informe l'IA que l'entite a deja un support visuel. Permet de prioriser + les suggestions ecrites qui collent a l'existant visuel plutot que de + diverger. + """ + if count <= 0: + return "" + noun = "illustration" if count == 1 else "illustrations" + return f" [{count} {noun}]" + + @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() + ) + if ne.fields: + fields_block = "\n".join( + f'- "{key}" : {value or "(vide)"}' + for key, value in ne.fields.items() + ) + else: + fields_block = "(aucun champ renseigné)" + return ( + f"--- {type_label} EN COURS D'ÉDITION ---\n" + f"Titre : {ne.title}\n" + f"Champs et valeurs actuelles :\n{fields_block}\n\n" + "IMPORTANT : concentre-toi EXCLUSIVEMENT sur cette entité narrative. " + "Tes suggestions doivent enrichir UNIQUEMENT les champs listés ci-dessus. " + "Ne déborde pas vers d'autres arcs, chapitres ou scènes de la campagne, " + "même si ça te semblerait pertinent." + ) diff --git a/brain/app/application/generate_page.py b/brain/app/application/generate_page.py new file mode 100644 index 0000000..5cc499c --- /dev/null +++ b/brain/app/application/generate_page.py @@ -0,0 +1,99 @@ +"""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) + + @staticmethod + def _build_prompt(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." + ) + + @staticmethod + def _parse_values( + 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 + } diff --git a/brain/app/core/__init__.py b/brain/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/brain/app/core/config.py b/brain/app/core/config.py new file mode 100644 index 0000000..b9c54ab --- /dev/null +++ b/brain/app/core/config.py @@ -0,0 +1,59 @@ +"""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. + +Depuis l'ecran Parametres (UI) : certains champs sont surchargeables a chaud +via `settings_store` (fichier JSON). A chaque Depends(get_settings), on relit +.env + overrides fusionnes. Pas de cache : le cout d'un read JSON local est +negligeable face a un appel LLM. +""" +from typing import Literal + +from pydantic_settings import BaseSettings, SettingsConfigDict + +from app.core.settings_store import load_overrides + + +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", + ) + + # Provider LLM actif. "ollama" = local ; "onemin" = 1min.ai (etage 2). + llm_provider: Literal["ollama", "onemin"] = "ollama" + + ollama_base_url: str = "http://localhost:11434" + llm_model: str = "gemma4:26b" + llm_timeout_seconds: int = 120 + + # Fenêtre de contexte (num_ctx Ollama). Défaut Ollama = 2048, trop étroit + # dès que le Structural Context du Lore dépasse ~10 pages (b9). On monte + # à 16384 pour tenir ~100 pages enrichies. Coût VRAM : ~600 MB de KV cache + # supplémentaire (vs 2048) pour le modèle gemma 2B. Surchargeable via + # LLM_NUM_CTX dans .env si besoin (ex: VRAM limitée → 8192). + llm_num_ctx: int = 16384 + + # 1min.ai (etage 2) — la cle et le modele sont stockes via settings_store + # (modifiables depuis l'UI). Les defauts ici sont juste des placeholders. + onemin_api_key: str = "" + onemin_model: str = "gpt-4o-mini" + + # Secret partage entre le Core Spring et le Brain. Le Brain n'accepte une + # requete que si l'entete X-Internal-Secret correspond. Volontairement + # non-surchargeable via settings_store (securite critique, .env-only). + internal_shared_secret: str = "" + + +def get_settings() -> Settings: + """Fabrique des Settings merges (.env -> overrides runtime). + + Relu a chaque requete HTTP (via Depends). Permet a l'UI de changer + le modele / provider sans redemarrer le Brain. + """ + return Settings(**load_overrides()) diff --git a/brain/app/core/settings_store.py b/brain/app/core/settings_store.py new file mode 100644 index 0000000..b648c01 --- /dev/null +++ b/brain/app/core/settings_store.py @@ -0,0 +1,61 @@ +"""Overrides runtime persistés sur disque pour les Settings. + +Les Settings par defaut viennent de .env (12-factor). L'utilisateur peut +surcharger certains champs depuis l'UI (ex: modele Ollama choisi) — ces +overrides sont stockes dans un fichier JSON local, relus a chaque requete. + +Thread-safe via un lock simple : suffisant pour un deploiement mono-process +(usage local). Si un jour on passe en multi-worker, migrer vers SQLite. +""" +from __future__ import annotations + +import json +import threading +from pathlib import Path +from typing import Any + +_LOCK = threading.Lock() +_OVERRIDES_PATH = Path("data/settings.json") + +# Allow-list stricte des cles persistables via l'API. Toute autre cle est +# silencieusement ignoree — empeche un appelant de polluer settings.json +# avec des champs arbitraires (ex: `internal_shared_secret`) ou d'exposer +# un vecteur SSRF/credential-swap via un champ non-documente. +_ALLOWED_KEYS = frozenset({ + "llm_provider", + "ollama_base_url", + "llm_model", + "llm_timeout_seconds", + "llm_num_ctx", + "onemin_api_key", + "onemin_model", +}) + + +def load_overrides() -> dict[str, Any]: + """Retourne le dict d'overrides, ou {} si le fichier n'existe pas / est corrompu.""" + if not _OVERRIDES_PATH.exists(): + return {} + try: + raw = json.loads(_OVERRIDES_PATH.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return {} + if not isinstance(raw, dict): + return {} + # Defense en profondeur au chargement : si settings.json contient des + # cles hors allow-list (heritage d'un ancien binaire), on les ignore. + return {k: v for k, v in raw.items() if k in _ALLOWED_KEYS} + + +def save_overrides(patch: dict[str, Any]) -> dict[str, Any]: + """Fusionne `patch` (cles allow-listees uniquement) et persiste.""" + filtered = {k: v for k, v in patch.items() if k in _ALLOWED_KEYS} + with _LOCK: + current = load_overrides() + current.update(filtered) + _OVERRIDES_PATH.parent.mkdir(parents=True, exist_ok=True) + _OVERRIDES_PATH.write_text( + json.dumps(current, indent=2, ensure_ascii=False), + encoding="utf-8", + ) + return current diff --git a/brain/app/domain/__init__.py b/brain/app/domain/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/brain/app/domain/models.py b/brain/app/domain/models.py new file mode 100644 index 0000000..ec17322 --- /dev/null +++ b/brain/app/domain/models.py @@ -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` 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] diff --git a/brain/app/domain/ports.py b/brain/app/domain/ports.py new file mode 100644 index 0000000..a2b81d7 --- /dev/null +++ b/brain/app/domain/ports.py @@ -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. + """ diff --git a/brain/app/infrastructure/__init__.py b/brain/app/infrastructure/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/brain/app/infrastructure/ollama_adapter.py b/brain/app/infrastructure/ollama_adapter.py new file mode 100644 index 0000000..560d2b7 --- /dev/null +++ b/brain/app/infrastructure/ollama_adapter.py @@ -0,0 +1,121 @@ +"""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 + self._num_ctx = settings.llm_num_ctx + + def _build_options(self, temperature: float | None) -> dict[str, object]: + """Construit le dict `options` attendu par Ollama (hyperparamètres). + + `num_ctx` est TOUJOURS envoyé — sinon Ollama retombe sur son défaut + 2048 et tronque silencieusement les gros prompts (Structural Context + du Lore enrichi depuis b9). `temperature` n'est ajoutée que si + fournie par le use case (sinon Ollama utilise son défaut). + """ + options: dict[str, object] = {"num_ctx": self._num_ctx} + if temperature is not None: + options["temperature"] = temperature + return options + + 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, + "options": self._build_options(temperature), + } + if output_format is not None: + payload["format"] = output_format + + 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, + "options": self._build_options(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 diff --git a/brain/app/infrastructure/onemin_adapter.py b/brain/app/infrastructure/onemin_adapter.py new file mode 100644 index 0000000..1849cb7 --- /dev/null +++ b/brain/app/infrastructure/onemin_adapter.py @@ -0,0 +1,174 @@ +"""Adapter 1min.ai — implementation alternative des ports LLMProvider / LLMChatProvider. + +API 1min.ai (cf. https://docs.1min.ai/docs/api/chat-with-ai-api) : + - POST https://api.1min.ai/api/chat-with-ai (one-shot) + - POST https://api.1min.ai/api/chat-with-ai?isStreaming=true (SSE) + - Auth : header "API-KEY: " + - Body : {"type": "UNIFY_CHAT_WITH_AI", "model": "...", + "promptObject": {"prompt": "..."}} + +Le port LoreMind expose une API "messages[]", mais 1min.ai attend un prompt +unique. On aplatit donc l'historique + system prompt en un seul bloc texte, +avec des marqueurs de role lisibles pour le modele. +""" +from __future__ import annotations + +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 + +_API_BASE = "https://api.1min.ai/api/chat-with-ai" +_PAYLOAD_TYPE = "UNIFY_CHAT_WITH_AI" + + +class OneMinAiLLMProvider: + """Adapter 1min.ai — satisfait LLMProvider et LLMChatProvider par duck typing.""" + + def __init__(self, settings: Settings) -> None: + if not settings.onemin_api_key: + raise LLMProviderError( + "Cle API 1min.ai manquante. Configure-la depuis l'ecran Parametres." + ) + self._api_key = settings.onemin_api_key + self._model = settings.onemin_model + self._timeout = settings.llm_timeout_seconds + + def _headers(self) -> dict[str, str]: + return {"API-KEY": self._api_key, "Content-Type": "application/json"} + + def _payload(self, prompt: str) -> dict[str, object]: + return { + "type": _PAYLOAD_TYPE, + "model": self._model, + "promptObject": {"prompt": prompt}, + } + + async def generate( + self, + prompt: str, + *, + output_format: str | None = None, # 1min.ai ne supporte pas format=json + temperature: float | None = None, # idem, pas d'hyperparam expose ici + ) -> str: + """Appel one-shot : retourne la reponse complete sous forme de string.""" + async with httpx.AsyncClient(timeout=self._timeout) as client: + try: + response = await client.post( + _API_BASE, headers=self._headers(), json=self._payload(prompt) + ) + response.raise_for_status() + data = response.json() + except httpx.HTTPError as exc: + raise LLMProviderError(f"Erreur 1min.ai : {exc}") from exc + + return self._extract_result(data) + + async def stream_chat( + self, + messages: list[ChatMessage], + *, + system_prompt: str | None = None, + temperature: float | None = None, + ) -> AsyncIterator[str]: + """Streame via SSE. + + 1min.ai expose deux evenements utiles : + - `event: content` → `data: {"content": "..."}` + - `event: done` → fin du stream + - `event: error` → erreur serveur + On yield le champ `content` au fil de l'arrivee. + """ + prompt = self._flatten_messages(messages, system_prompt) + url = f"{_API_BASE}?isStreaming=true" + + async with httpx.AsyncClient(timeout=self._timeout) as client: + try: + async with client.stream( + "POST", url, headers=self._headers(), json=self._payload(prompt) + ) as response: + response.raise_for_status() + async for token in self._parse_sse(response): + yield token + except httpx.HTTPError as exc: + raise LLMProviderError( + f"Erreur lors du streaming 1min.ai : {exc}" + ) from exc + + # --- Helpers ------------------------------------------------------------ + + @staticmethod + async def _parse_sse(response: httpx.Response) -> AsyncIterator[str]: + """Decoupe le flux SSE ligne par ligne et yield les chunks 'content'.""" + current_event: str | None = None + current_data = "" + async for line in response.aiter_lines(): + if line == "": + # Fin d'un evenement SSE : dispatch + if current_event == "done": + return + if current_event == "error": + raise LLMProviderError(f"1min.ai a signale une erreur : {current_data}") + if current_data and current_event in (None, "content", "message"): + token = OneMinAiLLMProvider._extract_content_chunk(current_data) + if token: + yield token + current_event = None + current_data = "" + continue + if line.startswith("event:"): + current_event = line[6:].strip() + elif line.startswith("data:"): + chunk = line[5:].lstrip() + current_data = f"{current_data}\n{chunk}" if current_data else chunk + + @staticmethod + def _extract_content_chunk(data: str) -> str: + """Extrait le champ `content` d'un data JSON, avec tolerance si format brut.""" + try: + obj = json.loads(data) + except json.JSONDecodeError: + return data # filet de securite si le serveur envoie du texte brut + if isinstance(obj, dict): + return obj.get("content") or obj.get("token") or "" + return "" + + @staticmethod + def _extract_result(payload: dict) -> str: + """Extrait le texte final d'une reponse non-streamee. + + Schema attendu : `aiRecord.aiRecordDetail.resultObject` (list[str]). + On concatene par securite (le serveur renvoie habituellement un seul element). + """ + record = payload.get("aiRecord") or {} + detail = record.get("aiRecordDetail") or {} + result = detail.get("resultObject") or [] + if isinstance(result, list): + return "".join(str(x) for x in result) + if isinstance(result, str): + return result + raise LLMProviderError("Reponse 1min.ai inattendue : resultObject absent.") + + @staticmethod + def _flatten_messages( + messages: list[ChatMessage], system_prompt: str | None + ) -> str: + """Transforme [system_prompt, history] en un unique prompt textuel. + + 1min.ai n'accepte qu'un champ `prompt` : on serialise la conversation + avec des marqueurs explicites pour que le modele comprenne les tours. + """ + parts: list[str] = [] + if system_prompt: + parts.append(f"[SYSTEM]\n{system_prompt}") + if messages: + history = "\n\n".join( + f"[{m.role.upper()}]\n{m.content}" for m in messages + ) + parts.append(history) + parts.append("[ASSISTANT]") # invite le modele a continuer + return "\n\n".join(parts) diff --git a/brain/app/main.py b/brain/app/main.py new file mode 100644 index 0000000..42af9b3 --- /dev/null +++ b/brain/app/main.py @@ -0,0 +1,598 @@ +"""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, Literal + +import hmac +import httpx +from fastapi import Depends, FastAPI, HTTPException, Request +from fastapi.responses import JSONResponse, 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.core.settings_store import save_overrides +from app.domain.models import ( + ArcSummary, + CampaignStructuralContext, + ChapterSummary, + ChatMessage, + LoreStructuralContext, + NarrativeEntityContext, + PageContext, + PageGenerationContext, + PageSummary, + SceneBranchHint, + SceneSummary, +) +from app.domain.ports import LLMProvider, LLMProviderError +from app.infrastructure.ollama_adapter import OllamaLLMProvider +from app.infrastructure.onemin_adapter import OneMinAiLLMProvider + +app = FastAPI( + title="LoreMind Brain", + description="Backend IA pour la génération de contenu narratif.", + version="0.2.0", +) + + +# Chemins exemptes d'auth inter-service : healthcheck docker + introspection +# FastAPI (docs uniquement utiles en dev ; en prod docker-compose, le Brain +# n'est pas expose en dehors du reseau interne donc pas un risque). +_PUBLIC_PATHS = frozenset({"/health", "/docs", "/redoc", "/openapi.json"}) + + +@app.middleware("http") +async def require_internal_secret(request: Request, call_next): + """Refuse toute requete qui ne presente pas le secret partage core<->brain. + + Fail-closed : si `INTERNAL_SHARED_SECRET` n'est pas configure cote Brain, + TOUTES les requetes non-publiques sont rejetees. Force la configuration + explicite en prod et empeche un deploiement par defaut non-authentifie. + + Comparaison en temps-constant via `hmac.compare_digest` pour eviter les + attaques par timing side-channel sur la validation du secret. + """ + if request.url.path in _PUBLIC_PATHS: + return await call_next(request) + + expected = get_settings().internal_shared_secret + provided = request.headers.get("x-internal-secret", "") + if not expected or not hmac.compare_digest(expected, provided): + return JSONResponse( + {"detail": "Unauthorized: invalid or missing X-Internal-Secret"}, + status_code=401, + ) + return await call_next(request) + + +# --- 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 PageSummaryDTO(BaseModel): + """Résumé enrichi d'une page : identité + contenu + interconnexions. + + Depuis b9 : values/tags/related_page_titles sont optionnels côté JSON — + le Core Java ne les sérialise que s'ils sont non-vides (payload léger + pour un Lore avec beaucoup de pages vierges). + """ + + title: str + template_name: str + values: dict[str, str] = Field(default_factory=dict) + tags: list[str] = Field(default_factory=list) + related_page_titles: list[str] = Field(default_factory=list) + + +class LoreContextDTO(BaseModel): + """Carte structurelle du Lore avec contenu des pages (b9+).""" + + lore_name: str + lore_description: str | None = None + folders: dict[str, list[PageSummaryDTO]] = 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 SceneBranchHintDTO(BaseModel): + """Indice d'une branche narrative (le Core a deja resolu le nom cible).""" + + label: str + target_scene_name: str + condition: str | None = None + + +class SceneSummaryDTO(BaseModel): + """Résumé d'une scène : nom + description courte (synopsis).""" + + name: str + description: str | None = None + # Optionnel : le Core Java ne serialise illustration_count QUE si > 0 + # (payload plus leger). Defaut 0 = pas d'illustrations ou champ absent. + illustration_count: int = 0 + # Branches narratives sortantes, omises cote Core si vides. + branches: list[SceneBranchHintDTO] = Field(default_factory=list) + + +class ChapterSummaryDTO(BaseModel): + """Résumé d'un chapitre : nom + description courte + ses scènes.""" + + name: str + description: str | None = None + scenes: list[SceneSummaryDTO] = Field(default_factory=list) + illustration_count: int = 0 + + +class ArcSummaryDTO(BaseModel): + """Résumé d'un arc narratif : nom + description courte + ses chapitres.""" + + name: str + description: str | None = None + chapters: list[ChapterSummaryDTO] = Field(default_factory=list) + illustration_count: int = 0 + + +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) + + +class NarrativeEntityDTO(BaseModel): + """Entité narrative (arc/chapter/scene) en cours d'édition — focus optionnel.""" + + entity_type: str = Field(pattern="^(arc|chapter|scene)$") + title: str + fields: dict[str, str] = Field(default_factory=dict) + + +class ChatStreamRequestDTO(BaseModel): + """Requête de chat streamé : historique + contextes structurels. + + Les 4 contextes (lore, page, campaign, narrative_entity) sont optionnels, + mais au moins l'un des deux "niveaux haut" (lore_context ou + campaign_context) doit être fourni. Le validateur `check_scope` applique + cette règle à la frontière HTTP. + """ + + messages: list[ChatMessageDTO] = Field(min_length=1) + lore_context: LoreContextDTO | None = None + page_context: PageContextDTO | None = None + campaign_context: CampaignContextDTO | None = None + narrative_entity: NarrativeEntityDTO | None = None + + def has_scope(self) -> bool: + """Vrai si au moins un contexte racine (Lore ou Campagne) est fourni.""" + return self.lore_context is not None or self.campaign_context is not 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, en fonction du champ `llm_provider` des Settings + (modifiable a chaud depuis l'ecran Parametres de l'UI). + """ + try: + if settings.llm_provider == "onemin": + return OneMinAiLLMProvider(settings) + return OllamaLLMProvider(settings) + except LLMProviderError as exc: + # Ex : cle 1min.ai manquante. On renvoie du 400 plutot que du 500 + # pour que le frontend puisse afficher un message actionnable. + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +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. + + Accepte jusqu'à 4 contextes optionnels (Lore, Page focalisée, Campagne, + entité narrative focalisée). Au moins un contexte racine (Lore ou + Campagne) est requis pour que la requête ait du sens. + + Format de flux : + - Chaque token : `data: {"token": "..."}\\n\\n` + - Fin normale : `event: done\\ndata: {}\\n\\n` + - Erreur LLM : `event: error\\ndata: {"message": "..."}\\n\\n` + """ + if not body.has_scope(): + raise HTTPException( + status_code=422, + detail="Au moins un des deux contextes racines (lore_context ou campaign_context) est requis.", + ) + + messages = [ChatMessage(role=m.role, content=m.content) for m in body.messages] + lore_context = _to_lore_context(body.lore_context) + page_context = _to_page_context(body.page_context) + campaign_context = _to_campaign_context(body.campaign_context) + narrative_entity = _to_narrative_entity(body.narrative_entity) + + async def event_stream() -> AsyncIterator[str]: + try: + async for token in use_case.stream( + messages, + lore_context=lore_context, + page_context=page_context, + campaign_context=campaign_context, + narrative_entity=narrative_entity, + ): + # 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") + + +# --- Mapping DTO → domaine (frontière HTTP) --------------------------------- + + +def _to_lore_context(dto: LoreContextDTO | None) -> LoreStructuralContext | None: + if dto is None: + return None + return LoreStructuralContext( + lore_name=dto.lore_name, + lore_description=dto.lore_description, + folders={ + folder: [_to_page_summary(p) for p in pages] + for folder, pages in dto.folders.items() + }, + tags=dto.tags, + ) + + +def _to_page_summary(dto: PageSummaryDTO) -> PageSummary: + return PageSummary( + title=dto.title, + template_name=dto.template_name, + values=dict(dto.values), + tags=list(dto.tags), + related_page_titles=list(dto.related_page_titles), + ) + + +def _to_page_context(dto: PageContextDTO | None) -> PageContext | None: + if dto is None: + return None + return PageContext( + title=dto.title, + template_name=dto.template_name, + template_fields=dto.template_fields, + values=dto.values, + ) + + +def _to_campaign_context(dto: CampaignContextDTO | None) -> CampaignStructuralContext | None: + if dto is None: + return None + arcs = [ + ArcSummary( + name=arc.name, + description=arc.description, + illustration_count=arc.illustration_count, + chapters=[ + ChapterSummary( + name=ch.name, + description=ch.description, + illustration_count=ch.illustration_count, + scenes=[ + SceneSummary( + name=sc.name, + description=sc.description, + illustration_count=sc.illustration_count, + branches=[ + SceneBranchHint( + label=br.label, + target_scene_name=br.target_scene_name, + condition=br.condition, + ) + for br in sc.branches + ], + ) + for sc in ch.scenes + ], + ) + for ch in arc.chapters + ], + ) + for arc in dto.arcs + ] + return CampaignStructuralContext( + campaign_name=dto.campaign_name, + campaign_description=dto.campaign_description, + arcs=arcs, + ) + + +# --- Settings (parametrage runtime depuis l'UI) ------------------------------ + + +class SettingsDTO(BaseModel): + """Vue serialisable des settings modifiables depuis l'UI. + + Expose uniquement les champs que l'utilisateur peut changer a chaud. + Les secrets (onemin_api_key) sont masques en lecture. + """ + + llm_provider: Literal["ollama", "onemin"] + ollama_base_url: str + llm_model: str + onemin_model: str + # True si une cle 1min.ai est deja configuree — pas de leak de la cle elle-meme. + onemin_api_key_set: bool + + +class SettingsUpdateDTO(BaseModel): + """Patch partiel des settings. Tous les champs sont optionnels.""" + + llm_provider: Literal["ollama", "onemin"] | None = None + ollama_base_url: str | None = None + llm_model: str | None = None + onemin_model: str | None = None + # Chaine vide => on efface la cle. None => pas de changement. + onemin_api_key: str | None = None + + +def _to_settings_dto(s: Settings) -> SettingsDTO: + return SettingsDTO( + llm_provider=s.llm_provider, + ollama_base_url=s.ollama_base_url, + llm_model=s.llm_model, + onemin_model=s.onemin_model, + onemin_api_key_set=bool(s.onemin_api_key), + ) + + +@app.get("/settings", response_model=SettingsDTO) +def read_settings(settings: Annotated[Settings, Depends(get_settings)]) -> SettingsDTO: + """Retourne la config courante (secrets masques).""" + return _to_settings_dto(settings) + + +@app.put("/settings", response_model=SettingsDTO) +def update_settings(patch: SettingsUpdateDTO) -> SettingsDTO: + """Applique un patch partiel aux settings et persiste les overrides. + + Toute requete HTTP suivante verra les nouvelles valeurs (pas de cache). + """ + overrides = {k: v for k, v in patch.model_dump().items() if v is not None} + if overrides: + save_overrides(overrides) + # Relit .env + overrides fusionnes pour confirmation. + return _to_settings_dto(get_settings()) + + +@app.get("/models/ollama") +async def list_ollama_models( + settings: Annotated[Settings, Depends(get_settings)], +) -> dict[str, list[str]]: + """Liste les modeles disponibles sur le serveur Ollama configure. + + Retourne une liste vide si Ollama est injoignable — l'UI affichera un + message plutot qu'une 500. + """ + url = f"{settings.ollama_base_url}/api/tags" + try: + async with httpx.AsyncClient(timeout=5) as client: + response = await client.get(url) + response.raise_for_status() + data = response.json() + except httpx.HTTPError: + return {"models": []} + models = [m.get("name", "") for m in data.get("models", []) if m.get("name")] + return {"models": sorted(models)} + + +@app.get("/models/onemin") +def list_onemin_models() -> dict[str, list[dict[str, object]]]: + """Catalogue statique des modeles 1min.ai, groupes par fournisseur. + + Liste construite par probing direct de l'endpoint chat-with-ai avec + une vraie cle API (avril 2026) : chaque ID renvoie 200, les IDs + absents renvoient 400 UNSUPPORTED_MODEL. + + Nota : les IDs Anthropic utilisent la nomenclature propre a 1min.ai + (`claude--`), pas la convention officielle Anthropic. + """ + return { + "groups": [ + { + "provider": "Anthropic", + "models": ["claude-opus-4-6", "claude-sonnet-4-6"], + }, + { + "provider": "OpenAI", + "models": [ + "gpt-5", + "gpt-5-mini", + "gpt-5-nano", + "gpt-4.1", + "gpt-4.1-mini", + "gpt-4.1-nano", + "gpt-4o", + "gpt-4o-mini", + "gpt-4-turbo", + "gpt-3.5-turbo", + "o3", + "o3-pro", + "o3-mini", + "o4-mini", + ], + }, + { + "provider": "Google", + "models": ["gemini-2.5-pro", "gemini-2.5-flash"], + }, + { + "provider": "Mistral", + "models": [ + "mistral-large-latest", + "mistral-medium-latest", + "mistral-small-latest", + "open-mistral-nemo", + ], + }, + { + "provider": "DeepSeek", + "models": ["deepseek-chat", "deepseek-reasoner"], + }, + { + "provider": "xAI", + "models": ["grok-3", "grok-3-mini"], + }, + { + "provider": "Meta", + "models": [ + "meta/meta-llama-3.1-405b-instruct", + "meta/meta-llama-3-70b-instruct", + ], + }, + { + "provider": "Alibaba", + "models": ["qwen-plus", "qwen3-max"], + }, + { + "provider": "Perplexity", + "models": ["sonar", "sonar-pro"], + }, + ] + } + + +def _to_narrative_entity(dto: NarrativeEntityDTO | None) -> NarrativeEntityContext | None: + if dto is None: + return None + return NarrativeEntityContext( + entity_type=dto.entity_type, + title=dto.title, + fields=dict(dto.fields), + ) diff --git a/brain/requirements.txt b/brain/requirements.txt new file mode 100644 index 0000000..9a74705 --- /dev/null +++ b/brain/requirements.txt @@ -0,0 +1,6 @@ +fastapi==0.115.* +uvicorn[standard]==0.32.* +httpx==0.27.* +pydantic-settings==2.6.* + +pydantic \ No newline at end of file diff --git a/core/.dockerignore b/core/.dockerignore new file mode 100644 index 0000000..5f1ca29 --- /dev/null +++ b/core/.dockerignore @@ -0,0 +1,4 @@ +target/ +.idea/ +*.iml +.mvn/ diff --git a/core/Dockerfile b/core/Dockerfile new file mode 100644 index 0000000..c9f77a2 --- /dev/null +++ b/core/Dockerfile @@ -0,0 +1,12 @@ +FROM maven:3.9-eclipse-temurin-17 AS build +WORKDIR /build +COPY pom.xml . +RUN mvn dependency:go-offline -B +COPY src ./src +RUN mvn clean package -DskipTests -B + +FROM eclipse-temurin:17-jre +WORKDIR /app +COPY --from=build /build/target/*.jar app.jar +EXPOSE 8080 +ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/core/pom.xml b/core/pom.xml new file mode 100644 index 0000000..ec34442 --- /dev/null +++ b/core/pom.xml @@ -0,0 +1,104 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.2.0 + + + + com.loremind + loremind-core + 0.2.0 + LoreMind Core + Backend Core - Architecture Hexagonale + + + 17 + + + + + + org.springframework.boot + spring-boot-starter-web + + + + + org.springframework.boot + spring-boot-starter-security + + + + + org.springframework.boot + spring-boot-starter-webflux + + + + + org.springframework.boot + spring-boot-starter-data-jpa + + + + + org.postgresql + postgresql + runtime + + + + + com.h2database + h2 + test + + + + + org.projectlombok + lombok + true + + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + io.minio + minio + 8.5.11 + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + + + + + diff --git a/core/src/main/java/com/loremind/LoreMindApplication.java b/core/src/main/java/com/loremind/LoreMindApplication.java new file mode 100644 index 0000000..5ad7700 --- /dev/null +++ b/core/src/main/java/com/loremind/LoreMindApplication.java @@ -0,0 +1,16 @@ +package com.loremind; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * Classe principale de l'application LoreMind. + * Point d'entrée Spring Boot qui démarre l'application. + */ +@SpringBootApplication +public class LoreMindApplication { + + public static void main(String[] args) { + SpringApplication.run(LoreMindApplication.class, args); + } +} diff --git a/core/src/main/java/com/loremind/application/campaigncontext/ArcService.java b/core/src/main/java/com/loremind/application/campaigncontext/ArcService.java new file mode 100644 index 0000000..0f08001 --- /dev/null +++ b/core/src/main/java/com/loremind/application/campaigncontext/ArcService.java @@ -0,0 +1,69 @@ +package com.loremind.application.campaigncontext; + +import com.loremind.domain.campaigncontext.Arc; +import com.loremind.domain.campaigncontext.ports.ArcRepository; +import org.springframework.beans.BeanUtils; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Optional; + +/** + * Service d'application pour le contexte Arc. + * Orchestre la logique métier en utilisant le Port ArcRepository. + * Fait partie de la couche Application de l'Architecture Hexagonale. + */ +@Service +public class ArcService { + + private final ArcRepository arcRepository; + + public ArcService(ArcRepository arcRepository) { + this.arcRepository = arcRepository; + } + + public Arc createArc(String name, String description, String campaignId, int order) { + Arc arc = Arc.builder() + .name(name) + .description(description) + .campaignId(campaignId) + .order(order) + .build(); + return arcRepository.save(arc); + } + + public Optional getArcById(String id) { + return arcRepository.findById(id); + } + + public List getAllArcs() { + return arcRepository.findAll(); + } + + public List getArcsByCampaignId(String campaignId) { + return arcRepository.findByCampaignId(campaignId); + } + + /** + * Met à jour un Arc avec tous ses champs narratifs. + * Accepte un objet Arc pour éviter l'explosion de paramètres (Parameter Object pattern). + */ + public Arc updateArc(String id, Arc updated) { + Optional existingArc = arcRepository.findById(id); + if (existingArc.isEmpty()) { + throw new IllegalArgumentException("Arc non trouvé avec l'ID: " + id); + } + + Arc arc = existingArc.get(); + BeanUtils.copyProperties(updated, arc, "id"); + return arcRepository.save(arc); + } + + public void deleteArc(String id) { + arcRepository.deleteById(id); + } + + public boolean arcExists(String id) { + return arcRepository.existsById(id); + } +} diff --git a/core/src/main/java/com/loremind/application/campaigncontext/CampaignService.java b/core/src/main/java/com/loremind/application/campaigncontext/CampaignService.java new file mode 100644 index 0000000..48dbcac --- /dev/null +++ b/core/src/main/java/com/loremind/application/campaigncontext/CampaignService.java @@ -0,0 +1,84 @@ +package com.loremind.application.campaigncontext; + +import com.loremind.domain.campaigncontext.Campaign; +import com.loremind.domain.campaigncontext.ports.CampaignRepository; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Optional; + +/** + * Service d'application pour le contexte Campaign. + * Orchestre la logique métier en utilisant le Port CampaignRepository. + * Fait partie de la couche Application de l'Architecture Hexagonale. + */ +@Service +public class CampaignService { + + private final CampaignRepository campaignRepository; + + public CampaignService(CampaignRepository campaignRepository) { + this.campaignRepository = campaignRepository; + } + + /** + * Parameter Object pour la création / mise à jour d'une Campaign. + * Évite une signature à rallonge et rend les évolutions futures (theme, + * coverImageUrl, etc.) sans casser les appelants. + * + *

{@code loreId} est nullable : une campagne peut exister sans univers associé.

+ */ + public record CampaignData(String name, String description, String loreId) {} + + public Campaign createCampaign(CampaignData data) { + Campaign campaign = Campaign.builder() + .name(data.name()) + .description(data.description()) + .loreId(normalizeLoreId(data.loreId())) + .arcsCount(0) + .build(); + return campaignRepository.save(campaign); + } + + public Optional getCampaignById(String id) { + return campaignRepository.findById(id); + } + + public List getAllCampaigns() { + return campaignRepository.findAll(); + } + + public Campaign updateCampaign(String id, CampaignData data) { + Optional existingCampaign = campaignRepository.findById(id); + if (existingCampaign.isEmpty()) { + throw new IllegalArgumentException("Campaign non trouvé avec l'ID: " + id); + } + + Campaign campaign = existingCampaign.get(); + campaign.setName(data.name()); + campaign.setDescription(data.description()); + campaign.setLoreId(normalizeLoreId(data.loreId())); + return campaignRepository.save(campaign); + } + + /** + * Normalise un loreId entrant : une chaîne vide/blanche est traitée comme "pas de lien". + * Utile car les payloads JSON peuvent envoyer "" au lieu de null. + */ + private String normalizeLoreId(String loreId) { + return (loreId == null || loreId.isBlank()) ? null : loreId; + } + + public void deleteCampaign(String id) { + campaignRepository.deleteById(id); + } + + public boolean campaignExists(String id) { + return campaignRepository.existsById(id); + } + + public List searchCampaigns(String query) { + if (query == null || query.isBlank()) return List.of(); + return campaignRepository.searchByName(query.trim()); + } +} diff --git a/core/src/main/java/com/loremind/application/campaigncontext/ChapterService.java b/core/src/main/java/com/loremind/application/campaigncontext/ChapterService.java new file mode 100644 index 0000000..1fd710a --- /dev/null +++ b/core/src/main/java/com/loremind/application/campaigncontext/ChapterService.java @@ -0,0 +1,68 @@ +package com.loremind.application.campaigncontext; + +import com.loremind.domain.campaigncontext.Chapter; +import com.loremind.domain.campaigncontext.ports.ChapterRepository; +import org.springframework.beans.BeanUtils; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Optional; + +/** + * Service d'application pour le contexte Chapter. + * Orchestre la logique métier en utilisant le Port ChapterRepository. + * Fait partie de la couche Application de l'Architecture Hexagonale. + */ +@Service +public class ChapterService { + + private final ChapterRepository chapterRepository; + + public ChapterService(ChapterRepository chapterRepository) { + this.chapterRepository = chapterRepository; + } + + public Chapter createChapter(String name, String description, String arcId, int order) { + Chapter chapter = Chapter.builder() + .name(name) + .description(description) + .arcId(arcId) + .order(order) + .build(); + return chapterRepository.save(chapter); + } + + public Optional getChapterById(String id) { + return chapterRepository.findById(id); + } + + public List getAllChapters() { + return chapterRepository.findAll(); + } + + public List getChaptersByArcId(String arcId) { + return chapterRepository.findByArcId(arcId); + } + + /** + * Met à jour un Chapter avec tous ses champs narratifs (Parameter Object pattern). + */ + public Chapter updateChapter(String id, Chapter updated) { + Optional existingChapter = chapterRepository.findById(id); + if (existingChapter.isEmpty()) { + throw new IllegalArgumentException("Chapter non trouvé avec l'ID: " + id); + } + + Chapter chapter = existingChapter.get(); + BeanUtils.copyProperties(updated, chapter, "id"); + return chapterRepository.save(chapter); + } + + public void deleteChapter(String id) { + chapterRepository.deleteById(id); + } + + public boolean chapterExists(String id) { + return chapterRepository.existsById(id); + } +} diff --git a/core/src/main/java/com/loremind/application/campaigncontext/SceneService.java b/core/src/main/java/com/loremind/application/campaigncontext/SceneService.java new file mode 100644 index 0000000..1175c76 --- /dev/null +++ b/core/src/main/java/com/loremind/application/campaigncontext/SceneService.java @@ -0,0 +1,109 @@ +package com.loremind.application.campaigncontext; + +import com.loremind.domain.campaigncontext.Scene; +import com.loremind.domain.campaigncontext.SceneBranch; +import com.loremind.domain.campaigncontext.ports.SceneRepository; +import org.springframework.beans.BeanUtils; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * Service d'application pour le contexte Scene. + * Orchestre la logique métier en utilisant le Port SceneRepository. + * Fait partie de la couche Application de l'Architecture Hexagonale. + */ +@Service +public class SceneService { + + private final SceneRepository sceneRepository; + + public SceneService(SceneRepository sceneRepository) { + this.sceneRepository = sceneRepository; + } + + public Scene createScene(String name, String description, String chapterId, int order) { + Scene scene = Scene.builder() + .name(name) + .description(description) + .chapterId(chapterId) + .order(order) + .build(); + return sceneRepository.save(scene); + } + + public Optional getSceneById(String id) { + return sceneRepository.findById(id); + } + + public List getAllScenes() { + return sceneRepository.findAll(); + } + + public List getScenesByChapterId(String chapterId) { + return sceneRepository.findByChapterId(chapterId); + } + + /** + * Met à jour une Scene avec tous ses champs narratifs (Parameter Object pattern). + */ + public Scene updateScene(String id, Scene updated) { + Optional existingScene = sceneRepository.findById(id); + if (existingScene.isEmpty()) { + throw new IllegalArgumentException("Scene non trouvée avec l'ID: " + id); + } + + Scene scene = existingScene.get(); + BeanUtils.copyProperties(updated, scene, "id"); + + // Validation métier : le graphe narratif doit rester cohérent. + validateBranches(scene); + + return sceneRepository.save(scene); + } + + public void deleteScene(String id) { + sceneRepository.deleteById(id); + } + + public boolean sceneExists(String id) { + return sceneRepository.existsById(id); + } + + /** + * Vérifie les invariants du graphe narratif : + * 1. Pas d'auto-référence (scène qui pointe sur elle-même). + * 2. Toutes les branches pointent vers des scènes du MÊME chapitre. + * 3. Pas de targetSceneId null/vide. + *

+ * Note : on ne vérifie PAS l'existence réelle de chaque scène cible + * individuellement (ça serait un N+1). On charge une seule fois les + * IDs du chapitre et on compare. + */ + private void validateBranches(Scene scene) { + List branches = scene.getBranches(); + if (branches == null || branches.isEmpty()) return; + + // IDs des scènes du chapitre courant (référentiel de validation) + Set chapterSceneIds = sceneRepository.findByChapterId(scene.getChapterId()).stream() + .map(Scene::getId) + .collect(Collectors.toSet()); + + for (SceneBranch b : branches) { + String target = b.getTargetSceneId(); + if (target == null || target.isBlank()) { + throw new IllegalArgumentException("Une branche doit avoir une scène de destination"); + } + if (target.equals(scene.getId())) { + throw new IllegalArgumentException("Une scène ne peut pas se brancher sur elle-même"); + } + if (!chapterSceneIds.contains(target)) { + throw new IllegalArgumentException( + "La branche pointe vers la scène " + target + " qui n'appartient pas au même chapitre"); + } + } + } +} diff --git a/core/src/main/java/com/loremind/application/generationcontext/CampaignStructuralContextBuilder.java b/core/src/main/java/com/loremind/application/generationcontext/CampaignStructuralContextBuilder.java new file mode 100644 index 0000000..3ad07e2 --- /dev/null +++ b/core/src/main/java/com/loremind/application/generationcontext/CampaignStructuralContextBuilder.java @@ -0,0 +1,134 @@ +package com.loremind.application.generationcontext; + +import com.loremind.domain.campaigncontext.Arc; +import com.loremind.domain.campaigncontext.Campaign; +import com.loremind.domain.campaigncontext.Chapter; +import com.loremind.domain.campaigncontext.Scene; +import com.loremind.domain.campaigncontext.ports.ArcRepository; +import com.loremind.domain.campaigncontext.ports.CampaignRepository; +import com.loremind.domain.campaigncontext.ports.ChapterRepository; +import com.loremind.domain.campaigncontext.ports.SceneRepository; +import com.loremind.domain.generationcontext.CampaignStructuralContext; +import com.loremind.domain.generationcontext.CampaignStructuralContext.ArcSummary; +import com.loremind.domain.generationcontext.CampaignStructuralContext.BranchHint; +import com.loremind.domain.generationcontext.CampaignStructuralContext.ChapterSummary; +import com.loremind.domain.generationcontext.CampaignStructuralContext.SceneSummary; +import org.springframework.stereotype.Component; + +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * Service applicatif qui construit un {@link CampaignStructuralContext} + * depuis le Campaign Context (projection Campaign → GenerationContext). + *

+ * Traverse l'arbre arcs → chapitres → scènes en respectant l'ordre narratif + * (tri sur le champ `order` de chaque entité). Charge le NOM + le SYNOPSIS + * (description courte) de chaque niveau : l'IA sait donc de quoi parle + * chaque scène/chapitre/arc sans qu'on lui passe les notes MJ ou la + * narration détaillée — celles-ci restent réservées à l'entité focus via + * NarrativeEntityContext. + */ +@Component +public class CampaignStructuralContextBuilder { + + private final CampaignRepository campaignRepository; + private final ArcRepository arcRepository; + private final ChapterRepository chapterRepository; + private final SceneRepository sceneRepository; + + public CampaignStructuralContextBuilder( + CampaignRepository campaignRepository, + ArcRepository arcRepository, + ChapterRepository chapterRepository, + SceneRepository sceneRepository) { + this.campaignRepository = campaignRepository; + this.arcRepository = arcRepository; + this.chapterRepository = chapterRepository; + this.sceneRepository = sceneRepository; + } + + /** + * Construit la carte narrative d'une Campagne (arcs → chapitres → scènes, + * nom + description courte à chaque niveau). + * @throws IllegalArgumentException si la Campagne est introuvable + */ + public CampaignStructuralContext build(String campaignId) { + Campaign campaign = campaignRepository.findById(campaignId) + .orElseThrow(() -> new IllegalArgumentException( + "Campagne non trouvée avec l'ID: " + campaignId)); + + List arcs = arcRepository.findByCampaignId(campaignId).stream() + .sorted(Comparator.comparingInt(Arc::getOrder)) + .map(this::toArcSummary) + .collect(Collectors.toList()); + + return CampaignStructuralContext.builder() + .campaignName(campaign.getName()) + .campaignDescription(campaign.getDescription()) + .arcs(arcs) + .build(); + } + + private ArcSummary toArcSummary(Arc arc) { + List chapters = chapterRepository.findByArcId(arc.getId()).stream() + .sorted(Comparator.comparingInt(Chapter::getOrder)) + .map(this::toChapterSummary) + .collect(Collectors.toList()); + return ArcSummary.builder() + .name(arc.getName()) + .description(arc.getDescription()) + .illustrationCount(countImages(arc.getIllustrationImageIds())) + .chapters(chapters) + .build(); + } + + private ChapterSummary toChapterSummary(Chapter chapter) { + List scenes = sceneRepository.findByChapterId(chapter.getId()).stream() + .sorted(Comparator.comparingInt(Scene::getOrder)) + .toList(); + + // Map id -> nom construite en une seule passe pour resoudre les + // targetSceneId des branches sans re-interroger le repo (evite N+1). + Map nameById = scenes.stream() + .collect(Collectors.toMap(Scene::getId, Scene::getName)); + + List summaries = scenes.stream() + .map(s -> toSceneSummary(s, nameById)) + .collect(Collectors.toList()); + + return ChapterSummary.builder() + .name(chapter.getName()) + .description(chapter.getDescription()) + .illustrationCount(countImages(chapter.getIllustrationImageIds())) + .scenes(summaries) + .build(); + } + + private SceneSummary toSceneSummary(Scene scene, Map nameById) { + List hints = scene.getBranches() == null + ? List.of() + : scene.getBranches().stream() + .map(b -> BranchHint.builder() + .label(b.getLabel()) + .targetSceneName(nameById.getOrDefault( + b.getTargetSceneId(), "(scène inconnue)")) + .condition(b.getCondition()) + .build()) + .collect(Collectors.toList()); + + return SceneSummary.builder() + .name(scene.getName()) + .description(scene.getDescription()) + .illustrationCount(countImages(scene.getIllustrationImageIds())) + .branches(hints) + .build(); + } + + /** Helper defensif : compte les illustrations attachees (null-safe). */ + private static int countImages(List ids) { + return ids == null ? 0 : ids.size(); + } +} diff --git a/core/src/main/java/com/loremind/application/generationcontext/GeneratePageValuesUseCase.java b/core/src/main/java/com/loremind/application/generationcontext/GeneratePageValuesUseCase.java new file mode 100644 index 0000000..71c334b --- /dev/null +++ b/core/src/main/java/com/loremind/application/generationcontext/GeneratePageValuesUseCase.java @@ -0,0 +1,127 @@ +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 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()) + // Seuls les champs TEXT sont envoyes a l'IA : les champs IMAGE + // necessitent un workflow different (pas de generation LLM texte). + .templateFields(template.textFieldNames()) + .pageTitle(page.getTitle()) + .build(); + + GenerationResult result = aiProvider.generatePage(context); + return result.values(); + } + + // --- 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) { + // On exige au moins un champ TEXT : les champs IMAGE ne sont pas genereables + // par l'IA (pas de text-to-image pour l'instant). + if (template.textFieldNames().isEmpty()) { + throw new IllegalStateException( + "Le template '" + template.getName() + + "' n'a aucun champ texte à générer."); + } + } +} diff --git a/core/src/main/java/com/loremind/application/generationcontext/LoreStructuralContextBuilder.java b/core/src/main/java/com/loremind/application/generationcontext/LoreStructuralContextBuilder.java new file mode 100644 index 0000000..ee665a1 --- /dev/null +++ b/core/src/main/java/com/loremind/application/generationcontext/LoreStructuralContextBuilder.java @@ -0,0 +1,175 @@ +package com.loremind.application.generationcontext; + +import com.loremind.domain.generationcontext.LoreStructuralContext; +import com.loremind.domain.generationcontext.LoreStructuralContext.PageSummary; +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.Component; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; + +/** + * Service applicatif qui construit un {@link LoreStructuralContext} + * depuis le Lore Context (Single Responsibility : projection LoreContext → GenerationContext). + *

+ * Partagé entre {@link StreamChatForLoreUseCase} (Lore) et + * {@link StreamChatForCampaignUseCase} (Campagne liée à un Lore) pour + * respecter DRY — la carte structurelle d'un Lore se calcule de la même + * manière des deux côtés. + *

+ * Depuis b9 : chaque PageSummary embarque values/tags/relatedPageTitles + * (résolus en titres), avec troncature à {@value #MAX_VALUE_LENGTH} caractères + * par valeur pour garder le prompt sous contrôle. + */ +@Component +public class LoreStructuralContextBuilder { + + /** Garde-fou : évite qu'un champ énorme (ex: "Histoire" de 5000 car.) ne sature le prompt. */ + private static final int MAX_VALUE_LENGTH = 500; + + private final LoreRepository loreRepository; + private final LoreNodeRepository loreNodeRepository; + private final PageRepository pageRepository; + private final TemplateRepository templateRepository; + + public LoreStructuralContextBuilder( + LoreRepository loreRepository, + LoreNodeRepository loreNodeRepository, + PageRepository pageRepository, + TemplateRepository templateRepository) { + this.loreRepository = loreRepository; + this.loreNodeRepository = loreNodeRepository; + this.pageRepository = pageRepository; + this.templateRepository = templateRepository; + } + + /** + * Construit la carte structurelle pour un Lore obligatoire. + * @throws IllegalArgumentException si le Lore est introuvable + */ + public LoreStructuralContext build(String loreId) { + return buildOptional(loreId).orElseThrow(() -> + new IllegalArgumentException("Lore non trouvé avec l'ID: " + loreId)); + } + + /** + * Variante non-strict : renvoie Optional.empty() si le Lore a été supprimé + * (cas d'une Campagne dont le loreId pointe sur un Lore effacé entre-temps). + */ + public Optional buildOptional(String loreId) { + return loreRepository.findById(loreId).map(this::buildFromLore); + } + + private LoreStructuralContext buildFromLore(Lore lore) { + List nodes = loreNodeRepository.findByLoreId(lore.getId()); + List pages = pageRepository.findByLoreId(lore.getId()); + List