From 6d00543a59ccf41c44c5bf314b9782762a52a405 Mon Sep 17 00:00:00 2001 From: "IETM_FIXE\\ietm6" Date: Thu, 4 Jun 2026 13:55:27 +0200 Subject: [PATCH] =?UTF-8?q?Ajout=20de=202=20fonctionnalit=C3=A9es=20princi?= =?UTF-8?q?pales=20:=20import=20PDF=20que=20ce=20soit=20pour=20les=20r?= =?UTF-8?q?=C3=A8gles=20ou=20les=20campagnes=20directement.=20Fonctionnali?= =?UTF-8?q?t=C3=A9=20de=20comparaison=20PDF=20/=20campagne=20pour=20faire?= =?UTF-8?q?=20un=20mix=20et=20demander=20des=20conseils=20=C3=A0=20l'IA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- brain/Dockerfile | 10 +- brain/app/application/adapt_campaign.py | 125 ++++++ brain/app/application/chunking.py | 55 +++ brain/app/application/import_campaign.py | 312 +++++++++++++++ brain/app/application/import_rules.py | 197 +++++++++ brain/app/application/llm_json.py | 126 ++++++ brain/app/application/llm_retry.py | 47 +++ brain/app/core/config.py | 12 +- brain/app/core/settings_store.py | 1 + brain/app/domain/models.py | 125 ++++++ brain/app/domain/ports.py | 31 +- brain/app/infrastructure/onemin_adapter.py | 77 ++-- brain/app/infrastructure/pdf_extractor.py | 102 +++++ brain/app/main.py | 223 ++++++++++- brain/requirements.txt | 11 + brain/scripts/test_import_rules.py | 107 +++++ core/pom.xml | 2 +- .../campaigncontext/CampaignAdaptService.java | 118 ++++++ .../CampaignImportService.java | 181 +++++++++ .../campaigncontext/SceneService.java | 14 + .../gamesystemcontext/GameSystemService.java | 29 +- .../CampaignImportProgress.java | 18 + .../CampaignImportProposal.java | 40 ++ .../ports/CampaignImportException.java | 16 + .../ports/CampaignPdfAdvisor.java | 32 ++ .../ports/CampaignPdfImporter.java | 28 ++ .../RulesImportProgress.java | 19 + .../gamesystemcontext/RulesImportResult.java | 20 + .../ports/RulesImportException.java | 17 + .../ports/RulesPdfImporter.java | 39 ++ .../ai/BrainCampaignAdaptClient.java | 120 ++++++ .../ai/BrainCampaignImportClient.java | 232 +++++++++++ .../ai/BrainRulesImportClient.java | 248 ++++++++++++ .../ai/BrainRulesImportResponse.java | 26 ++ .../infrastructure/ai/RestTemplateConfig.java | 25 ++ .../controller/CampaignAdaptController.java | 97 +++++ .../controller/CampaignImportController.java | 119 ++++++ .../web/controller/GameSystemController.java | 118 +++++- .../RulesImportResponseDTO.java | 16 + .../src/main/resources/application.properties | 10 +- web/package-lock.json | 4 +- web/package.json | 2 +- web/src/app/app.component.html | 1 + web/src/app/app.routes.ts | 8 +- .../arc/arc-view/arc-view.component.html | 2 +- web/src/app/campaigns/campaign-tree.helper.ts | 38 +- .../campaign-adapt.component.html | 61 +++ .../campaign-adapt.component.scss | 184 +++++++++ .../campaign-adapt.component.ts | 130 ++++++ .../campaign-detail.component.html | 53 +-- .../campaign-detail.component.scss | 14 + .../campaign-detail.component.ts | 64 +-- .../campaign-import.component.html | 183 +++++++++ .../campaign-import.component.scss | 323 +++++++++++++++ .../campaign-import.component.ts | 374 ++++++++++++++++++ .../chapter-create.component.html | 12 +- .../chapter-create.component.ts | 3 + .../character-edit.component.ts | 15 +- .../character-view.component.ts | 12 +- .../playthrough-detail.component.html | 10 +- .../playthrough-detail.component.scss | 26 ++ .../playthrough-detail.component.ts | 8 +- .../game-system-edit.component.html | 27 ++ .../game-system-edit.component.scss | 88 +++++ .../game-system-edit.component.ts | 106 ++++- .../app/services/campaign-adapt.service.ts | 108 +++++ web/src/app/services/campaign-import.model.ts | 72 ++++ .../app/services/campaign-import.service.ts | 113 ++++++ web/src/app/services/game-system.model.ts | 22 ++ web/src/app/services/game-system.service.ts | 107 ++++- web/src/app/services/layout.service.ts | 6 + web/src/app/services/settings.service.ts | 6 + .../session-reference-panel.component.html | 2 +- web/src/app/settings/settings.component.html | 39 ++ web/src/app/settings/settings.component.ts | 4 +- .../secondary-sidebar.component.html | 14 +- .../secondary-sidebar.component.scss | 29 ++ .../secondary-sidebar.component.ts | 18 +- 78 files changed, 5250 insertions(+), 183 deletions(-) create mode 100644 brain/app/application/adapt_campaign.py create mode 100644 brain/app/application/chunking.py create mode 100644 brain/app/application/import_campaign.py create mode 100644 brain/app/application/import_rules.py create mode 100644 brain/app/application/llm_json.py create mode 100644 brain/app/application/llm_retry.py create mode 100644 brain/app/infrastructure/pdf_extractor.py create mode 100644 brain/scripts/test_import_rules.py create mode 100644 core/src/main/java/com/loremind/application/campaigncontext/CampaignAdaptService.java create mode 100644 core/src/main/java/com/loremind/application/campaigncontext/CampaignImportService.java create mode 100644 core/src/main/java/com/loremind/domain/campaigncontext/CampaignImportProgress.java create mode 100644 core/src/main/java/com/loremind/domain/campaigncontext/CampaignImportProposal.java create mode 100644 core/src/main/java/com/loremind/domain/campaigncontext/ports/CampaignImportException.java create mode 100644 core/src/main/java/com/loremind/domain/campaigncontext/ports/CampaignPdfAdvisor.java create mode 100644 core/src/main/java/com/loremind/domain/campaigncontext/ports/CampaignPdfImporter.java create mode 100644 core/src/main/java/com/loremind/domain/gamesystemcontext/RulesImportProgress.java create mode 100644 core/src/main/java/com/loremind/domain/gamesystemcontext/RulesImportResult.java create mode 100644 core/src/main/java/com/loremind/domain/gamesystemcontext/ports/RulesImportException.java create mode 100644 core/src/main/java/com/loremind/domain/gamesystemcontext/ports/RulesPdfImporter.java create mode 100644 core/src/main/java/com/loremind/infrastructure/ai/BrainCampaignAdaptClient.java create mode 100644 core/src/main/java/com/loremind/infrastructure/ai/BrainCampaignImportClient.java create mode 100644 core/src/main/java/com/loremind/infrastructure/ai/BrainRulesImportClient.java create mode 100644 core/src/main/java/com/loremind/infrastructure/ai/BrainRulesImportResponse.java create mode 100644 core/src/main/java/com/loremind/infrastructure/web/controller/CampaignAdaptController.java create mode 100644 core/src/main/java/com/loremind/infrastructure/web/controller/CampaignImportController.java create mode 100644 core/src/main/java/com/loremind/infrastructure/web/dto/gamesystemcontext/RulesImportResponseDTO.java create mode 100644 web/src/app/campaigns/campaign/campaign-adapt/campaign-adapt.component.html create mode 100644 web/src/app/campaigns/campaign/campaign-adapt/campaign-adapt.component.scss create mode 100644 web/src/app/campaigns/campaign/campaign-adapt/campaign-adapt.component.ts create mode 100644 web/src/app/campaigns/campaign/campaign-import/campaign-import.component.html create mode 100644 web/src/app/campaigns/campaign/campaign-import/campaign-import.component.scss create mode 100644 web/src/app/campaigns/campaign/campaign-import/campaign-import.component.ts create mode 100644 web/src/app/services/campaign-adapt.service.ts create mode 100644 web/src/app/services/campaign-import.model.ts create mode 100644 web/src/app/services/campaign-import.service.ts diff --git a/brain/Dockerfile b/brain/Dockerfile index c3c4379..4c7cef1 100644 --- a/brain/Dockerfile +++ b/brain/Dockerfile @@ -1,7 +1,15 @@ FROM python:3.12-slim WORKDIR /app -RUN apt-get update && apt-get install -y --no-install-recommends curl \ +# curl : healthcheck docker. +# tesseract-ocr (+ langues fra/eng) : repli OCR de l'import de PDF de regles +# pour les pages sans couche texte (scans). Inutile pour les PDF born-digital +# mais necessaire pour couvrir tous les cas. +RUN apt-get update && apt-get install -y --no-install-recommends \ + curl \ + tesseract-ocr \ + tesseract-ocr-fra \ + tesseract-ocr-eng \ && rm -rf /var/lib/apt/lists/* COPY requirements.txt . diff --git a/brain/app/application/adapt_campaign.py b/brain/app/application/adapt_campaign.py new file mode 100644 index 0000000..8a7b9a2 --- /dev/null +++ b/brain/app/application/adapt_campaign.py @@ -0,0 +1,125 @@ +"""Use case : conseils d'adaptation d'un PDF à une campagne EXISTANTE. + +L'IA connaît la campagne de l'utilisateur (un « brief » : structure arcs/chapitres/ +scènes + PNJ + univers/lore), lit le contenu du PDF, et rédige des recommandations +d'INTÉGRATION/ADAPTATION (où insérer, reskins de PNJ, transposition à l'univers, +doublons à réconcilier…). Sortie en markdown, streamée token par token. + +Contrairement à l'IMPORT (qui produit une arborescence à créer), ici on produit +du CONSEIL libre : rien n'est créé, l'utilisateur applique à la main. +""" +from __future__ import annotations + +import logging +from typing import AsyncIterator + +from app.domain.models import ChatMessage +from app.domain.ports import LLMChatProvider, PdfExtractionError, PdfTextExtractor + +logger = logging.getLogger(__name__) + +# Plus créatif que l'import (tâche de structuration) : ici on conseille/adapte. +_TEMPERATURE = 0.7 + +_SYSTEM_PREFIX = ( + "Tu es un assistant pour Maître de Jeu de jeu de rôle. L'utilisateur a une " + "campagne EXISTANTE (décrite plus bas) et souhaite ADAPTER et INTÉGRER le " + "contenu d'un PDF (aventure, donjon, supplément) à CETTE campagne précise." +) + +_SYSTEM_SUFFIX = ( + "Produis des CONSEILS D'ADAPTATION concrets, actionnables et en FRANÇAIS, " + "en markdown structuré (titres ##, listes). Couvre notamment :\n" + "- **Où l'insérer** : à quel(s) arc(s)/chapitre(s) EXISTANT(s) rattacher ce " + "contenu, dans quel ordre, et — si l'arc est un hub — sous quelles conditions de déblocage.\n" + "- **Reskins / liens PNJ** : quels PNJ EXISTANTS de la campagne peuvent incarner " + "ou remplacer les personnages clés du PDF.\n" + "- **Adaptation à l'univers** : comment transposer lieux, factions, noms propres et " + "ton vers l'univers de l'utilisateur plutôt que le cadre d'origine du PDF.\n" + "- **Doublons / conflits** : ce qui recoupe l'existant et comment le réconcilier.\n" + "- **Ajustements de ton et de difficulté**.\n\n" + "Réfère-toi TOUJOURS aux éléments existants par leur NOM. Ne réécris PAS le PDF en " + "entier : donne des recommandations. Si une information manque, propose des options." +) + + +class AdaptCampaignUseCase: + """Génère (en streaming) des conseils d'adaptation d'un PDF à une campagne.""" + + def __init__( + self, + llm: LLMChatProvider, + extractor: PdfTextExtractor, + max_input_tokens: int = 10000, + ) -> None: + self._llm = llm + self._extractor = extractor + # L'adaptation envoie le PDF en UNE requête (pas de découpage). On plafonne + # donc l'entrée pour ne pas dépasser la taille de requête acceptée par le + # provider (sinon HTTP 400). Calé sur la taille des morceaux d'import. + self._max_input_tokens = max_input_tokens + + async def stream( + self, + pdf_bytes: bytes, + brief: str, + messages: list[ChatMessage], + ) -> AsyncIterator[str]: + """Conversationnel : le PDF + la campagne sont le CONTEXTE (system prompt), + `messages` est l'échange (demande initiale, puis feedbacks de l'utilisateur).""" + doc = self._extractor.extract(pdf_bytes) + pdf_text = doc.full_text + if not pdf_text.strip(): + raise PdfExtractionError("Aucun texte exploitable n'a été extrait du PDF.") + + brief = brief or "" + pdf_text, truncated = self._fit_pdf_to_budget(pdf_text, brief) + + logger.info( + "Adaptation campagne : %s page(s) (%s via OCR), brief %s car., PDF %s car.%s, %s message(s).", + doc.page_count, doc.ocr_page_count, len(brief), len(pdf_text), + " (tronqué)" if truncated else "", len(messages), + ) + + trunc_note = ( + "\n[Note : PDF tronqué pour tenir dans une requête — base-toi sur ce début.]" + if truncated else "" + ) + # Concaténation (pas .format) : brief/PDF peuvent contenir des { } littéraux. + system_prompt = ( + f"{_SYSTEM_PREFIX}\n\n" + "--- CAMPAGNE EXISTANTE DE L'UTILISATEUR ---\n" + f"{brief.strip() or '(campagne encore vide)'}\n\n" + "--- CONTENU DU PDF À ADAPTER ---\n" + f"{pdf_text}{trunc_note}\n\n" + f"{_SYSTEM_SUFFIX}\n\n" + "Tu es en CONVERSATION : à chaque message de l'utilisateur, ajuste, corrige " + "ou propose des alternatives en gardant tout ce contexte à l'esprit." + ) + + # 1er tour : si aucun message, on lance la demande initiale par défaut. + convo = messages or [ChatMessage( + role="user", + content="Propose-moi comment intégrer et adapter ce PDF à ma campagne.", + )] + + async for token in self._llm.stream_chat( + convo, system_prompt=system_prompt, temperature=_TEMPERATURE + ): + yield token + + def _fit_pdf_to_budget(self, pdf_text: str, brief: str) -> tuple[str, bool]: + """Tronque le texte du PDF pour que (brief + PDF) tienne dans le budget tokens. + + Évite un HTTP 400 « requête trop grosse » côté provider. Réserve une marge + pour le prompt système et le brief. + """ + import tiktoken + + enc = tiktoken.get_encoding("cl100k_base") + brief_tokens = len(enc.encode(brief)) + budget = max(2000, self._max_input_tokens - brief_tokens - 1000) # 1000 = marge système + pdf_tokens = enc.encode(pdf_text) + if len(pdf_tokens) <= budget: + return pdf_text, False + return enc.decode(pdf_tokens[:budget]), True diff --git a/brain/app/application/chunking.py b/brain/app/application/chunking.py new file mode 100644 index 0000000..e127a2d --- /dev/null +++ b/brain/app/application/chunking.py @@ -0,0 +1,55 @@ +"""Découpage d'un long texte en morceaux qui tiennent dans la fenêtre LLM. + +Partagé par les imports (règles, campagne) : un livre dépasse la fenêtre de +contexte, on le découpe par paragraphes jusqu'à une cible de tokens, en coupant +les paragraphes géants si besoin. Dimensionnement via tiktoken (cl100k_base), +approximation suffisante (±10% vs tokenizer natif). +""" +from __future__ import annotations + +# Cible conservatrice : tient dans une fenêtre Ollama (num_ctx 16384) en laissant +# la place au prompt + à la sortie JSON. Les providers à grand contexte (1min.ai) +# le supportent largement. +CHUNK_TARGET_TOKENS = 6000 + + +def chunk_text(full_text: str, target_tokens: int = CHUNK_TARGET_TOKENS) -> list[str]: + """Découpe `full_text` en morceaux ~`target_tokens` tokens (frontières de §).""" + if not full_text.strip(): + return [] + + import tiktoken + + enc = tiktoken.get_encoding("cl100k_base") + paragraphs = [p for p in full_text.split("\n\n") if p.strip()] + + chunks: list[str] = [] + current: list[str] = [] + current_tokens = 0 + for para in paragraphs: + para_tokens = len(enc.encode(para)) + # Un paragraphe seul plus gros que la cible : on le coupe en sous-blocs. + if para_tokens > target_tokens: + if current: + chunks.append("\n\n".join(current)) + current, current_tokens = [], 0 + chunks.extend(_split_oversized(para, enc, target_tokens)) + continue + if current_tokens + para_tokens > target_tokens and current: + chunks.append("\n\n".join(current)) + current, current_tokens = [], 0 + current.append(para) + current_tokens += para_tokens + + if current: + chunks.append("\n\n".join(current)) + return chunks + + +def _split_oversized(paragraph: str, enc, target_tokens: int) -> list[str]: + """Coupe un paragraphe géant en sous-blocs ~`target_tokens` tokens.""" + tokens = enc.encode(paragraph) + out: list[str] = [] + for i in range(0, len(tokens), target_tokens): + out.append(enc.decode(tokens[i : i + target_tokens])) + return out diff --git a/brain/app/application/import_campaign.py b/brain/app/application/import_campaign.py new file mode 100644 index 0000000..aad5d64 --- /dev/null +++ b/brain/app/application/import_campaign.py @@ -0,0 +1,312 @@ +"""Use case : import d'un PDF de campagne → arbre arc → chapitre → scène. + +Couche APPLICATION. Même chaîne que l'import de règles (extraction + OCR + +chunking + map-reduce) mais la cible est une ARBORESCENCE narrative : + - MAP : chaque morceau → un sous-arbre {arcs:[{chapters:[{scenes}]}]} + - REDUCE : fusion par NOM à chaque niveau (un chapitre coupé entre 2 morceaux + est recollé ; ses scènes s'accumulent). + +PROPOSITION non persistée : le Core crée les entités seulement après revue. +""" +from __future__ import annotations + +import logging + +from app.application.chunking import chunk_text +from app.application.llm_json import load_json_object +from app.application.llm_retry import generate_with_retry +from app.domain.models import ( + ArcProposal, + CampaignImportResult, + ChapterProposal, + RoomProposal, + SceneProposal, +) +from app.domain.ports import LLMProvider, PdfTextExtractor + +logger = logging.getLogger(__name__) + +_TEMPERATURE = 0.2 + +# Nom de l'arc unique quand le livre n'est pas découpé en actes/parties. +_DEFAULT_ARC_NAME = "Aventure principale" + +# Morceaux PLUS GROS que pour les règles : l'IA voit une quête/un chapitre entier +# d'un coup et le structure de façon cohérente (1 scène par lieu) au lieu de le +# fragmenter en dizaines de scènes. Adapté aux providers à grand contexte (1min.ai). +_CHUNK_TARGET_TOKENS = 10000 + +_MAP_SYSTEM = """Tu es un assistant qui structure un livre de campagne de jeu de rôle. +On te donne un EXTRAIT brut d'un PDF de campagne (texte parfois mal coupé par la mise en page). + +Ta tâche : en dégager une ARBORESCENCE narrative à GROS GRAIN : arcs → chapitres → scènes, +et — pour les lieux explorables — leurs PIÈCES (rooms). + - Un ARC = un acte / une grande partie de la campagne (souvent un seul pour une aventure courte). + - Un CHAPITRE = une étape majeure du récit : un chapitre du livre, OU — dans une + campagne "hub" / bac-à-sable — UNE QUÊTE ou UN LIEU principal débloqué depuis le + point central (ex : Dragon of Icespire Peak → chaque quête/lieu = un chapitre). + - Une SCÈNE = un temps fort jouable du chapitre : un lieu, une rencontre clé, un moment pivot. + - Une PIÈCE (room) = une salle d'un lieu explorable (donjon, crypte, manoir...). + +TYPE D'ARC ("type") : +- "HUB" si la campagne est un bac-à-sable : des quêtes/lieux optionnels, parallèles, + débloqués depuis un point central, SANS ordre fixe imposé (ex : Dragon of Icespire Peak). +- "LINEAR" si les chapitres se jouent dans un ordre séquentiel imposé. +- Dans le doute : "LINEAR". + +GRANULARITÉ (évite la sur-détection) : +- Vise PEU de scènes : typiquement 1 à 6 par chapitre. PAS des dizaines. +- Un LIEU EXPLORABLE (donjon, crypte, manoir, grotte à plusieurs salles) = UNE SEULE + scène. Ses salles vont dans le tableau "rooms" de cette scène — JAMAIS en scènes séparées. +- NE crée PAS une scène par rencontre isolée, par PNJ, par monstre ou par paragraphe. +- IGNORE : blocs de stats, listes de monstres, encarts de règles, légendes de cartes, + pieds de page, sommaires, crédits. + +CONTENU D'UNE SCÈNE (fidélité au livre — important) : +- `description` = synopsis de la scène, 2 à 4 phrases (plus que 1 ligne, mais pas le texte intégral). +- `player_narration` = le texte d'AMBIANCE « à lire aux joueurs » (encadrés / boxed text / + « lecture à voix haute »), recopié FIDÈLEMENT s'il existe dans l'extrait. Vide sinon. +- `gm_notes` = les informations pour le MJ : secrets, développement, ce qui se passe, + conséquences, indices cachés. Vide si rien de tel. +- Ne RÉSUME pas abusivement player_narration et gm_notes : recopie le contenu utile du livre. + +PIÈCES (rooms) — uniquement pour les scènes qui sont des lieux explorables : +- Une entrée par salle numérotée/nommée du donjon (ex : "1. Entrée", "2. Salle des gardes"). +- `enemies` = créatures/boss de la salle (vide si aucune). `loot` = trésor/récompense (vide si aucun). +- Pour une scène narrative classique (pas un donjon), "rooms" est un tableau vide []. + +Format de réponse : +- Tu réponds UNIQUEMENT par un objet JSON valide, sans markdown ni commentaire autour. +- Schéma EXACT : + {{"arcs": [{{"name": "...", "description": "...", "type": "LINEAR", + "chapters": [{{"name": "...", "description": "...", "scenes": [ + {{"name": "...", "description": "...", "player_narration": "...", "gm_notes": "...", + "rooms": [{{"name": "...", "description": "...", "enemies": "...", "loot": "..."}}]}} + ]}}]}} + ]}} +- Utilise les VRAIS titres du livre pour les noms (pas de paraphrase). +- Si le livre n'est PAS découpé en actes/parties, regroupe tout sous un seul arc nommé "{default_arc}". +- N'invente pas de contenu : tu réorganises et recopies ce qui est présent dans l'extrait. +- Si l'extrait ne contient aucune matière narrative, renvoie {{"arcs": []}}.""" + + +class _TreeMerger: + """Fusionne les sous-arbres des morceaux en un seul arbre, ordre préservé. + + Clés insensibles à la casse à chaque niveau (nom d'arc / chapitre / scène). + Description : la première non-vide rencontrée l'emporte (les morceaux suivants + ne l'écrasent pas). + """ + + def __init__(self) -> None: + # arc_key -> {"name", "description", "chapters": {chap_key -> {...}}} + self._arcs: dict[str, dict] = {} + + def add(self, arcs_json: list[dict]) -> None: + for arc in arcs_json or []: + name = str(arc.get("name", "")).strip() + if not name: + continue + a = self._arcs.setdefault( + name.lower(), {"name": name, "description": "", "type": "LINEAR", "chapters": {}}) + self._fill_desc(a, arc) + # Type d'arc : HUB l'emporte si un seul morceau le signale (propriété globale + # souvent énoncée une fois, dans l'intro du livre). + if str(arc.get("type", "")).strip().upper() == "HUB": + a["type"] = "HUB" + for chap in arc.get("chapters", []) or []: + cname = str(chap.get("name", "")).strip() + if not cname: + continue + c = a["chapters"].setdefault(cname.lower(), {"name": cname, "description": "", "scenes": {}}) + self._fill_desc(c, chap) + for sc in chap.get("scenes", []) or []: + sname = str(sc.get("name", "")).strip() + if not sname: + continue + s = c["scenes"].setdefault( + sname.lower(), + {"name": sname, "description": "", "player_narration": "", + "gm_notes": "", "rooms": {}}) + self._fill_desc(s, sc) + self._fill_field(s, sc, "player_narration") + self._fill_field(s, sc, "gm_notes") + for rm in sc.get("rooms", []) or []: + rname = str(rm.get("name", "")).strip() + if not rname: + continue + r = s["rooms"].setdefault( + rname.lower(), + {"name": rname, "description": "", "enemies": "", "loot": ""}) + self._fill_desc(r, rm) + self._fill_field(r, rm, "enemies") + self._fill_field(r, rm, "loot") + + @staticmethod + def _fill_desc(node: dict, src: dict) -> None: + if not node["description"]: + node["description"] = str(src.get("description") or "").strip() + + @staticmethod + def _fill_field(node: dict, src: dict, field_name: str) -> None: + if not node[field_name]: + node[field_name] = str(src.get(field_name) or "").strip() + + def result(self) -> list[ArcProposal]: + arcs: list[ArcProposal] = [] + for a in self._arcs.values(): + chapters: list[ChapterProposal] = [] + for c in a["chapters"].values(): + scenes: list[SceneProposal] = [] + for s in c["scenes"].values(): + rooms = [ + RoomProposal(r["name"], r["description"], r["enemies"], r["loot"]) + for r in s["rooms"].values() + ] + scenes.append(SceneProposal( + s["name"], s["description"], s["player_narration"], s["gm_notes"], rooms)) + chapters.append(ChapterProposal(c["name"], c["description"], scenes)) + arcs.append(ArcProposal(a["name"], a["description"], a["type"], chapters)) + return arcs + + def counts(self) -> tuple[int, int, int]: + arcs = len(self._arcs) + chapters = sum(len(a["chapters"]) for a in self._arcs.values()) + scenes = sum(len(c["scenes"]) for a in self._arcs.values() for c in a["chapters"].values()) + return arcs, chapters, scenes + + +class ImportCampaignUseCase: + """Transforme un PDF de campagne en proposition d'arbre arc→chapitre→scène.""" + + def __init__( + self, + llm: LLMProvider, + extractor: PdfTextExtractor, + chunk_target_tokens: int = _CHUNK_TARGET_TOKENS, + ) -> None: + self._llm = llm + self._extractor = extractor + self._chunk_target_tokens = chunk_target_tokens + + async def execute(self, pdf_bytes: bytes) -> CampaignImportResult: + """Variante non-streamée : traite tout puis renvoie l'arbre complet.""" + doc = self._extractor.extract(pdf_bytes) + chunks = chunk_text(doc.full_text, self._chunk_target_tokens) + merger = _TreeMerger() + for i, chunk in enumerate(chunks): + merger.add(await self._map_chunk(chunk, index=i, total=len(chunks))) + return CampaignImportResult( + arcs=merger.result(), + page_count=doc.page_count, + ocr_page_count=doc.ocr_page_count, + ) + + async def stream(self, pdf_bytes: bytes): + """Variante streamée : yield des évènements d'avancement. + + {"type":"extracting"}, puis {"type":"start", page_count, ocr_page_count, + total}, puis un {"type":"progress", current, total, arc_count, + chapter_count, scene_count} par morceau, et enfin + {"type":"done", arcs:[...], page_count, ocr_page_count}. + """ + yield {"type": "extracting"} + + doc = self._extractor.extract(pdf_bytes) + chunks = chunk_text(doc.full_text, self._chunk_target_tokens) + total = len(chunks) + logger.info( + "Import campagne (stream) : %s page(s) (%s via OCR), %s morceau(x).", + doc.page_count, doc.ocr_page_count, total, + ) + yield { + "type": "start", + "page_count": doc.page_count, + "ocr_page_count": doc.ocr_page_count, + "total": total, + } + + merger = _TreeMerger() + for i, chunk in enumerate(chunks): + merger.add(await self._map_chunk(chunk, index=i, total=total)) + arcs, chapters, scenes = merger.counts() + yield { + "type": "progress", + "current": i + 1, + "total": total, + "arc_count": arcs, + "chapter_count": chapters, + "scene_count": scenes, + } + + yield { + "type": "done", + "arcs": _serialize_arcs(merger.result()), + "page_count": doc.page_count, + "ocr_page_count": doc.ocr_page_count, + } + + # --- MAP : un morceau → sous-arbre --------------------------------------- + + async def _map_chunk(self, chunk: str, *, index: int, total: int) -> list[dict]: + prompt = ( + _MAP_SYSTEM.format(default_arc=_DEFAULT_ARC_NAME) + + f"\n\n--- EXTRAIT {index + 1}/{total} ---\n{chunk}\n\n" + "Renvoie maintenant le JSON de l'arborescence." + ) + raw = await generate_with_retry( + self._llm, prompt, output_format="json", temperature=_TEMPERATURE) + return self._parse_arcs(raw, index=index) + + @staticmethod + def _parse_arcs(raw: str, *, index: int) -> list[dict]: + """Parse robuste : objet JSON équilibré, ou récupération partielle si tronqué.""" + parsed, recovered = load_json_object(raw) + if parsed is None: + logger.warning("Morceau %s : aucun objet JSON exploitable, ignoré.", index) + return [] + if recovered: + logger.warning( + "Morceau %s : sortie tronquée — récupération des éléments complets " + "(envisagez des morceaux plus petits).", index) + if isinstance(parsed, dict): + arcs = parsed.get("arcs", []) + return arcs if isinstance(arcs, list) else [] + return [] + + +def _serialize_arcs(arcs: list[ArcProposal]) -> list[dict]: + """Sérialise l'arbre de dataclasses en dicts JSON pour le flux SSE.""" + return [ + { + "name": a.name, + "description": a.description, + "type": a.arc_type, + "chapters": [ + { + "name": c.name, + "description": c.description, + "scenes": [ + { + "name": s.name, + "description": s.description, + "player_narration": s.player_narration, + "gm_notes": s.gm_notes, + "rooms": [ + { + "name": r.name, + "description": r.description, + "enemies": r.enemies, + "loot": r.loot, + } + for r in s.rooms + ], + } + for s in c.scenes + ], + } + for c in a.chapters + ], + } + for a in arcs + ] diff --git a/brain/app/application/import_rules.py b/brain/app/application/import_rules.py new file mode 100644 index 0000000..5f62b4c --- /dev/null +++ b/brain/app/application/import_rules.py @@ -0,0 +1,197 @@ +"""Use case : import d'un PDF de règles → sections markdown structurées. + +Couche APPLICATION. Orchestre : + PDF (bytes) → extraction texte (port PdfTextExtractor) + → CHUNKING (le texte d'un livre dépasse la fenêtre de contexte) + → MAP : chaque morceau → {titre de section → markdown} + → REDUCE: fusion des sections de même titre entre morceaux + → RulesImportResult (proposition, NON persistée) + +Ne dépend que des abstractions du domaine (ports LLMProvider + PdfTextExtractor) +→ testable avec des fakes, et indépendant du provider concret (Ollama/1min.ai). +""" +from __future__ import annotations + +import logging + +from app.application.chunking import CHUNK_TARGET_TOKENS, chunk_text +from app.application.llm_json import load_json_object +from app.application.llm_retry import generate_with_retry +from app.domain.models import RulesImportResult +from app.domain.ports import LLMProvider, LLMProviderError, PdfTextExtractor + +logger = logging.getLogger(__name__) + +# Température basse : tâche de tri/réécriture fidèle, pas de créativité. +_TEMPERATURE = 0.2 + +# Taxonomie canonique suggérée au modèle pour homogénéiser les titres entre +# morceaux (sinon "Combat" / "Le combat" / "Règles de combat" se dispersent). +# Le modèle reste libre d'en créer d'autres si rien ne correspond. +_CANONICAL_SECTIONS = [ + "Règles générales", + "Création de personnage", + "Caractéristiques et tests", + "Compétences", + "Combat", + "Magie et sorts", + "Équipement et objets", + "États et conditions", + "Repos et récupération", + "Progression et niveaux", + "Conseils au Maître de Jeu", +] + +_MAP_SYSTEM = """Tu es un assistant qui réorganise un livre de règles de jeu de rôle. +On te donne un EXTRAIT brut d'un PDF de règles (texte parfois mal coupé par la mise en page). + +Ta tâche : répartir le contenu de cet extrait dans des SECTIONS THÉMATIQUES. + +Règles impératives : +- Tu réponds UNIQUEMENT par un objet JSON valide, sans markdown ni commentaire autour. +- Les CLÉS sont des titres de section (texte court). Les VALEURS sont le contenu de la règle en markdown. +- Utilise EN PRIORITÉ ces titres canoniques quand le contenu y correspond : +{canonical} +- Si un contenu ne rentre dans aucun, crée un titre clair et concis (en français). +- Reproduis FIDÈLEMENT les règles : tu peux nettoyer la coupure des lignes, recoller les mots coupés + par un tiret en fin de ligne, retirer les en-têtes/pieds de page et numéros de page parasites. +- N'INVENTE AUCUNE règle, ne résume pas abusivement : tu réorganises, tu ne réécris pas le fond. +- Ignore les pages de garde, sommaires, crédits, pages vides (renvoie {{}} si l'extrait n'a aucune règle).""" + + +class _SectionMerger: + """Fusionne les sections issues des différents morceaux, ordre préservé. + + Titres insensibles à la casse ("Combat" / "combat" → une seule clé). Chaque + `add()` renvoie la liste (dé-dupliquée, ordonnée) des titres touchés par ce + morceau — sert au flux de progression pour annoncer les sections trouvées. + """ + + def __init__(self) -> None: + self._merged: dict[str, list[str]] = {} + self._canonical_key: dict[str, str] = {} + + def add(self, sections: dict[str, str]) -> list[str]: + touched: list[str] = [] + for title, content in sections.items(): + title = title.strip() + content = (content or "").strip() + if not title or not content: + continue + key = title.lower() + if key not in self._canonical_key: + self._canonical_key[key] = title + self._merged[title] = [] + canonical = self._canonical_key[key] + self._merged[canonical].append(content) + touched.append(canonical) + # Dé-duplication en préservant l'ordre d'apparition. + seen: set[str] = set() + return [t for t in touched if not (t in seen or seen.add(t))] + + def result(self) -> dict[str, str]: + return {title: "\n\n".join(parts) for title, parts in self._merged.items()} + + +class ImportRulesUseCase: + """Transforme un PDF de règles en proposition de sections markdown.""" + + def __init__( + self, + llm: LLMProvider, + extractor: PdfTextExtractor, + chunk_target_tokens: int = CHUNK_TARGET_TOKENS, + ) -> None: + self._llm = llm + self._extractor = extractor + self._chunk_target_tokens = chunk_target_tokens + + async def execute(self, pdf_bytes: bytes) -> RulesImportResult: + """Variante non-streamée : traite tout puis renvoie le résultat complet.""" + doc = self._extractor.extract(pdf_bytes) + chunks = chunk_text(doc.full_text, self._chunk_target_tokens) + logger.info( + "Import règles : %s page(s) (%s via OCR), %s morceau(x) à traiter.", + doc.page_count, doc.ocr_page_count, len(chunks), + ) + merger = _SectionMerger() + for i, chunk in enumerate(chunks): + merger.add(await self._map_chunk(chunk, index=i, total=len(chunks))) + return RulesImportResult( + sections=merger.result(), + page_count=doc.page_count, + ocr_page_count=doc.ocr_page_count, + ) + + async def stream(self, pdf_bytes: bytes): + """Variante streamée : yield des évènements d'avancement au fil de l'eau. + + Évènements (dicts) : {"type": "extracting"}, puis + {"type": "start", page_count, ocr_page_count, total}, puis un + {"type": "progress", current, total, new_sections:[...]} par morceau, + et enfin {"type": "done", sections, page_count, ocr_page_count}. + """ + # Émis AVANT l'extraction (potentiellement lente si OCR) pour que l'UI + # affiche tout de suite "Extraction…" plutôt qu'un écran figé. + yield {"type": "extracting"} + + doc = self._extractor.extract(pdf_bytes) + chunks = chunk_text(doc.full_text, self._chunk_target_tokens) + total = len(chunks) + logger.info( + "Import règles (stream) : %s page(s) (%s via OCR), %s morceau(x).", + doc.page_count, doc.ocr_page_count, total, + ) + yield { + "type": "start", + "page_count": doc.page_count, + "ocr_page_count": doc.ocr_page_count, + "total": total, + } + + merger = _SectionMerger() + for i, chunk in enumerate(chunks): + new_titles = merger.add(await self._map_chunk(chunk, index=i, total=total)) + yield { + "type": "progress", + "current": i + 1, + "total": total, + "new_sections": new_titles, + } + + yield { + "type": "done", + "sections": merger.result(), + "page_count": doc.page_count, + "ocr_page_count": doc.ocr_page_count, + } + + # --- MAP : un morceau → sections ----------------------------------------- + + async def _map_chunk(self, chunk: str, *, index: int, total: int) -> dict[str, str]: + prompt = ( + _MAP_SYSTEM.format( + canonical="\n".join(f" - {s}" for s in _CANONICAL_SECTIONS) + ) + + f"\n\n--- EXTRAIT {index + 1}/{total} ---\n{chunk}\n\n" + "Renvoie maintenant le JSON des sections." + ) + raw = await generate_with_retry( + self._llm, prompt, output_format="json", temperature=_TEMPERATURE) + return self._parse_sections(raw, index=index) + + @staticmethod + def _parse_sections(raw: str, *, index: int) -> dict[str, str]: + """Parse robuste : objet JSON équilibré, ou récupération partielle si tronqué.""" + parsed, recovered = load_json_object(raw) + if parsed is None: + logger.warning("Morceau %s : aucun objet JSON exploitable, ignoré.", index) + return {} + if recovered: + logger.warning( + "Morceau %s : sortie tronquée — récupération des sections complètes " + "(envisagez des morceaux plus petits).", index) + if not isinstance(parsed, dict): + logger.warning("Morceau %s : le LLM n'a pas renvoyé un objet, ignoré.", index) + return {} + return {str(k): str(v) for k, v in parsed.items()} diff --git a/brain/app/application/llm_json.py b/brain/app/application/llm_json.py new file mode 100644 index 0000000..bf07127 --- /dev/null +++ b/brain/app/application/llm_json.py @@ -0,0 +1,126 @@ +"""Extraction robuste d'un objet JSON depuis une réponse LLM. + +Les LLM enrobent souvent leur JSON : fences markdown ```json … ```, texte +d'introduction, commentaire de fin, voire un 2e objet. Un simple +`json.loads(raw)` ou un `raw[first_brace:last_brace]` échoue dans ces cas +("Extra data", accolade parasite dans une string, etc.). + +Cette fonction scanne depuis la PREMIÈRE `{` et renvoie exactement le premier +objet `{…}` ÉQUILIBRÉ, en ignorant les accolades à l'intérieur des chaînes JSON +et tout ce qui suit. Renvoie None si aucun objet complet n'est trouvé +(sortie tronquée / accolades non refermées). +""" +from __future__ import annotations + +import json + + +def load_json_object(raw: str) -> tuple[object | None, bool]: + """Parse un objet JSON depuis une réponse LLM, avec récupération si tronqué. + + Renvoie (objet_parsé, récupéré_partiellement) : + - d'abord on tente le 1er objet complet (extract_json_object) ; + - sinon on tente une réparation du JSON tronqué (repair_truncated_json), + auquel cas le second élément vaut True. + (None, False) si rien d'exploitable. + """ + obj = extract_json_object(raw) + if obj is not None: + try: + return json.loads(obj), False + except json.JSONDecodeError: + pass + repaired = repair_truncated_json(raw) + if repaired is not None: + try: + return json.loads(repaired), True + except json.JSONDecodeError: + pass + return None, False + + +def extract_json_object(raw: str) -> str | None: + if not raw: + return None + text = raw.strip() + start = text.find("{") + if start == -1: + return None + + depth = 0 + in_string = False + escape = False + for i in range(start, len(text)): + c = text[i] + if in_string: + if escape: + escape = False + elif c == "\\": + escape = True + elif c == '"': + in_string = False + else: + if c == '"': + in_string = True + elif c == "{": + depth += 1 + elif c == "}": + depth -= 1 + if depth == 0: + return text[start : i + 1] + return None # accolades non refermées (réponse probablement tronquée) + + +# Fermeture correspondante de chaque ouvrant, pour reconstituer un JSON tronqué. +_CLOSE_OF = {"{": "}", "[": "]"} + + +def repair_truncated_json(raw: str) -> str | None: + """Répare un JSON COUPÉ (sortie LLM tronquée) en gardant les éléments complets. + + On scanne depuis la première `{` et on retient le DERNIER point où un conteneur + (`}` ou `]`) vient de se fermer — donc juste après une sous-structure complète + (un arc / chapitre / scène / pièce / section entièrement écrit). On coupe là et + on referme les conteneurs encore ouverts. L'élément en cours d'écriture au moment + de la troncature est abandonné, mais tous les précédents sont sauvés. + + Renvoie une chaîne JSON équilibrée (à valider par json.loads) ou None. + """ + if not raw: + return None + text = raw.strip() + start = text.find("{") + if start == -1: + return None + + stack: list[str] = [] + in_string = False + escape = False + best_cut = -1 # index (exclusif) où couper + best_closing = "" # fermetures à ajouter pour rééquilibrer + + for i in range(start, len(text)): + c = text[i] + if in_string: + if escape: + escape = False + elif c == "\\": + escape = True + elif c == '"': + in_string = False + else: + if c == '"': + in_string = True + elif c in "{[": + stack.append(c) + elif c in "}]": + if stack: + stack.pop() + # Point de coupe sûr : on vient de fermer une sous-structure complète. + best_cut = i + 1 + best_closing = "".join(_CLOSE_OF[b] for b in reversed(stack)) + + if best_cut == -1: + return None # rien de complet à sauver + head = text[start:best_cut].rstrip().rstrip(",") + return head + best_closing diff --git a/brain/app/application/llm_retry.py b/brain/app/application/llm_retry.py new file mode 100644 index 0000000..bd57433 --- /dev/null +++ b/brain/app/application/llm_retry.py @@ -0,0 +1,47 @@ +"""Retry avec backoff pour les appels LLM one-shot (imports). + +Les imports enchaînent de nombreux appels en série ; un échec TRANSITOIRE sur un +seul morceau (503/502 surcharge serveur, 504/524 passerelle, timeout réseau) ne +doit pas faire échouer tout l'import. On réessaie quelques fois avec une attente +croissante. Après épuisement, on relaie l'erreur (problème durable : quota, panne). + +Réservé aux appels `generate` (one-shot, bufferisé) : réessayer est propre, sans +risque de doublons. À NE PAS utiliser sur le streaming (re-jouerait des tokens). +""" +from __future__ import annotations + +import asyncio +import logging + +from app.domain.ports import LLMProvider, LLMProviderError + +logger = logging.getLogger(__name__) + +_ATTEMPTS = 3 +_BASE_DELAY_SECONDS = 2.0 + + +async def generate_with_retry( + llm: LLMProvider, + prompt: str, + *, + output_format: str | None = None, + temperature: float | None = None, +) -> str: + """Comme `llm.generate`, mais réessaie les erreurs transitoires (backoff x2).""" + delay = _BASE_DELAY_SECONDS + last_error: LLMProviderError | None = None + for attempt in range(_ATTEMPTS): + try: + return await llm.generate(prompt, output_format=output_format, temperature=temperature) + except LLMProviderError as exc: + last_error = exc + if attempt < _ATTEMPTS - 1: + logger.warning( + "Appel LLM échoué (tentative %s/%s) : %s — nouvelle tentative dans %ss.", + attempt + 1, _ATTEMPTS, exc, delay, + ) + await asyncio.sleep(delay) + delay *= 2 + assert last_error is not None + raise last_error diff --git a/brain/app/core/config.py b/brain/app/core/config.py index b9c54ab..b5ec820 100644 --- a/brain/app/core/config.py +++ b/brain/app/core/config.py @@ -30,7 +30,10 @@ class Settings(BaseSettings): ollama_base_url: str = "http://localhost:11434" llm_model: str = "gemma4:26b" - llm_timeout_seconds: int = 120 + # Timeout HTTP des appels au LLM. Les imports/adaptations PDF génèrent de gros + # blocs (surtout avec l'extraction riche) → 120s était trop court. Surchargeable + # depuis l'UI (Paramètres) si un import lourd dépasse encore. + llm_timeout_seconds: int = 300 # 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 @@ -44,6 +47,13 @@ class Settings(BaseSettings): onemin_api_key: str = "" onemin_model: str = "gpt-4o-mini" + # Taille cible d'un morceau (en tokens) pour l'import de PDF (regles/campagne). + # Plus c'est gros, moins il y a de morceaux => moins de fragmentation et un + # import plus rapide, MAIS il faut que ca tienne dans la fenetre du modele. + # Defaut prudent (compatible Ollama num_ctx 16384). Sur un modele a grand + # contexte (ex: GPT-5 mini, 400k), monter a ~100000 traite un livre en 1 passe. + import_chunk_tokens: int = 10000 + # 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). diff --git a/brain/app/core/settings_store.py b/brain/app/core/settings_store.py index b648c01..10d1031 100644 --- a/brain/app/core/settings_store.py +++ b/brain/app/core/settings_store.py @@ -29,6 +29,7 @@ _ALLOWED_KEYS = frozenset({ "llm_num_ctx", "onemin_api_key", "onemin_model", + "import_chunk_tokens", }) diff --git a/brain/app/domain/models.py b/brain/app/domain/models.py index fb04aa1..992ebe6 100644 --- a/brain/app/domain/models.py +++ b/brain/app/domain/models.py @@ -301,3 +301,128 @@ class SessionContext: in_progress_quests: list[QuestSummary] = field(default_factory=list) locked_quest_titles: list[str] = field(default_factory=list) active_flags: list[str] = field(default_factory=list) + + +# ─────────────────────── Import de PDF (règles → GameSystem) ─────────────────────── + + +@dataclass(frozen=True) +class ExtractedPage: + """Texte extrait d'UNE page de PDF, avec la trace de la méthode utilisée. + + `used_ocr=True` signale que la page n'avait pas de couche texte exploitable + (born-digital absent) et a donc été rasterisée puis passée à l'OCR. Permet + au CLI/diagnostic de dire à l'utilisateur si son PDF est "texte" ou "scan". + """ + + index: int # 0-based + text: str + used_ocr: bool + + +@dataclass(frozen=True) +class ExtractedDocument: + """Résultat brut de l'extraction d'un PDF : une entrée par page.""" + + pages: list[ExtractedPage] + + @property + def page_count(self) -> int: + return len(self.pages) + + @property + def ocr_page_count(self) -> int: + return sum(1 for p in self.pages if p.used_ocr) + + @property + def full_text(self) -> str: + """Concatène le texte de toutes les pages, séparées par un saut double.""" + return "\n\n".join(p.text for p in self.pages if p.text.strip()) + + +@dataclass(frozen=True) +class RulesImportResult: + """Proposition structurée de règles : sections markdown indexées par titre. + + `sections` = {titre H2 → contenu markdown}. C'est une PROPOSITION : rien + n'est persisté côté Core tant que l'utilisateur n'a pas validé/édité. + `page_count` / `ocr_page_count` remontent au diagnostic d'extraction. + """ + + sections: dict[str, str] + page_count: int + ocr_page_count: int + + def to_markdown(self) -> str: + """Assemble les sections en un markdown monolithique (## titre + contenu). + + Format aligné sur `GameSystem.rulesMarkdown` côté Core (découpé par H2). + """ + blocks = [f"## {title}\n\n{content.strip()}" for title, content in self.sections.items()] + return "\n\n".join(blocks).strip() + "\n" + + +# ─────────────────────── Import de PDF de campagne (arbre arc→chapitre→scène) ────────────── + + +@dataclass(frozen=True) +class RoomProposal: + """Pièce d'un lieu explorable (donjon) proposée pour une scène.""" + + name: str + description: str + enemies: str = "" + loot: str = "" + + +@dataclass(frozen=True) +class SceneProposal: + """Scène proposée. `rooms` non vide => donjon/lieu explorable. + + On capture aussi, quand le livre les fournit, le texte d'encadré « à lire aux + joueurs » (`player_narration`) et les secrets/développement MJ (`gm_notes`). + """ + + name: str + description: str + player_narration: str = "" + gm_notes: str = "" + rooms: list[RoomProposal] = field(default_factory=list) + + +@dataclass(frozen=True) +class ChapterProposal: + """Chapitre proposé : nom + synopsis + ses scènes.""" + + name: str + description: str + scenes: list[SceneProposal] = field(default_factory=list) + + +@dataclass(frozen=True) +class ArcProposal: + """Arc proposé : nom + synopsis + type (LINEAR/HUB) + ses chapitres.""" + + name: str + description: str + arc_type: str = "LINEAR" + chapters: list[ChapterProposal] = field(default_factory=list) + + +@dataclass(frozen=True) +class CampaignImportResult: + """Proposition d'arborescence narrative extraite d'un PDF de campagne. + + PROPOSITION non persistée : l'UI laisse l'utilisateur réviser/éditer l'arbre + avant la création effective des arcs/chapitres/scènes côté Core. + """ + + arcs: list[ArcProposal] + page_count: int + ocr_page_count: int + + def counts(self) -> tuple[int, int, int]: + """(nb arcs, nb chapitres, nb scènes) — pour le diagnostic / la progression.""" + chapters = sum(len(a.chapters) for a in self.arcs) + scenes = sum(len(c.scenes) for a in self.arcs for c in a.chapters) + return len(self.arcs), chapters, scenes diff --git a/brain/app/domain/ports.py b/brain/app/domain/ports.py index a2b81d7..aaaeb97 100644 --- a/brain/app/domain/ports.py +++ b/brain/app/domain/ports.py @@ -7,7 +7,10 @@ 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 +from typing import TYPE_CHECKING, AsyncIterator, Protocol + +if TYPE_CHECKING: + from app.domain.models import ExtractedDocument class LLMProvider(Protocol): @@ -78,6 +81,32 @@ class LLMChatProvider(Protocol): ... +class PdfTextExtractor(Protocol): + """Port sortant — extrait le texte d'un PDF (born-digital ou scan). + + L'implémentation décide de sa stratégie (couche texte directe, repli OCR + page par page…). Le domaine ne connaît ni PyMuPDF ni Tesseract. + """ + + def extract(self, pdf_bytes: bytes) -> "ExtractedDocument": + """Extrait le texte du PDF fourni sous forme d'octets. + + Args: + pdf_bytes: contenu binaire du fichier PDF. + + Returns: + ExtractedDocument : une entrée par page (texte + flag OCR). + + Raises: + PdfExtractionError: si le PDF est illisible/corrompu. + """ + ... + + +class PdfExtractionError(Exception): + """Erreur du domaine : un PDF n'a pas pu être lu/extrait.""" + + class LLMProviderError(Exception): """Erreur du domaine signalant qu'un LLMProvider n'a pas pu générer. diff --git a/brain/app/infrastructure/onemin_adapter.py b/brain/app/infrastructure/onemin_adapter.py index 1849cb7..c44f4a8 100644 --- a/brain/app/infrastructure/onemin_adapter.py +++ b/brain/app/infrastructure/onemin_adapter.py @@ -14,6 +14,7 @@ avec des marqueurs de role lisibles pour le modele. from __future__ import annotations import json +import logging from typing import AsyncIterator import httpx @@ -22,6 +23,8 @@ from app.core.config import Settings from app.domain.models import ChatMessage from app.domain.ports import LLMProviderError +logger = logging.getLogger(__name__) + _API_BASE = "https://api.1min.ai/api/chat-with-ai" _PAYLOAD_TYPE = "UNIFY_CHAT_WITH_AI" @@ -48,6 +51,18 @@ class OneMinAiLLMProvider: "promptObject": {"prompt": prompt}, } + def _format_http_error(self, exc: httpx.HTTPError) -> str: + """Message d'erreur lisible. Un timeout httpx a un str() vide → on le nomme.""" + if isinstance(exc, httpx.TimeoutException): + return ( + f"Erreur 1min.ai : délai dépassé (timeout {self._timeout}s). Le modèle a mis " + "trop de temps à répondre — typique d'un morceau d'import trop gros. " + "Réduisez « Taille des morceaux à l'import » (Paramètres → Import de PDF) " + "ou augmentez le timeout LLM." + ) + detail = str(exc) or exc.__class__.__name__ + return f"Erreur 1min.ai ({exc.__class__.__name__}) : {detail}" + async def generate( self, prompt: str, @@ -55,18 +70,18 @@ class OneMinAiLLMProvider: 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 + """One-shot, mais via l'endpoint STREAMING (puis recollage). - return self._extract_result(data) + On NE passe PAS par l'endpoint non-streame `chat-with-ai` : sur les longues + generations (gros imports), la passerelle Cloudflare de 1min.ai coupe la + connexion au bout de ~100s et renvoie un HTTP 524. En streaming, des octets + circulent en continu => pas de 524, quelle que soit la duree. On accumule + tous les fragments pour reconstituer la reponse complete. + """ + chunks: list[str] = [] + async for token in self._stream_prompt(prompt): + chunks.append(token) + return "".join(chunks) async def stream_chat( self, @@ -75,17 +90,18 @@ class OneMinAiLLMProvider: 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. - """ + """Streame une conversation : aplatit les messages puis delegue au coeur SSE.""" prompt = self._flatten_messages(messages, system_prompt) - url = f"{_API_BASE}?isStreaming=true" + async for token in self._stream_prompt(prompt): + yield token + async def _stream_prompt(self, prompt: str) -> AsyncIterator[str]: + """Coeur du streaming SSE 1min.ai (`?isStreaming=true`) pour un prompt brut. + + 1min.ai expose : `event: content` → `data: {"content": "..."}`, `event: done`, + `event: error`. On yield le champ `content` au fil de l'arrivee. + """ + url = f"{_API_BASE}?isStreaming=true" async with httpx.AsyncClient(timeout=self._timeout) as client: try: async with client.stream( @@ -95,9 +111,7 @@ class OneMinAiLLMProvider: 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 + raise LLMProviderError(self._format_http_error(exc)) from exc # --- Helpers ------------------------------------------------------------ @@ -146,12 +160,21 @@ class OneMinAiLLMProvider: """ record = payload.get("aiRecord") or {} detail = record.get("aiRecordDetail") or {} - result = detail.get("resultObject") or [] - if isinstance(result, list): + result = detail.get("resultObject") + if isinstance(result, list) and result: return "".join(str(x) for x in result) - if isinstance(result, str): + if isinstance(result, str) and result: return result - raise LLMProviderError("Reponse 1min.ai inattendue : resultObject absent.") + + # Schema inattendu : on remonte un EXTRAIT du vrai payload pour diagnostiquer. + # Causes frequentes : credits/quota 1min.ai epuises, moderation, modele + # indisponible, ou reponse asynchrone (record cree mais resultat pas encore + # pret). Sans ce detail, l'erreur "resultObject absent" est aveugle. + snippet = json.dumps(payload, ensure_ascii=False) + if len(snippet) > 800: + snippet = snippet[:800] + "…" + logger.warning("Reponse 1min.ai inattendue (resultObject absent) : %s", snippet) + raise LLMProviderError(f"Reponse 1min.ai inattendue (resultObject absent) : {snippet}") @staticmethod def _flatten_messages( diff --git a/brain/app/infrastructure/pdf_extractor.py b/brain/app/infrastructure/pdf_extractor.py new file mode 100644 index 0000000..a8cb79e --- /dev/null +++ b/brain/app/infrastructure/pdf_extractor.py @@ -0,0 +1,102 @@ +"""Adapter d'extraction de texte PDF — implémente le port PdfTextExtractor. + +Stratégie HYBRIDE auto : + 1. On tente d'abord l'extraction de la couche texte (PyMuPDF). Les PDF + "born-digital" (livres de règles officiels type Nimble, faits dans + InDesign/Affinity : très graphiques mais avec une vraie couche texte) + passent par là → rapide, fidèle, AUCUN OCR. + 2. Si une page ne rend (quasi) aucun texte → c'est probablement une image + (scan ou page 100% illustrée). On rasterise la page et on la passe à + Tesseract (OCR). Gère donc aussi les scans purs et les PDF mixtes. + +Tesseract est un binaire SYSTÈME (installé dans l'image Docker du Brain). S'il +est absent (ex: exécution locale Windows sans install), l'OCR est désactivé +proprement : les pages-images ressortent vides mais l'extraction ne plante pas, +et le diagnostic le signale (used_ocr reste False, texte vide). +""" +from __future__ import annotations + +import logging + +import pymupdf as fitz # PyMuPDF — on importe par le nom canonique `pymupdf` +# (et NON `import fitz`) pour éviter la collision avec le faux paquet PyPI "fitz" +# qui échoue sur `from frontend import *`. + +from app.domain.models import ExtractedDocument, ExtractedPage +from app.domain.ports import PdfExtractionError + +logger = logging.getLogger(__name__) + +# En dessous de ce nombre de caractères "significatifs" sur une page, on +# considère qu'il n'y a pas de couche texte exploitable → repli OCR. +_MIN_TEXT_CHARS = 20 + +# DPI de rasterisation avant OCR. 300 = bon compromis qualité/vitesse pour du +# texte de livre. Plus haut = plus lent et plus gourmand en mémoire. +_OCR_DPI = 300 + +# Langues Tesseract : français + anglais (la plupart des règles de JDR FR ont +# des termes anglais résiduels). Doivent être installées dans l'image Docker +# (tesseract-ocr-fra, tesseract-ocr-eng). +_OCR_LANGS = "fra+eng" + + +class PyMuPdfTextExtractor: + """Extracteur PDF basé sur PyMuPDF, avec repli OCR Tesseract optionnel.""" + + def __init__(self) -> None: + # On détecte la disponibilité de l'OCR une seule fois (le binaire + # Tesseract ne va pas apparaître/disparaître en cours d'exécution). + self._ocr_available = self._detect_ocr() + + @staticmethod + def _detect_ocr() -> bool: + """True si pytesseract + le binaire Tesseract sont disponibles.""" + try: + import pytesseract + + pytesseract.get_tesseract_version() + return True + except Exception as exc: # ImportError, TesseractNotFoundError, etc. + logger.warning( + "OCR indisponible (Tesseract non installé ?) : %s. " + "Les pages sans couche texte ressortiront vides.", + exc, + ) + return False + + def extract(self, pdf_bytes: bytes) -> ExtractedDocument: + try: + doc = fitz.open(stream=pdf_bytes, filetype="pdf") + except Exception as exc: + raise PdfExtractionError(f"PDF illisible ou corrompu : {exc}") from exc + + pages: list[ExtractedPage] = [] + try: + for index, page in enumerate(doc): + text = (page.get_text() or "").strip() + used_ocr = False + if len(text) < _MIN_TEXT_CHARS and self._ocr_available: + ocr_text = self._ocr_page(page) + if ocr_text.strip(): + text = ocr_text.strip() + used_ocr = True + pages.append(ExtractedPage(index=index, text=text, used_ocr=used_ocr)) + finally: + doc.close() + + return ExtractedDocument(pages=pages) + + @staticmethod + def _ocr_page(page: "fitz.Page") -> str: + """Rasterise une page et lui applique l'OCR Tesseract.""" + import pytesseract + from PIL import Image + + pix = page.get_pixmap(dpi=_OCR_DPI) + img = Image.frombytes("RGB", (pix.width, pix.height), pix.samples) + try: + return pytesseract.image_to_string(img, lang=_OCR_LANGS) + except Exception as exc: + logger.warning("Échec OCR sur la page %s : %s", page.number, exc) + return "" diff --git a/brain/app/main.py b/brain/app/main.py index 52199be..779c240 100644 --- a/brain/app/main.py +++ b/brain/app/main.py @@ -10,12 +10,15 @@ from typing import Annotated, AsyncIterator, Literal import hmac import httpx import tiktoken -from fastapi import Depends, FastAPI, HTTPException, Request +from fastapi import Depends, FastAPI, File, Form, HTTPException, Request, UploadFile from fastapi.responses import JSONResponse, StreamingResponse from pydantic import BaseModel, Field +from app.application.adapt_campaign import AdaptCampaignUseCase from app.application.chat import ChatUseCase from app.application.generate_page import GeneratePageUseCase +from app.application.import_campaign import ImportCampaignUseCase +from app.application.import_rules import ImportRulesUseCase from app.core.config import Settings, get_settings from app.core.settings_store import save_overrides from app.domain.models import ( @@ -39,14 +42,15 @@ from app.domain.models import ( SceneSummary, SessionContext, ) -from app.domain.ports import LLMProvider, LLMProviderError +from app.domain.ports import LLMProvider, LLMProviderError, PdfExtractionError from app.infrastructure.ollama_adapter import OllamaLLMProvider from app.infrastructure.onemin_adapter import OneMinAiLLMProvider +from app.infrastructure.pdf_extractor import PyMuPdfTextExtractor app = FastAPI( title="LoreMind Brain", description="Backend IA pour la génération de contenu narratif.", - version="0.9.2-beta", + version="0.10.0-beta", ) @@ -375,6 +379,40 @@ def get_chat_use_case( return ChatUseCase(llm=llm) # type: ignore[arg-type] +# Extracteur PDF partagé : la détection OCR (version Tesseract) a un coût +# (subprocess) qu'on ne veut pas payer à chaque requête → singleton module. +_PDF_EXTRACTOR = PyMuPdfTextExtractor() + + +def get_import_rules_use_case( + llm: Annotated[LLMProvider, Depends(get_llm_provider)], + settings: Annotated[Settings, Depends(get_settings)], +) -> ImportRulesUseCase: + """Factory du use case d'import de règles PDF (extraction + structuration).""" + return ImportRulesUseCase( + llm=llm, extractor=_PDF_EXTRACTOR, chunk_target_tokens=settings.import_chunk_tokens) + + +def get_import_campaign_use_case( + llm: Annotated[LLMProvider, Depends(get_llm_provider)], + settings: Annotated[Settings, Depends(get_settings)], +) -> ImportCampaignUseCase: + """Factory du use case d'import de campagne PDF (extraction + arborescence).""" + return ImportCampaignUseCase( + llm=llm, extractor=_PDF_EXTRACTOR, chunk_target_tokens=settings.import_chunk_tokens) + + +def get_adapt_campaign_use_case( + llm: Annotated[LLMProvider, Depends(get_llm_provider)], + settings: Annotated[Settings, Depends(get_settings)], +) -> AdaptCampaignUseCase: + """Factory du use case d'adaptation d'un PDF à une campagne (conseils streamés).""" + # L'adapter satisfait aussi LLMChatProvider (stream_chat) par duck typing. + # Budget d'entrée = taille de morceau configurée (qui passe déjà côté provider). + return AdaptCampaignUseCase( # type: ignore[arg-type] + llm=llm, extractor=_PDF_EXTRACTOR, max_input_tokens=settings.import_chunk_tokens) + + # --- Endpoints --- @@ -428,6 +466,177 @@ async def generate_page( return GeneratePageResponseDTO(values=result.values) +class RulesImportResponseDTO(BaseModel): + """Proposition de sections de règles extraites d'un PDF. + + `sections` = {titre → contenu markdown}. C'est une PROPOSITION : le Core + et l'UI laissent l'utilisateur réviser/éditer avant toute persistance. + `ocr_page_count` permet d'indiquer si le PDF était un scan (OCR utilisé). + """ + + sections: dict[str, str] + page_count: int + ocr_page_count: int + + +# Garde-fou taille : un livre de règles dépasse rarement quelques dizaines de Mo. +# Au-delà, on refuse (probable erreur d'upload) plutôt que d'OOM le conteneur. +_MAX_PDF_BYTES = 60 * 1024 * 1024 # 60 Mo + + +@app.post("/import/rules", response_model=RulesImportResponseDTO) +async def import_rules( + use_case: Annotated[ImportRulesUseCase, Depends(get_import_rules_use_case)], + file: UploadFile = File(...), +) -> RulesImportResponseDTO: + """Import d'un PDF de règles → sections markdown structurées (proposition). + + Extrait le texte (couche texte + repli OCR par page pour les scans), découpe, + et demande au LLM de répartir les règles en sections thématiques. Ne persiste + rien : renvoie la proposition au Core, qui la présente pour révision. + """ + content = await file.read() + if not content: + raise HTTPException(status_code=422, detail="Fichier PDF vide.") + if len(content) > _MAX_PDF_BYTES: + raise HTTPException( + status_code=413, + detail=f"PDF trop volumineux (> {_MAX_PDF_BYTES // (1024 * 1024)} Mo).", + ) + + try: + result = await use_case.execute(content) + except PdfExtractionError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except LLMProviderError as exc: + raise HTTPException(status_code=502, detail=str(exc)) from exc + + return RulesImportResponseDTO( + sections=result.sections, + page_count=result.page_count, + ocr_page_count=result.ocr_page_count, + ) + + +@app.post("/import/rules/stream") +async def import_rules_stream( + use_case: Annotated[ImportRulesUseCase, Depends(get_import_rules_use_case)], + file: UploadFile = File(...), +) -> StreamingResponse: + """Import streamé : émet l'avancement (SSE) puis le résultat final. + + Évènements SSE : + - `event: extracting` → data: {} (extraction en cours) + - `event: start` → data: {page_count, ocr_page_count, total} + - `event: progress` → data: {current, total, new_sections:[...]} + - `event: done` → data: {sections, page_count, ocr_page_count} + - `event: error` → data: {message} + """ + content = await file.read() + + def _sse(event: str, data: dict) -> str: + return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n" + + async def event_stream() -> AsyncIterator[str]: + if not content: + yield _sse("error", {"message": "Fichier PDF vide."}) + return + if len(content) > _MAX_PDF_BYTES: + yield _sse("error", {"message": f"PDF trop volumineux (> {_MAX_PDF_BYTES // (1024 * 1024)} Mo)."}) + return + try: + async for ev in use_case.stream(content): + event_type = ev.pop("type") + yield _sse(event_type, ev) + except PdfExtractionError as exc: + yield _sse("error", {"message": str(exc)}) + except LLMProviderError as exc: + yield _sse("error", {"message": str(exc)}) + + return StreamingResponse(event_stream(), media_type="text/event-stream") + + +@app.post("/import/campaign/stream") +async def import_campaign_stream( + use_case: Annotated[ImportCampaignUseCase, Depends(get_import_campaign_use_case)], + file: UploadFile = File(...), +) -> StreamingResponse: + """Import streamé d'un PDF de campagne → arbre arc→chapitre→scène (SSE). + + Évènements : `extracting`, `start` {page_count, ocr_page_count, total}, + `progress` {current, total, arc_count, chapter_count, scene_count}, + `done` {arcs:[...], page_count, ocr_page_count}, `error` {message}. + """ + content = await file.read() + + def _sse(event: str, data: dict) -> str: + return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n" + + async def event_stream() -> AsyncIterator[str]: + if not content: + yield _sse("error", {"message": "Fichier PDF vide."}) + return + if len(content) > _MAX_PDF_BYTES: + yield _sse("error", {"message": f"PDF trop volumineux (> {_MAX_PDF_BYTES // (1024 * 1024)} Mo)."}) + return + try: + async for ev in use_case.stream(content): + event_type = ev.pop("type") + yield _sse(event_type, ev) + except PdfExtractionError as exc: + yield _sse("error", {"message": str(exc)}) + except LLMProviderError as exc: + yield _sse("error", {"message": str(exc)}) + + return StreamingResponse(event_stream(), media_type="text/event-stream") + + +@app.post("/adapt/campaign/stream") +async def adapt_campaign_stream( + use_case: Annotated[AdaptCampaignUseCase, Depends(get_adapt_campaign_use_case)], + file: UploadFile = File(...), + brief: str = Form(""), + messages: str = Form("[]"), +) -> StreamingResponse: + """Adaptation CONVERSATIONNELLE d'un PDF à une campagne (SSE markdown). + + `brief` = description de la campagne (Core). `messages` = JSON de l'échange + ([{role, content}, …]) ; vide au 1er tour. Évènements : `token`, `done`, `error`. + """ + content = await file.read() + + try: + raw_messages = json.loads(messages) if messages else [] + except json.JSONDecodeError: + raw_messages = [] + convo = [ + ChatMessage(role=str(m.get("role", "user")), content=str(m.get("content", ""))) + for m in raw_messages + if isinstance(m, dict) and str(m.get("content", "")).strip() + ] + + def _sse(event: str, data: dict) -> str: + return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n" + + async def event_stream() -> AsyncIterator[str]: + if not content: + yield _sse("error", {"message": "Fichier PDF vide."}) + return + if len(content) > _MAX_PDF_BYTES: + yield _sse("error", {"message": f"PDF trop volumineux (> {_MAX_PDF_BYTES // (1024 * 1024)} Mo)."}) + return + try: + async for token in use_case.stream(content, brief, convo): + yield _sse("token", {"token": token}) + yield _sse("done", {}) + except PdfExtractionError as exc: + yield _sse("error", {"message": str(exc)}) + except LLMProviderError as exc: + yield _sse("error", {"message": str(exc)}) + + return StreamingResponse(event_stream(), media_type="text/event-stream") + + @app.post("/chat/stream") async def chat_stream( body: ChatStreamRequestDTO, @@ -685,6 +894,10 @@ class SettingsDTO(BaseModel): # Fenetre de contexte effective passee au modele (num_ctx Ollama) — sert # aussi de plafond a la jauge de contexte UI. llm_num_ctx: int + # Taille cible d'un morceau (tokens) pour l'import de PDF (regles/campagne). + import_chunk_tokens: int + # Timeout HTTP des appels LLM (s). A monter si les imports lourds expirent. + llm_timeout_seconds: int class SettingsUpdateDTO(BaseModel): @@ -697,6 +910,8 @@ class SettingsUpdateDTO(BaseModel): # Chaine vide => on efface la cle. None => pas de changement. onemin_api_key: str | None = None llm_num_ctx: int | None = None + import_chunk_tokens: int | None = None + llm_timeout_seconds: int | None = None def _to_settings_dto(s: Settings) -> SettingsDTO: @@ -707,6 +922,8 @@ def _to_settings_dto(s: Settings) -> SettingsDTO: onemin_model=s.onemin_model, onemin_api_key_set=bool(s.onemin_api_key), llm_num_ctx=s.llm_num_ctx, + import_chunk_tokens=s.import_chunk_tokens, + llm_timeout_seconds=s.llm_timeout_seconds, ) diff --git a/brain/requirements.txt b/brain/requirements.txt index 20c115d..e25305c 100644 --- a/brain/requirements.txt +++ b/brain/requirements.txt @@ -2,6 +2,8 @@ fastapi==0.115.* uvicorn[standard]==0.32.* httpx==0.27.* pydantic-settings==2.6.* +# Requis par FastAPI pour les uploads multipart (UploadFile) — import de PDF. +python-multipart==0.0.* pydantic @@ -10,3 +12,12 @@ pydantic # la plupart des modeles Llama/Gemma/Mistral (~5-10% d'ecart) — suffisant # pour une jauge visuelle. tiktoken==0.8.* + +# Import de PDF de regles (-> GameSystem). Extraction de la couche texte +# (born-digital) avec repli OCR par page pour les scans. +# - pymupdf : extraction texte + rasterisation des pages (pas besoin de poppler) +# - pytesseract + Pillow : OCR Tesseract sur les pages sans couche texte +# (le binaire tesseract-ocr est installe dans le Dockerfile, langues fra+eng) +pymupdf==1.24.* +pytesseract==0.3.* +Pillow==11.* diff --git a/brain/scripts/test_import_rules.py b/brain/scripts/test_import_rules.py new file mode 100644 index 0000000..4d6c2e3 --- /dev/null +++ b/brain/scripts/test_import_rules.py @@ -0,0 +1,107 @@ +"""CLI de test pour l'import de règles PDF — boucle de feedback rapide. + +But : tester l'extraction + la structuration en sections sur un VRAI PDF +(ex: livre Nimble), SANS passer par HTTP, le Core ou l'UI. + +Usage (depuis le dossier brain/, venv activé) : + python scripts/test_import_rules.py "chemin/vers/regles.pdf" + +Le provider LLM utilisé est celui configuré (.env + overrides de l'écran +Paramètres) — donc 1min.ai si tu l'as sélectionné. Le script : + 1. extrait le texte (et dit, page par page, si l'OCR s'est déclenché), + 2. découpe + structure via le LLM, + 3. affiche un résumé des sections, + 4. écrit le markdown complet dans ".rules.md" à côté du PDF. +""" +from __future__ import annotations + +import asyncio +import logging +import sys +from pathlib import Path + +# Permet `import app...` quel que soit le cwd : on ajoute la racine brain/. +_BRAIN_ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(_BRAIN_ROOT)) + +from app.application.import_rules import ImportRulesUseCase # noqa: E402 +from app.core.config import get_settings # noqa: E402 +from app.domain.ports import LLMProvider, LLMProviderError, PdfExtractionError # noqa: E402 +from app.infrastructure.ollama_adapter import OllamaLLMProvider # noqa: E402 +from app.infrastructure.onemin_adapter import OneMinAiLLMProvider # noqa: E402 +from app.infrastructure.pdf_extractor import PyMuPdfTextExtractor # noqa: E402 + + +def _build_provider() -> LLMProvider: + """Réplique la factory de main.py (choix provider selon les settings).""" + settings = get_settings() + print(f"→ Provider LLM : {settings.llm_provider} " + f"(modèle : {settings.onemin_model if settings.llm_provider == 'onemin' else settings.llm_model})") + if settings.llm_provider == "onemin": + return OneMinAiLLMProvider(settings) + return OllamaLLMProvider(settings) + + +async def _run(pdf_path: Path) -> None: + pdf_bytes = pdf_path.read_bytes() + print(f"→ PDF chargé : {pdf_path.name} ({len(pdf_bytes) // 1024} Ko)\n") + + extractor = PyMuPdfTextExtractor() + + # 1. Extraction seule d'abord, pour le diagnostic page/OCR avant tout LLM. + try: + doc = extractor.extract(pdf_bytes) + except PdfExtractionError as exc: + print(f"✗ Extraction impossible : {exc}") + return + + print(f"=== Extraction : {doc.page_count} page(s), " + f"{doc.ocr_page_count} via OCR, " + f"{len(doc.full_text)} caractères ===") + if doc.ocr_page_count == 0: + print(" → PDF born-digital détecté (couche texte présente, OCR non nécessaire).") + else: + print(f" → {doc.ocr_page_count} page(s) sans couche texte : OCR déclenché.") + if not doc.full_text.strip(): + print("\n✗ Aucun texte extrait. Si le PDF est un scan, vérifie que Tesseract " + "est installé (sinon l'OCR est désactivé).") + return + + # 2. Structuration via le LLM (réutilise l'extraction déjà faite indirectement : + # le use case ré-extrait, coût négligeable vs l'appel LLM). + print("\n=== Structuration via LLM (peut prendre un moment selon le nombre de morceaux)… ===") + use_case = ImportRulesUseCase(llm=_build_provider(), extractor=extractor) + try: + result = await use_case.execute(pdf_bytes) + except LLMProviderError as exc: + print(f"✗ Échec LLM : {exc}") + return + + if not result.sections: + print("\n✗ Aucune section proposée (le modèle n'a rien renvoyé d'exploitable).") + return + + print(f"\n=== {len(result.sections)} section(s) proposée(s) ===") + for title, content in result.sections.items(): + preview = content.strip().replace("\n", " ")[:90] + print(f" • {title} ({len(content)} car.) — {preview}…") + + out_path = pdf_path.with_suffix(".rules.md") + out_path.write_text(result.to_markdown(), encoding="utf-8") + print(f"\n✓ Markdown complet écrit dans : {out_path}") + + +def main() -> None: + logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s — %(message)s") + if len(sys.argv) != 2: + print("Usage : python scripts/test_import_rules.py \"chemin/vers/regles.pdf\"") + sys.exit(1) + pdf_path = Path(sys.argv[1]).expanduser() + if not pdf_path.is_file(): + print(f"✗ Fichier introuvable : {pdf_path}") + sys.exit(1) + asyncio.run(_run(pdf_path)) + + +if __name__ == "__main__": + main() diff --git a/core/pom.xml b/core/pom.xml index a82a146..1779ab2 100644 --- a/core/pom.xml +++ b/core/pom.xml @@ -14,7 +14,7 @@ com.loremind loremind-core - 0.9.2-beta + 0.10.0-beta LoreMind Core Backend Core - Architecture Hexagonale diff --git a/core/src/main/java/com/loremind/application/campaigncontext/CampaignAdaptService.java b/core/src/main/java/com/loremind/application/campaigncontext/CampaignAdaptService.java new file mode 100644 index 0000000..c0c2c31 --- /dev/null +++ b/core/src/main/java/com/loremind/application/campaigncontext/CampaignAdaptService.java @@ -0,0 +1,118 @@ +package com.loremind.application.campaigncontext; + +import com.loremind.application.generationcontext.CampaignStructuralContextBuilder; +import com.loremind.application.generationcontext.LoreStructuralContextBuilder; +import com.loremind.domain.campaigncontext.Campaign; +import com.loremind.domain.campaigncontext.ports.CampaignPdfAdvisor; +import com.loremind.domain.campaigncontext.ports.CampaignRepository; +import com.loremind.domain.generationcontext.CampaignStructuralContext; +import com.loremind.domain.generationcontext.CampaignStructuralContext.ArcSummary; +import com.loremind.domain.generationcontext.CampaignStructuralContext.ChapterSummary; +import com.loremind.domain.generationcontext.CampaignStructuralContext.NpcSummary; +import com.loremind.domain.generationcontext.CampaignStructuralContext.SceneSummary; +import com.loremind.domain.generationcontext.LoreStructuralContext; +import com.loremind.domain.generationcontext.LoreStructuralContext.PageSummary; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; + +/** + * Service applicatif : conseils d'adaptation d'un PDF à une campagne existante. + * + *

Assemble un « brief » de la campagne (structure + PNJ + univers/lore) — la même + * matière que le chat de campagne — et délègue la génération streamée au Brain via + * {@link CampaignPdfAdvisor}. Ne persiste rien : la sortie est du conseil libre.

+ */ +@Service +public class CampaignAdaptService { + + private final CampaignRepository campaignRepository; + private final CampaignStructuralContextBuilder campaignContextBuilder; + private final LoreStructuralContextBuilder loreContextBuilder; + private final CampaignPdfAdvisor advisor; + + public CampaignAdaptService( + CampaignRepository campaignRepository, + CampaignStructuralContextBuilder campaignContextBuilder, + LoreStructuralContextBuilder loreContextBuilder, + CampaignPdfAdvisor advisor) { + this.campaignRepository = campaignRepository; + this.campaignContextBuilder = campaignContextBuilder; + this.loreContextBuilder = loreContextBuilder; + this.advisor = advisor; + } + + public void adviseStreaming( + String campaignId, + byte[] pdfBytes, + String filename, + String messagesJson, + Consumer onToken, + Runnable onComplete, + Consumer onError) { + Campaign campaign = campaignRepository.findById(campaignId) + .orElseThrow(() -> new IllegalArgumentException("Campagne introuvable : " + campaignId)); + advisor.adviseStreaming( + pdfBytes, filename, buildBrief(campaign), messagesJson, onToken, onComplete, onError); + } + + /** Construit un résumé markdown de la campagne (structure + PNJ + lore). */ + private String buildBrief(Campaign campaign) { + CampaignStructuralContext cc = campaignContextBuilder.build(campaign.getId()); + StringBuilder sb = new StringBuilder(); + + sb.append("# Campagne : ").append(cc.campaignName()).append("\n"); + if (notBlank(cc.campaignDescription())) sb.append(cc.campaignDescription()).append("\n"); + + sb.append("\n## Structure (arcs → chapitres → scènes)\n"); + if (cc.arcs().isEmpty()) { + sb.append("_(aucun arc pour le moment)_\n"); + } + for (ArcSummary arc : cc.arcs()) { + sb.append("### Arc : ").append(arc.name()); + if (notBlank(arc.description())) sb.append(" — ").append(arc.description()); + sb.append("\n"); + for (ChapterSummary ch : arc.chapters()) { + sb.append("- Chapitre : ").append(ch.name()); + if (notBlank(ch.description())) sb.append(" — ").append(ch.description()); + sb.append("\n"); + for (SceneSummary sc : ch.scenes()) { + sb.append(" - Scène : ").append(sc.name()); + if (notBlank(sc.description())) sb.append(" — ").append(sc.description()); + sb.append("\n"); + } + } + } + + if (!cc.npcs().isEmpty()) { + sb.append("\n## PNJ existants\n"); + for (NpcSummary n : cc.npcs()) { + sb.append("- ").append(n.name()); + if (notBlank(n.snippet())) sb.append(" : ").append(n.snippet()); + sb.append("\n"); + } + } + + if (campaign.isLinkedToLore()) { + loreContextBuilder.buildOptional(campaign.getLoreId()).ifPresent(lore -> appendLore(sb, lore)); + } + return sb.toString(); + } + + private void appendLore(StringBuilder sb, LoreStructuralContext lore) { + sb.append("\n## Univers (Lore) : ").append(lore.loreName()).append("\n"); + if (notBlank(lore.loreDescription())) sb.append(lore.loreDescription()).append("\n"); + for (Map.Entry> entry : lore.folders().entrySet()) { + sb.append("### ").append(entry.getKey()).append("\n"); + for (PageSummary page : entry.getValue()) { + sb.append("- ").append(page.title()).append("\n"); + } + } + } + + private static boolean notBlank(String s) { + return s != null && !s.isBlank(); + } +} diff --git a/core/src/main/java/com/loremind/application/campaigncontext/CampaignImportService.java b/core/src/main/java/com/loremind/application/campaigncontext/CampaignImportService.java new file mode 100644 index 0000000..406c740 --- /dev/null +++ b/core/src/main/java/com/loremind/application/campaigncontext/CampaignImportService.java @@ -0,0 +1,181 @@ +package com.loremind.application.campaigncontext; + +import com.loremind.domain.campaigncontext.Arc; +import com.loremind.domain.campaigncontext.ArcType; +import com.loremind.domain.campaigncontext.CampaignImportProgress; +import com.loremind.domain.campaigncontext.CampaignImportProposal; +import com.loremind.domain.campaigncontext.CampaignImportProposal.ArcProposal; +import com.loremind.domain.campaigncontext.CampaignImportProposal.ChapterProposal; +import com.loremind.domain.campaigncontext.CampaignImportProposal.RoomProposal; +import com.loremind.domain.campaigncontext.CampaignImportProposal.SceneProposal; +import com.loremind.domain.campaigncontext.Chapter; +import com.loremind.domain.campaigncontext.Room; +import com.loremind.domain.campaigncontext.Scene; +import com.loremind.domain.campaigncontext.ports.CampaignPdfImporter; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.function.Consumer; + +/** + * Service applicatif pour l'import d'un PDF de campagne. + * + *

Deux temps, conformes au principe « revue avant écriture » : + * 1. {@link #importStructureStreaming} génère une PROPOSITION d'arbre (rien + * n'est persisté), streamée pour l'avancement. + * 2. {@link #applyStructure} crée réellement les arcs/chapitres/scènes une fois + * l'arbre révisé/édité par l'utilisateur.

+ */ +@Service +public class CampaignImportService { + + private final CampaignPdfImporter campaignPdfImporter; + private final CampaignService campaignService; + private final ArcService arcService; + private final ChapterService chapterService; + private final SceneService sceneService; + + public CampaignImportService( + CampaignPdfImporter campaignPdfImporter, + CampaignService campaignService, + ArcService arcService, + ChapterService chapterService, + SceneService sceneService) { + this.campaignPdfImporter = campaignPdfImporter; + this.campaignService = campaignService; + this.arcService = arcService; + this.chapterService = chapterService; + this.sceneService = sceneService; + } + + /** Résumé de ce qui a été créé par {@link #applyStructure}. */ + public record ApplyResult(int arcsCreated, int chaptersCreated, int scenesCreated) {} + + /** Génère la proposition d'arbre (streamée). Ne persiste rien. */ + public void importStructureStreaming( + byte[] pdfBytes, + String filename, + Consumer onProgress, + Consumer onDone, + Consumer onError) { + campaignPdfImporter.importCampaignStreaming(pdfBytes, filename, onProgress, onDone, onError); + } + + /** + * Crée les arcs/chapitres/scènes de l'arbre révisé dans la campagne. Les + * arcs sont ajoutés APRÈS les arcs existants (ordre continué). Tout est créé + * dans une seule transaction (rollback si une étape échoue). + */ + @Transactional + public ApplyResult applyStructure(String campaignId, CampaignImportProposal proposal) { + if (!campaignService.campaignExists(campaignId)) { + throw new IllegalArgumentException("Campagne introuvable : " + campaignId); + } + + int arcsCreated = 0, chaptersCreated = 0, scenesCreated = 0; + + // Les nouveaux nœuds sont ordonnés APRÈS les frères existants (déjà comptés + // via leur existingId dans l'arbre fusionné venu de la revue). + int arcOrder = countExisting(proposal.arcs(), ArcProposal::existingId); + for (ArcProposal arcP : proposal.arcs()) { + if (isBlank(arcP.name())) continue; + String arcId; + if (!isBlank(arcP.existingId())) { + arcId = arcP.existingId(); // arc déjà présent → on s'y rattache + } else { + arcOrder++; + Arc arc = arcService.createArc(Arc.builder() + .name(arcP.name().trim()) + .description(nullIfBlank(arcP.description())) + .campaignId(campaignId) + .order(arcOrder) + .type(parseArcType(arcP.type())) + .build()); + arcId = arc.getId(); + arcsCreated++; + } + + int chapterOrder = countExisting(arcP.chapters(), ChapterProposal::existingId); + for (ChapterProposal chapP : safe(arcP.chapters())) { + if (isBlank(chapP.name())) continue; + String chapId; + if (!isBlank(chapP.existingId())) { + chapId = chapP.existingId(); + } else { + chapterOrder++; + Chapter chapter = chapterService.createChapter( + chapP.name().trim(), nullIfBlank(chapP.description()), arcId, chapterOrder); + chapId = chapter.getId(); + chaptersCreated++; + } + + int sceneOrder = countExisting(chapP.scenes(), SceneProposal::existingId); + for (SceneProposal sceneP : safe(chapP.scenes())) { + if (isBlank(sceneP.name())) continue; + if (!isBlank(sceneP.existingId())) continue; // scène déjà présente + sceneOrder++; + sceneService.createScene(Scene.builder() + .name(sceneP.name().trim()) + .description(nullIfBlank(sceneP.description())) + .playerNarration(nullIfBlank(sceneP.playerNarration())) + .gmSecretNotes(nullIfBlank(sceneP.gmNotes())) + .chapterId(chapId) + .order(sceneOrder) + .rooms(toRooms(sceneP.rooms())) + .build()); + scenesCreated++; + } + } + } + return new ApplyResult(arcsCreated, chaptersCreated, scenesCreated); + } + + /** Compte les nœuds déjà présents (existingId non vide) d'une liste. */ + private static int countExisting(List list, java.util.function.Function idOf) { + if (list == null) return 0; + int n = 0; + for (T t : list) { + if (!isBlank(idOf.apply(t))) n++; + } + return n; + } + + /** "HUB" (insensible à la casse) → {@link ArcType#HUB} ; tout le reste → LINEAR. */ + private static ArcType parseArcType(String type) { + return "HUB".equalsIgnoreCase(type == null ? "" : type.trim()) ? ArcType.HUB : ArcType.LINEAR; + } + + /** Convertit les pièces proposées en {@link Room} (ID généré, ordre = index). */ + private static List toRooms(List proposals) { + List rooms = new ArrayList<>(); + if (proposals == null) return rooms; + int order = 0; + for (RoomProposal r : proposals) { + if (isBlank(r.name())) continue; + rooms.add(Room.builder() + .id(UUID.randomUUID().toString()) + .name(r.name().trim()) + .description(nullIfBlank(r.description())) + .enemies(nullIfBlank(r.enemies())) + .loot(nullIfBlank(r.loot())) + .order(order++) + .build()); + } + return rooms; + } + + private static java.util.List safe(java.util.List list) { + return list == null ? java.util.List.of() : list; + } + + private static boolean isBlank(String s) { + return s == null || s.isBlank(); + } + + private static String nullIfBlank(String s) { + return isBlank(s) ? null : s.trim(); + } +} diff --git a/core/src/main/java/com/loremind/application/campaigncontext/SceneService.java b/core/src/main/java/com/loremind/application/campaigncontext/SceneService.java index 927e23d..7dbfeb8 100644 --- a/core/src/main/java/com/loremind/application/campaigncontext/SceneService.java +++ b/core/src/main/java/com/loremind/application/campaigncontext/SceneService.java @@ -6,6 +6,7 @@ import com.loremind.domain.campaigncontext.ports.SceneRepository; import org.springframework.beans.BeanUtils; import org.springframework.stereotype.Service; +import java.util.ArrayList; import java.util.List; import java.util.Optional; import java.util.Set; @@ -40,6 +41,19 @@ public class SceneService { return sceneRepository.save(scene); } + /** + * Crée une scène à partir d'un objet Scene complet (tous les champs : narration + * joueurs, notes MJ, pièces…). Utilisé par l'import de campagne. L'ID est forcé + * à null pour laisser le repo en générer un nouveau. + */ + public Scene createScene(Scene input) { + input.setId(null); + if (input.getRooms() == null) { + input.setRooms(new ArrayList<>()); + } + return sceneRepository.save(input); + } + public Optional getSceneById(String id) { return sceneRepository.findById(id); } diff --git a/core/src/main/java/com/loremind/application/gamesystemcontext/GameSystemService.java b/core/src/main/java/com/loremind/application/gamesystemcontext/GameSystemService.java index 0a5e769..5782924 100644 --- a/core/src/main/java/com/loremind/application/gamesystemcontext/GameSystemService.java +++ b/core/src/main/java/com/loremind/application/gamesystemcontext/GameSystemService.java @@ -1,7 +1,9 @@ package com.loremind.application.gamesystemcontext; import com.loremind.domain.gamesystemcontext.GameSystem; +import com.loremind.domain.gamesystemcontext.RulesImportResult; import com.loremind.domain.gamesystemcontext.ports.GameSystemRepository; +import com.loremind.domain.gamesystemcontext.ports.RulesPdfImporter; import com.loremind.domain.shared.template.TemplateField; import org.springframework.stereotype.Service; @@ -12,9 +14,34 @@ import java.util.Optional; public class GameSystemService { private final GameSystemRepository gameSystemRepository; + private final RulesPdfImporter rulesPdfImporter; - public GameSystemService(GameSystemRepository gameSystemRepository) { + public GameSystemService(GameSystemRepository gameSystemRepository, + RulesPdfImporter rulesPdfImporter) { this.gameSystemRepository = gameSystemRepository; + this.rulesPdfImporter = rulesPdfImporter; + } + + /** + * Importe un PDF de règles et renvoie une PROPOSITION de sections (titre → + * markdown). Ne persiste rien : l'UI laisse l'utilisateur réviser/éditer + * puis enregistrer le GameSystem via {@link #updateGameSystem}/{@link #createGameSystem}. + */ + public RulesImportResult importRulesFromPdf(byte[] pdfBytes, String filename) { + return rulesPdfImporter.importRules(pdfBytes, filename); + } + + /** + * Variante streamée de {@link #importRulesFromPdf} : remonte l'avancement via + * callbacks (import long → l'UI affiche une progression). Ne persiste rien. + */ + public void importRulesFromPdfStreaming( + byte[] pdfBytes, + String filename, + java.util.function.Consumer onProgress, + java.util.function.Consumer onDone, + java.util.function.Consumer onError) { + rulesPdfImporter.importRulesStreaming(pdfBytes, filename, onProgress, onDone, onError); } /** diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/CampaignImportProgress.java b/core/src/main/java/com/loremind/domain/campaigncontext/CampaignImportProgress.java new file mode 100644 index 0000000..d14ec1b --- /dev/null +++ b/core/src/main/java/com/loremind/domain/campaigncontext/CampaignImportProgress.java @@ -0,0 +1,18 @@ +package com.loremind.domain.campaigncontext; + +/** + * Évènement d'avancement émis pendant l'import streamé d'un PDF de campagne. + *

+ * {@code total} = nombre de morceaux à traiter (0 pendant l'extraction). + * {@code current} = morceaux traités. Les compteurs arc/chapitre/scène donnent + * un aperçu de l'arbre trouvé jusqu'ici (affichage « au fil de l'eau »). + */ +public record CampaignImportProgress( + int current, + int total, + int pageCount, + int ocrPageCount, + int arcCount, + int chapterCount, + int sceneCount) { +} diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/CampaignImportProposal.java b/core/src/main/java/com/loremind/domain/campaigncontext/CampaignImportProposal.java new file mode 100644 index 0000000..0c601b8 --- /dev/null +++ b/core/src/main/java/com/loremind/domain/campaigncontext/CampaignImportProposal.java @@ -0,0 +1,40 @@ +package com.loremind.domain.campaigncontext; + +import java.util.List; + +/** + * Proposition d'arborescence narrative extraite d'un PDF de campagne. + *

+ * PROPOSITION non persistée : l'UI laisse l'utilisateur réviser/éditer l'arbre + * avant la création effective des arcs/chapitres/scènes. Records purs (domaine). + */ +public record CampaignImportProposal(List arcs) { + + /** + * {@code existingId} (nullable) : si présent, le nœud existe DÉJÀ dans la + * campagne (rempli côté UI lors de la revue pré-chargée) → l'apply ne le + * recrée pas, il l'utilise comme parent des nouveaux enfants. Null = à créer. + */ + + /** {@code type} = "LINEAR" ou "HUB" (mappé sur {@link ArcType} à l'apply). */ + public record ArcProposal( + String name, String description, String type, + List chapters, String existingId) { + } + + public record ChapterProposal( + String name, String description, List scenes, String existingId) { + } + + /** + * {@code rooms} non vide => lieu explorable (donjon). {@code playerNarration} + * = encadré « à lire aux joueurs », {@code gmNotes} = secrets/développement MJ. + */ + public record SceneProposal( + String name, String description, String playerNarration, String gmNotes, + List rooms, String existingId) { + } + + public record RoomProposal(String name, String description, String enemies, String loot) { + } +} diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/ports/CampaignImportException.java b/core/src/main/java/com/loremind/domain/campaigncontext/ports/CampaignImportException.java new file mode 100644 index 0000000..6fdbf8c --- /dev/null +++ b/core/src/main/java/com/loremind/domain/campaigncontext/ports/CampaignImportException.java @@ -0,0 +1,16 @@ +package com.loremind.domain.campaigncontext.ports; + +/** + * Erreur de domaine : l'import d'un PDF de campagne a échoué (PDF illisible, + * Brain injoignable, LLM en erreur...). + */ +public class CampaignImportException extends RuntimeException { + + public CampaignImportException(String message) { + super(message); + } + + public CampaignImportException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/ports/CampaignPdfAdvisor.java b/core/src/main/java/com/loremind/domain/campaigncontext/ports/CampaignPdfAdvisor.java new file mode 100644 index 0000000..556e386 --- /dev/null +++ b/core/src/main/java/com/loremind/domain/campaigncontext/ports/CampaignPdfAdvisor.java @@ -0,0 +1,32 @@ +package com.loremind.domain.campaigncontext.ports; + +import java.util.function.Consumer; + +/** + * Port de sortie : produit des CONSEILS d'adaptation d'un PDF à une campagne + * existante, streamés token par token. Délègue au Brain (LLM + extraction PDF). + *

+ * Contrairement à {@link CampaignPdfImporter} (qui structure pour créer), ici la + * sortie est du texte libre (markdown) : l'utilisateur applique à la main. + */ +public interface CampaignPdfAdvisor { + + /** + * @param pdfBytes contenu du PDF à adapter. + * @param filename nom d'origine (diagnostic ; peut être null). + * @param brief description de la campagne existante (structure + PNJ + lore). + * @param messagesJson JSON de l'échange conversationnel ([{role, content}, …]) ; + * "[]" au 1er tour. Permet à l'utilisateur de répondre/corriger. + * @param onToken invoqué à chaque fragment de texte généré. + * @param onComplete invoqué à la fin normale du flux. + * @param onError invoqué en cas d'échec (PDF illisible, Brain/LLM en erreur). + */ + void adviseStreaming( + byte[] pdfBytes, + String filename, + String brief, + String messagesJson, + Consumer onToken, + Runnable onComplete, + Consumer onError); +} diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/ports/CampaignPdfImporter.java b/core/src/main/java/com/loremind/domain/campaigncontext/ports/CampaignPdfImporter.java new file mode 100644 index 0000000..5879db3 --- /dev/null +++ b/core/src/main/java/com/loremind/domain/campaigncontext/ports/CampaignPdfImporter.java @@ -0,0 +1,28 @@ +package com.loremind.domain.campaigncontext.ports; + +import com.loremind.domain.campaigncontext.CampaignImportProgress; +import com.loremind.domain.campaigncontext.CampaignImportProposal; + +import java.util.function.Consumer; + +/** + * Port de sortie : extrait et structure un PDF de campagne en arbre + * arc → chapitre → scène. L'implémentation délègue au Brain Python. + */ +public interface CampaignPdfImporter { + + /** + * Variante streamée : l'import peut durer plusieurs minutes, on remonte + * l'avancement au fil de l'eau, puis la proposition finale. + * + * @param onProgress invoqué à chaque étape (extraction, puis par morceau). + * @param onDone invoqué une fois avec l'arbre proposé (non persisté). + * @param onError invoqué si l'extraction/structuration échoue. + */ + void importCampaignStreaming( + byte[] pdfBytes, + String filename, + Consumer onProgress, + Consumer onDone, + Consumer onError); +} diff --git a/core/src/main/java/com/loremind/domain/gamesystemcontext/RulesImportProgress.java b/core/src/main/java/com/loremind/domain/gamesystemcontext/RulesImportProgress.java new file mode 100644 index 0000000..b3bc2cf --- /dev/null +++ b/core/src/main/java/com/loremind/domain/gamesystemcontext/RulesImportProgress.java @@ -0,0 +1,19 @@ +package com.loremind.domain.gamesystemcontext; + +import java.util.List; + +/** + * Évènement d'avancement émis pendant l'import streamé d'un PDF de règles. + *

+ * {@code total} = nombre de morceaux à traiter (0 tant que l'extraction n'est + * pas finie). {@code current} = morceaux déjà traités. {@code newSectionTitles} + * = titres de sections nouvellement trouvés/complétés par le dernier morceau + * (pour un affichage « au fil de l'eau »). + */ +public record RulesImportProgress( + int current, + int total, + int pageCount, + int ocrPageCount, + List newSectionTitles) { +} diff --git a/core/src/main/java/com/loremind/domain/gamesystemcontext/RulesImportResult.java b/core/src/main/java/com/loremind/domain/gamesystemcontext/RulesImportResult.java new file mode 100644 index 0000000..5d58ce3 --- /dev/null +++ b/core/src/main/java/com/loremind/domain/gamesystemcontext/RulesImportResult.java @@ -0,0 +1,20 @@ +package com.loremind.domain.gamesystemcontext; + +import java.util.Map; + +/** + * Proposition de règles extraites d'un PDF, prête à être révisée par l'utilisateur. + *

+ * {@code sections} associe un titre de section à son contenu markdown — aligné + * sur le format {@link GameSystem#getRulesMarkdown()} (découpé par titres H2). + * C'est une PROPOSITION : rien n'est persisté ; l'UI laisse l'utilisateur + * réviser/éditer avant d'enregistrer le GameSystem. + *

+ * {@code ocrPageCount} indique combien de pages ont nécessité l'OCR (scan) — + * 0 = PDF born-digital (couche texte présente). + */ +public record RulesImportResult( + Map sections, + int pageCount, + int ocrPageCount) { +} diff --git a/core/src/main/java/com/loremind/domain/gamesystemcontext/ports/RulesImportException.java b/core/src/main/java/com/loremind/domain/gamesystemcontext/ports/RulesImportException.java new file mode 100644 index 0000000..3822f2b --- /dev/null +++ b/core/src/main/java/com/loremind/domain/gamesystemcontext/ports/RulesImportException.java @@ -0,0 +1,17 @@ +package com.loremind.domain.gamesystemcontext.ports; + +/** + * Erreur de domaine : l'import d'un PDF de règles a échoué (PDF illisible, + * Brain injoignable, LLM en erreur...). Les couches supérieures la traduisent + * en réponse HTTP sans connaître l'adapter concret. + */ +public class RulesImportException extends RuntimeException { + + public RulesImportException(String message) { + super(message); + } + + public RulesImportException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/core/src/main/java/com/loremind/domain/gamesystemcontext/ports/RulesPdfImporter.java b/core/src/main/java/com/loremind/domain/gamesystemcontext/ports/RulesPdfImporter.java new file mode 100644 index 0000000..006d0be --- /dev/null +++ b/core/src/main/java/com/loremind/domain/gamesystemcontext/ports/RulesPdfImporter.java @@ -0,0 +1,39 @@ +package com.loremind.domain.gamesystemcontext.ports; + +import com.loremind.domain.gamesystemcontext.RulesImportProgress; +import com.loremind.domain.gamesystemcontext.RulesImportResult; + +import java.util.function.Consumer; + +/** + * Port de sortie : extrait et structure les règles d'un PDF en sections. + *

+ * L'implémentation (adapter) délègue au Brain Python (extraction texte + OCR + + * structuration LLM). Le domaine ne connaît ni HTTP, ni le Brain, ni le LLM. + */ +public interface RulesPdfImporter { + + /** + * @param pdfBytes contenu binaire du PDF de règles. + * @param filename nom d'origine (diagnostic/logs ; peut être null). + * @return la proposition de sections (non persistée). + * @throws RulesImportException si l'extraction ou la structuration échoue. + */ + RulesImportResult importRules(byte[] pdfBytes, String filename); + + /** + * Variante streamée : l'import peut durer plusieurs minutes, on remonte + * l'avancement au fil de l'eau. Les callbacks sont invoqués depuis le thread + * d'exécution de l'adapter (synchrone jusqu'à {@code onDone}/{@code onError}). + * + * @param onProgress invoqué à chaque étape (extraction, puis par morceau). + * @param onDone invoqué une fois avec le résultat final. + * @param onError invoqué si l'extraction/structuration échoue. + */ + void importRulesStreaming( + byte[] pdfBytes, + String filename, + Consumer onProgress, + Consumer onDone, + Consumer onError); +} diff --git a/core/src/main/java/com/loremind/infrastructure/ai/BrainCampaignAdaptClient.java b/core/src/main/java/com/loremind/infrastructure/ai/BrainCampaignAdaptClient.java new file mode 100644 index 0000000..0c8a534 --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/ai/BrainCampaignAdaptClient.java @@ -0,0 +1,120 @@ +package com.loremind.infrastructure.ai; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loremind.domain.campaigncontext.ports.CampaignPdfAdvisor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.http.MediaType; +import org.springframework.http.client.MultipartBodyBuilder; +import org.springframework.http.codec.ServerSentEvent; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.BodyInserters; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Flux; + +import java.time.Duration; +import java.util.function.Consumer; + +/** + * Adapter de sortie : implémente {@link CampaignPdfAdvisor} via WebClient + SSE + * (POST /adapt/campaign/stream). Envoie le PDF (multipart) + le brief de campagne, + * relaie les tokens de conseil au fil de l'eau. + */ +@Component +public class BrainCampaignAdaptClient implements CampaignPdfAdvisor { + + private static final String ADAPT_PATH = "/adapt/campaign/stream"; + private static final ParameterizedTypeReference> SSE_STRING_TYPE = + new ParameterizedTypeReference<>() {}; + + private final WebClient webClient; + private final ObjectMapper objectMapper; + private final long timeoutSeconds; + + public BrainCampaignAdaptClient( + WebClient.Builder webClientBuilder, + ObjectMapper objectMapper, + @Value("${brain.base-url}") String baseUrl, + @Value("${brain.import-timeout-seconds:600}") long timeoutSeconds) { + this.webClient = webClientBuilder.baseUrl(baseUrl).build(); + this.objectMapper = objectMapper; + this.timeoutSeconds = timeoutSeconds; + } + + @Override + public void adviseStreaming( + byte[] pdfBytes, + String filename, + String brief, + String messagesJson, + Consumer onToken, + Runnable onComplete, + Consumer onError) { + + MultipartBodyBuilder parts = new MultipartBodyBuilder(); + parts.part("file", new ByteArrayResource(pdfBytes) { + @Override + public String getFilename() { + return (filename == null || filename.isBlank()) ? "campaign.pdf" : filename; + } + }); + parts.part("brief", brief == null ? "" : brief); + parts.part("messages", (messagesJson == null || messagesJson.isBlank()) ? "[]" : messagesJson); + + Flux> flux = webClient.post() + .uri(ADAPT_PATH) + .contentType(MediaType.MULTIPART_FORM_DATA) + .accept(MediaType.TEXT_EVENT_STREAM) + .body(BodyInserters.fromMultipartData(parts.build())) + .retrieve() + .bodyToFlux(SSE_STRING_TYPE); + + boolean[] terminated = {false}; + try { + flux + .timeout(Duration.ofSeconds(timeoutSeconds)) + .doOnNext(sse -> handleEvent(sse, terminated, onToken, onComplete, onError)) + .blockLast(); + // Flux clos sans 'done' explicite (ex: coupure) → on complète quand même. + if (!terminated[0]) onComplete.run(); + } catch (Exception e) { + if (!terminated[0]) { + onError.accept(new RuntimeException( + "Erreur lors du streaming d'adaptation depuis le Brain.", e)); + } + } + } + + private void handleEvent( + ServerSentEvent sse, + boolean[] terminated, + Consumer onToken, + Runnable onComplete, + Consumer onError) { + + String event = sse.event(); + String data = sse.data() == null ? "" : sse.data(); + + if ("error".equals(event)) { + terminated[0] = true; + onError.accept(new RuntimeException("Le Brain a signalé une erreur : " + readField(data, "message"))); + } else if ("done".equals(event)) { + terminated[0] = true; + onComplete.run(); + } else if ("token".equals(event)) { + String token = readField(data, "token"); + if (token != null && !token.isEmpty()) onToken.accept(token); + } + } + + private String readField(String data, String field) { + try { + JsonNode node = objectMapper.readTree(data); + return node.hasNonNull(field) ? node.get(field).asText() : data; + } catch (Exception e) { + return data; + } + } +} diff --git a/core/src/main/java/com/loremind/infrastructure/ai/BrainCampaignImportClient.java b/core/src/main/java/com/loremind/infrastructure/ai/BrainCampaignImportClient.java new file mode 100644 index 0000000..9419066 --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/ai/BrainCampaignImportClient.java @@ -0,0 +1,232 @@ +package com.loremind.infrastructure.ai; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loremind.domain.campaigncontext.CampaignImportProgress; +import com.loremind.domain.campaigncontext.CampaignImportProposal; +import com.loremind.domain.campaigncontext.CampaignImportProposal.ArcProposal; +import com.loremind.domain.campaigncontext.CampaignImportProposal.ChapterProposal; +import com.loremind.domain.campaigncontext.CampaignImportProposal.RoomProposal; +import com.loremind.domain.campaigncontext.CampaignImportProposal.SceneProposal; +import com.loremind.domain.campaigncontext.ports.CampaignImportException; +import com.loremind.domain.campaigncontext.ports.CampaignPdfImporter; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.http.MediaType; +import org.springframework.http.client.MultipartBodyBuilder; +import org.springframework.http.codec.ServerSentEvent; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.BodyInserters; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Flux; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; + +/** + * Adapter de sortie : implémente {@link CampaignPdfImporter} en appelant le + * Brain Python via WebClient + SSE (POST /import/campaign/stream). + *

+ * Le secret inter-service est ajouté par le WebClientCustomizer. Le timeout est + * long (import d'un livre entier = nombreux appels LLM en série). + */ +@Component +public class BrainCampaignImportClient implements CampaignPdfImporter { + + private static final String IMPORT_CAMPAIGN_STREAM_PATH = "/import/campaign/stream"; + private static final ParameterizedTypeReference> SSE_STRING_TYPE = + new ParameterizedTypeReference<>() {}; + + private final WebClient webClient; + private final ObjectMapper objectMapper; + private final long importTimeoutSeconds; + + public BrainCampaignImportClient( + WebClient.Builder webClientBuilder, + ObjectMapper objectMapper, + @Value("${brain.base-url}") String baseUrl, + @Value("${brain.import-timeout-seconds:600}") long importTimeoutSeconds) { + this.webClient = webClientBuilder.baseUrl(baseUrl).build(); + this.objectMapper = objectMapper; + this.importTimeoutSeconds = importTimeoutSeconds; + } + + @Override + public void importCampaignStreaming( + byte[] pdfBytes, + String filename, + Consumer onProgress, + Consumer onDone, + Consumer onError) { + + MultipartBodyBuilder parts = new MultipartBodyBuilder(); + parts.part("file", filePart(pdfBytes, filename)) + .filename(filename == null || filename.isBlank() ? "campaign.pdf" : filename); + + Flux> flux = webClient.post() + .uri(IMPORT_CAMPAIGN_STREAM_PATH) + .contentType(MediaType.MULTIPART_FORM_DATA) + .accept(MediaType.TEXT_EVENT_STREAM) + .body(BodyInserters.fromMultipartData(parts.build())) + .retrieve() + .bodyToFlux(SSE_STRING_TYPE); + + int[] pageCount = {0}; + int[] ocrPageCount = {0}; + boolean[] terminated = {false}; + + try { + flux + .timeout(Duration.ofSeconds(importTimeoutSeconds)) + .doOnNext(sse -> handleEvent( + sse, pageCount, ocrPageCount, terminated, onProgress, onDone, onError)) + .blockLast(); + if (!terminated[0]) { + onError.accept(new CampaignImportException( + "Le flux d'import s'est interrompu avant la fin.")); + } + } catch (Exception e) { + if (!terminated[0]) { + onError.accept(new CampaignImportException( + "Erreur lors du streaming d'import depuis le Brain.", e)); + } + } + } + + private void handleEvent( + ServerSentEvent sse, + int[] pageCount, + int[] ocrPageCount, + boolean[] terminated, + Consumer onProgress, + Consumer onDone, + Consumer onError) { + + String event = sse.event(); + String data = sse.data() == null ? "" : sse.data(); + + if ("error".equals(event)) { + terminated[0] = true; + onError.accept(new CampaignImportException( + "Le Brain a signalé une erreur : " + readMessage(data))); + return; + } + if ("extracting".equals(event)) { + onProgress.accept(new CampaignImportProgress(0, 0, 0, 0, 0, 0, 0)); + return; + } + + JsonNode node = readJson(data); + if (node == null) return; + + if ("start".equals(event)) { + pageCount[0] = node.path("page_count").asInt(); + ocrPageCount[0] = node.path("ocr_page_count").asInt(); + onProgress.accept(new CampaignImportProgress( + 0, node.path("total").asInt(), pageCount[0], ocrPageCount[0], 0, 0, 0)); + } else if ("progress".equals(event)) { + onProgress.accept(new CampaignImportProgress( + node.path("current").asInt(), + node.path("total").asInt(), + pageCount[0], + ocrPageCount[0], + node.path("arc_count").asInt(), + node.path("chapter_count").asInt(), + node.path("scene_count").asInt())); + } else if ("done".equals(event)) { + terminated[0] = true; + onDone.accept(new CampaignImportProposal(toArcs(node.path("arcs")))); + } + } + + // --- Parsing de l'arbre -------------------------------------------------- + + private List toArcs(JsonNode arcsNode) { + List arcs = new ArrayList<>(); + if (arcsNode != null && arcsNode.isArray()) { + for (JsonNode arc : arcsNode) { + arcs.add(new ArcProposal( + text(arc, "name"), + text(arc, "description"), + text(arc, "type"), + toChapters(arc.path("chapters")), + null)); + } + } + return arcs; + } + + private List toChapters(JsonNode chaptersNode) { + List chapters = new ArrayList<>(); + if (chaptersNode != null && chaptersNode.isArray()) { + for (JsonNode ch : chaptersNode) { + chapters.add(new ChapterProposal( + text(ch, "name"), + text(ch, "description"), + toScenes(ch.path("scenes")), + null)); + } + } + return chapters; + } + + private List toScenes(JsonNode scenesNode) { + List scenes = new ArrayList<>(); + if (scenesNode != null && scenesNode.isArray()) { + for (JsonNode sc : scenesNode) { + scenes.add(new SceneProposal( + text(sc, "name"), text(sc, "description"), + text(sc, "player_narration"), text(sc, "gm_notes"), + toRooms(sc.path("rooms")), null)); + } + } + return scenes; + } + + private List toRooms(JsonNode roomsNode) { + List rooms = new ArrayList<>(); + if (roomsNode != null && roomsNode.isArray()) { + for (JsonNode rm : roomsNode) { + rooms.add(new RoomProposal( + text(rm, "name"), text(rm, "description"), + text(rm, "enemies"), text(rm, "loot"))); + } + } + return rooms; + } + + // --- Helpers ------------------------------------------------------------- + + private ByteArrayResource filePart(byte[] pdfBytes, String filename) { + return new ByteArrayResource(pdfBytes) { + @Override + public String getFilename() { + return (filename == null || filename.isBlank()) ? "campaign.pdf" : filename; + } + }; + } + + private static String text(JsonNode node, String field) { + JsonNode v = node.path(field); + return v.isMissingNode() || v.isNull() ? "" : v.asText(); + } + + private JsonNode readJson(String data) { + try { + return objectMapper.readTree(data); + } catch (Exception e) { + return null; + } + } + + private String readMessage(String data) { + JsonNode node = readJson(data); + if (node != null && node.hasNonNull("message")) { + return node.get("message").asText(); + } + return data; + } +} diff --git a/core/src/main/java/com/loremind/infrastructure/ai/BrainRulesImportClient.java b/core/src/main/java/com/loremind/infrastructure/ai/BrainRulesImportClient.java new file mode 100644 index 0000000..3185ffb --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/ai/BrainRulesImportClient.java @@ -0,0 +1,248 @@ +package com.loremind.infrastructure.ai; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loremind.domain.gamesystemcontext.RulesImportProgress; +import com.loremind.domain.gamesystemcontext.RulesImportResult; +import com.loremind.domain.gamesystemcontext.ports.RulesImportException; +import com.loremind.domain.gamesystemcontext.ports.RulesPdfImporter; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.client.MultipartBodyBuilder; +import org.springframework.http.codec.ServerSentEvent; +import org.springframework.stereotype.Component; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.ResourceAccessException; +import org.springframework.web.client.RestClientResponseException; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.reactive.function.BodyInserters; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Flux; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; + +/** + * Adapter de sortie : implémente {@link RulesPdfImporter} en appelant le Brain + * Python via HTTP multipart. + *

+ * Deux variantes : + * - {@link #importRules} : one-shot bloquant (RestTemplate, POST /import/rules). + * - {@link #importRulesStreaming} : streamé (WebClient + SSE, POST /import/rules/stream) + * pour remonter l'avancement d'un import long. + *

+ * Le RestTemplate ({@code brainImportRestTemplate}) a un timeout long ; le secret + * inter-service est ajouté par l'intercepteur du bean (RestTemplate) et par le + * WebClientCustomizer (WebClient). + */ +@Component +public class BrainRulesImportClient implements RulesPdfImporter { + + private static final String IMPORT_RULES_PATH = "/import/rules"; + private static final String IMPORT_RULES_STREAM_PATH = "/import/rules/stream"; + private static final ParameterizedTypeReference> SSE_STRING_TYPE = + new ParameterizedTypeReference<>() {}; + + private final RestTemplate restTemplate; + private final WebClient webClient; + private final ObjectMapper objectMapper; + private final String baseUrl; + private final long importTimeoutSeconds; + + public BrainRulesImportClient( + @Qualifier("brainImportRestTemplate") RestTemplate restTemplate, + WebClient.Builder webClientBuilder, + ObjectMapper objectMapper, + @Value("${brain.base-url}") String baseUrl, + @Value("${brain.import-timeout-seconds:600}") long importTimeoutSeconds) { + this.restTemplate = restTemplate; + this.webClient = webClientBuilder.baseUrl(baseUrl).build(); + this.objectMapper = objectMapper; + this.baseUrl = baseUrl; + this.importTimeoutSeconds = importTimeoutSeconds; + } + + // --- One-shot (bloquant) ------------------------------------------------- + + @Override + public RulesImportResult importRules(byte[] pdfBytes, String filename) { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.MULTIPART_FORM_DATA); + + MultiValueMap body = new LinkedMultiValueMap<>(); + body.add("file", filePart(pdfBytes, filename)); + HttpEntity> entity = new HttpEntity<>(body, headers); + + try { + BrainRulesImportResponse response = restTemplate.postForObject( + baseUrl + IMPORT_RULES_PATH, entity, BrainRulesImportResponse.class); + if (response == null || response.getSections() == null) { + throw new RulesImportException("Le Brain a renvoyé une réponse vide."); + } + return new RulesImportResult( + Map.copyOf(response.getSections()), + response.getPageCount(), + response.getOcrPageCount()); + } catch (ResourceAccessException e) { + throw new RulesImportException( + "Le Brain est injoignable (timeout ou service arrêté).", e); + } catch (RestClientResponseException e) { + throw new RulesImportException( + "Le Brain a répondu HTTP " + e.getStatusCode().value() + + " : " + e.getResponseBodyAsString(), e); + } catch (RulesImportException e) { + throw e; + } catch (Exception e) { + throw new RulesImportException("Erreur inattendue lors de l'import via le Brain.", e); + } + } + + // --- Streamé (SSE) ------------------------------------------------------- + + @Override + public void importRulesStreaming( + byte[] pdfBytes, + String filename, + Consumer onProgress, + Consumer onDone, + Consumer onError) { + + MultipartBodyBuilder parts = new MultipartBodyBuilder(); + parts.part("file", filePart(pdfBytes, filename)) + .filename(filename == null || filename.isBlank() ? "rules.pdf" : filename); + + Flux> flux = webClient.post() + .uri(IMPORT_RULES_STREAM_PATH) + .contentType(MediaType.MULTIPART_FORM_DATA) + .accept(MediaType.TEXT_EVENT_STREAM) + .body(BodyInserters.fromMultipartData(parts.build())) + .retrieve() + .bodyToFlux(SSE_STRING_TYPE); + + // Holders mutables : le flux est consommé séquentiellement (blockLast), + // donc pas de souci de concurrence sur ces compteurs. + int[] pageCount = {0}; + int[] ocrPageCount = {0}; + boolean[] terminated = {false}; + + try { + flux + .timeout(Duration.ofSeconds(importTimeoutSeconds)) + .doOnNext(sse -> handleEvent( + sse, pageCount, ocrPageCount, terminated, onProgress, onDone, onError)) + .blockLast(); + // Flux terminé sans event done/error (ex: connexion coupée) → on signale. + if (!terminated[0]) { + onError.accept(new RulesImportException( + "Le flux d'import s'est interrompu avant la fin.")); + } + } catch (Exception e) { + if (!terminated[0]) { + onError.accept(new RulesImportException( + "Erreur lors du streaming d'import depuis le Brain.", e)); + } + } + } + + private void handleEvent( + ServerSentEvent sse, + int[] pageCount, + int[] ocrPageCount, + boolean[] terminated, + Consumer onProgress, + Consumer onDone, + Consumer onError) { + + String event = sse.event(); + String data = sse.data() == null ? "" : sse.data(); + + if ("error".equals(event)) { + terminated[0] = true; + onError.accept(new RulesImportException( + "Le Brain a signalé une erreur : " + readMessage(data))); + return; + } + if ("extracting".equals(event)) { + // Phase d'extraction : total inconnu (0) → l'UI affiche "Extraction…". + onProgress.accept(new RulesImportProgress(0, 0, 0, 0, List.of())); + return; + } + + JsonNode node = readJson(data); + if (node == null) return; + + if ("start".equals(event)) { + pageCount[0] = node.path("page_count").asInt(); + ocrPageCount[0] = node.path("ocr_page_count").asInt(); + onProgress.accept(new RulesImportProgress( + 0, node.path("total").asInt(), pageCount[0], ocrPageCount[0], List.of())); + } else if ("progress".equals(event)) { + onProgress.accept(new RulesImportProgress( + node.path("current").asInt(), + node.path("total").asInt(), + pageCount[0], + ocrPageCount[0], + toStringList(node.path("new_sections")))); + } else if ("done".equals(event)) { + terminated[0] = true; + onDone.accept(new RulesImportResult( + toStringMap(node.path("sections")), + node.path("page_count").asInt(), + node.path("ocr_page_count").asInt())); + } + } + + // --- Helpers ------------------------------------------------------------- + + /** ByteArrayResource avec nom de fichier : sans nom, l'upload n'est pas vu comme un fichier. */ + private ByteArrayResource filePart(byte[] pdfBytes, String filename) { + return new ByteArrayResource(pdfBytes) { + @Override + public String getFilename() { + return (filename == null || filename.isBlank()) ? "rules.pdf" : filename; + } + }; + } + + private JsonNode readJson(String data) { + try { + return objectMapper.readTree(data); + } catch (Exception e) { + return null; // morceau de flux non-JSON inattendu : on l'ignore. + } + } + + private String readMessage(String data) { + JsonNode node = readJson(data); + if (node != null && node.hasNonNull("message")) { + return node.get("message").asText(); + } + return data; + } + + private List toStringList(JsonNode array) { + List out = new ArrayList<>(); + if (array != null && array.isArray()) { + array.forEach(n -> out.add(n.asText())); + } + return out; + } + + private Map toStringMap(JsonNode object) { + Map out = new LinkedHashMap<>(); + if (object != null && object.isObject()) { + object.fields().forEachRemaining(e -> out.put(e.getKey(), e.getValue().asText())); + } + return out; + } +} diff --git a/core/src/main/java/com/loremind/infrastructure/ai/BrainRulesImportResponse.java b/core/src/main/java/com/loremind/infrastructure/ai/BrainRulesImportResponse.java new file mode 100644 index 0000000..7401761 --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/ai/BrainRulesImportResponse.java @@ -0,0 +1,26 @@ +package com.loremind.infrastructure.ai; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.Map; + +/** + * DTO interne de l'Adapter : JSON reçu du Brain sur POST /import/rules. + * + * @Data + @NoArgsConstructor : requis par Jackson pour la désérialisation. + */ +@Data +@NoArgsConstructor +class BrainRulesImportResponse { + + @JsonProperty("sections") + private Map sections; + + @JsonProperty("page_count") + private int pageCount; + + @JsonProperty("ocr_page_count") + private int ocrPageCount; +} diff --git a/core/src/main/java/com/loremind/infrastructure/ai/RestTemplateConfig.java b/core/src/main/java/com/loremind/infrastructure/ai/RestTemplateConfig.java index 1b93567..e06c07b 100644 --- a/core/src/main/java/com/loremind/infrastructure/ai/RestTemplateConfig.java +++ b/core/src/main/java/com/loremind/infrastructure/ai/RestTemplateConfig.java @@ -5,6 +5,7 @@ import org.springframework.boot.web.client.RestTemplateBuilder; import org.springframework.boot.web.reactive.function.client.WebClientCustomizer; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; import org.springframework.web.client.RestTemplate; import java.time.Duration; @@ -23,6 +24,7 @@ public class RestTemplateConfig { private static final String INTERNAL_SECRET_HEADER = "X-Internal-Secret"; @Bean + @Primary public RestTemplate brainRestTemplate( RestTemplateBuilder builder, @Value("${brain.timeout-seconds}") long timeoutSeconds, @@ -39,6 +41,29 @@ public class RestTemplateConfig { .build(); } + /** + * RestTemplate dédié à l'import de PDF (règles/campagne) au timeout LONG : + * l'extraction + la structuration map-reduce d'un livre entier enchaîne de + * nombreux appels LLM et dépasse facilement le timeout des appels courts. + * Même entête d'auth inter-service que {@link #brainRestTemplate}. + */ + @Bean + public RestTemplate brainImportRestTemplate( + RestTemplateBuilder builder, + @Value("${brain.import-timeout-seconds:600}") long importTimeoutSeconds, + @Value("${brain.internal-secret}") String internalSecret) { + return builder + .setConnectTimeout(Duration.ofSeconds(10)) + .setReadTimeout(Duration.ofSeconds(importTimeoutSeconds)) + .additionalInterceptors((request, body, execution) -> { + if (internalSecret != null && !internalSecret.isBlank()) { + request.getHeaders().set(INTERNAL_SECRET_HEADER, internalSecret); + } + return execution.execute(request, body); + }) + .build(); + } + /** * Ajoute X-Internal-Secret comme header par defaut a tous les WebClient * construits via le builder auto-configure par Spring Boot. Evite de diff --git a/core/src/main/java/com/loremind/infrastructure/web/controller/CampaignAdaptController.java b/core/src/main/java/com/loremind/infrastructure/web/controller/CampaignAdaptController.java new file mode 100644 index 0000000..7cfe2b1 --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/web/controller/CampaignAdaptController.java @@ -0,0 +1,97 @@ +package com.loremind.infrastructure.web.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loremind.application.campaigncontext.CampaignAdaptService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.core.task.TaskExecutor; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.io.IOException; +import java.util.Map; + +/** + * REST Controller : conseils d'adaptation d'un PDF à une campagne existante (SSE). + * - POST /api/campaigns/{id}/adapt-pdf/stream (multipart) → flux de tokens markdown. + * Ne persiste rien : la sortie est du conseil libre à appliquer manuellement. + */ +@RestController +@RequestMapping("/api/campaigns/{campaignId}/adapt-pdf") +public class CampaignAdaptController { + + private static final Logger log = LoggerFactory.getLogger(CampaignAdaptController.class); + private static final long SSE_TIMEOUT_MS = 15 * 60 * 1000L; + + private final CampaignAdaptService campaignAdaptService; + private final TaskExecutor taskExecutor; + private final ObjectMapper objectMapper; + + public CampaignAdaptController( + CampaignAdaptService campaignAdaptService, + @Qualifier("applicationTaskExecutor") TaskExecutor taskExecutor, + ObjectMapper objectMapper) { + this.campaignAdaptService = campaignAdaptService; + this.taskExecutor = taskExecutor; + this.objectMapper = objectMapper; + } + + @PostMapping(value = "/stream", + consumes = MediaType.MULTIPART_FORM_DATA_VALUE, + produces = MediaType.TEXT_EVENT_STREAM_VALUE) + public SseEmitter adaptStream( + @PathVariable String campaignId, + @RequestParam("file") MultipartFile file, + @RequestParam(value = "messages", required = false) String messagesJson) throws IOException { + SseEmitter emitter = new SseEmitter(SSE_TIMEOUT_MS); + if (file == null || file.isEmpty()) { + sendError(emitter, "Fichier PDF vide."); + return emitter; + } + byte[] bytes = file.getBytes(); + String filename = file.getOriginalFilename(); + + taskExecutor.execute(() -> { + try { + campaignAdaptService.adviseStreaming( + campaignId, bytes, filename, messagesJson, + token -> sendToken(emitter, token), + () -> { + sendEvent(emitter, "done", Map.of()); + emitter.complete(); + }, + error -> { + log.warn("Adaptation PDF échouée : {}", error.getMessage()); + sendError(emitter, error.getMessage()); + }); + } catch (IllegalArgumentException e) { + sendError(emitter, "Campagne introuvable."); + } catch (Exception e) { + log.warn("Adaptation PDF échouée : {}", e.getMessage()); + sendError(emitter, e.getMessage()); + } + }); + return emitter; + } + + private void sendToken(SseEmitter emitter, String token) { + sendEvent(emitter, "token", Map.of("token", token)); + } + + private void sendError(SseEmitter emitter, String message) { + sendEvent(emitter, "error", Map.of("message", message != null ? message : "Erreur inconnue.")); + emitter.complete(); + } + + private void sendEvent(SseEmitter emitter, String eventName, Object payload) { + try { + emitter.send(SseEmitter.event().name(eventName).data( + objectMapper.writeValueAsString(payload), MediaType.APPLICATION_JSON)); + } catch (IOException e) { + emitter.completeWithError(e); + } + } +} diff --git a/core/src/main/java/com/loremind/infrastructure/web/controller/CampaignImportController.java b/core/src/main/java/com/loremind/infrastructure/web/controller/CampaignImportController.java new file mode 100644 index 0000000..70a614f --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/web/controller/CampaignImportController.java @@ -0,0 +1,119 @@ +package com.loremind.infrastructure.web.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loremind.application.campaigncontext.CampaignImportService; +import com.loremind.domain.campaigncontext.CampaignImportProposal; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.core.task.TaskExecutor; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.io.IOException; +import java.util.Map; + +/** + * REST Controller pour l'import d'un PDF de campagne → arbre arc/chapitre/scène. + *

+ * - POST /api/campaigns/{id}/import-structure/stream (multipart) → SSE de la + * proposition (progress + done). Ne persiste rien. + * - POST /api/campaigns/{id}/import-structure/apply (JSON arbre révisé) → + * crée les entités et renvoie le récapitulatif. + */ +@RestController +@RequestMapping("/api/campaigns/{campaignId}/import-structure") +public class CampaignImportController { + + private static final Logger log = LoggerFactory.getLogger(CampaignImportController.class); + + /** Timeout SSE généreux : un import de livre entier peut durer plusieurs minutes. */ + private static final long IMPORT_SSE_TIMEOUT_MS = 15 * 60 * 1000L; + + private final CampaignImportService campaignImportService; + private final TaskExecutor taskExecutor; + private final ObjectMapper objectMapper; + + public CampaignImportController( + CampaignImportService campaignImportService, + @Qualifier("applicationTaskExecutor") TaskExecutor taskExecutor, + ObjectMapper objectMapper) { + this.campaignImportService = campaignImportService; + this.taskExecutor = taskExecutor; + this.objectMapper = objectMapper; + } + + @PostMapping(value = "/stream", + consumes = MediaType.MULTIPART_FORM_DATA_VALUE, + produces = MediaType.TEXT_EVENT_STREAM_VALUE) + public SseEmitter importStream( + @PathVariable String campaignId, + @RequestParam("file") MultipartFile file) throws IOException { + SseEmitter emitter = new SseEmitter(IMPORT_SSE_TIMEOUT_MS); + if (file == null || file.isEmpty()) { + sendError(emitter, "Fichier PDF vide."); + return emitter; + } + byte[] bytes = file.getBytes(); + String filename = file.getOriginalFilename(); + + taskExecutor.execute(() -> { + try { + campaignImportService.importStructureStreaming( + bytes, filename, + progress -> sendEvent(emitter, "progress", progress), + proposal -> { + sendEvent(emitter, "done", proposal); + emitter.complete(); + }, + error -> { + log.warn("Import campagne (stream) échoué : {}", error.getMessage()); + sendError(emitter, error.getMessage()); + }); + } catch (Exception e) { + log.warn("Import campagne (stream) échoué : {}", e.getMessage()); + sendError(emitter, e.getMessage()); + } + }); + return emitter; + } + + @PostMapping(value = "/apply", consumes = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity apply( + @PathVariable String campaignId, + @RequestBody CampaignImportProposal proposal) { + try { + CampaignImportService.ApplyResult result = + campaignImportService.applyStructure(campaignId, proposal); + return ResponseEntity.ok(result); + } catch (IllegalArgumentException e) { + return ResponseEntity.notFound().build(); + } + } + + // --- Helpers SSE --------------------------------------------------------- + + private void sendEvent(SseEmitter emitter, String eventName, Object payload) { + try { + emitter.send(SseEmitter.event().name(eventName).data( + objectMapper.writeValueAsString(payload), MediaType.APPLICATION_JSON)); + } catch (IOException e) { + emitter.completeWithError(e); + } + } + + private void sendError(SseEmitter emitter, String message) { + try { + emitter.send(SseEmitter.event().name("error").data( + objectMapper.writeValueAsString(Map.of( + "message", message != null ? message : "Erreur inconnue.")), + MediaType.APPLICATION_JSON)); + emitter.complete(); + } catch (IOException e) { + emitter.completeWithError(e); + } + } +} diff --git a/core/src/main/java/com/loremind/infrastructure/web/controller/GameSystemController.java b/core/src/main/java/com/loremind/infrastructure/web/controller/GameSystemController.java index e4d9ddd..04cd7f9 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/controller/GameSystemController.java +++ b/core/src/main/java/com/loremind/infrastructure/web/controller/GameSystemController.java @@ -1,33 +1,59 @@ package com.loremind.infrastructure.web.controller; +import com.fasterxml.jackson.databind.ObjectMapper; import com.loremind.application.gamesystemcontext.GameSystemService; import com.loremind.domain.gamesystemcontext.GameSystem; +import com.loremind.domain.gamesystemcontext.RulesImportProgress; +import com.loremind.domain.gamesystemcontext.RulesImportResult; +import com.loremind.domain.gamesystemcontext.ports.RulesImportException; import com.loremind.domain.shared.template.TemplateField; import com.loremind.infrastructure.web.dto.gamesystemcontext.GameSystemDTO; +import com.loremind.infrastructure.web.dto.gamesystemcontext.RulesImportResponseDTO; import com.loremind.infrastructure.web.dto.shared.TemplateFieldDTO; import com.loremind.infrastructure.web.mapper.GameSystemMapper; import com.loremind.infrastructure.web.mapper.TemplateFieldMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.core.task.TaskExecutor; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; +import java.io.IOException; import java.util.ArrayList; import java.util.List; +import java.util.Map; import java.util.stream.Collectors; @RestController @RequestMapping("/api/game-systems") public class GameSystemController { + private static final Logger log = LoggerFactory.getLogger(GameSystemController.class); + + /** Timeout SSE généreux : un import de livre entier peut durer plusieurs minutes. */ + private static final long IMPORT_SSE_TIMEOUT_MS = 15 * 60 * 1000L; + private final GameSystemService gameSystemService; private final GameSystemMapper gameSystemMapper; private final TemplateFieldMapper templateFieldMapper; + private final TaskExecutor taskExecutor; + private final ObjectMapper objectMapper; public GameSystemController(GameSystemService gameSystemService, GameSystemMapper gameSystemMapper, - TemplateFieldMapper templateFieldMapper) { + TemplateFieldMapper templateFieldMapper, + @Qualifier("applicationTaskExecutor") TaskExecutor taskExecutor, + ObjectMapper objectMapper) { this.gameSystemService = gameSystemService; this.gameSystemMapper = gameSystemMapper; this.templateFieldMapper = templateFieldMapper; + this.taskExecutor = taskExecutor; + this.objectMapper = objectMapper; } @PostMapping @@ -71,6 +97,96 @@ public class GameSystemController { return ResponseEntity.noContent().build(); } + /** + * Import d'un PDF de règles → proposition de sections (titre → markdown). + * Ne persiste RIEN : l'UI présente la proposition pour révision/édition, + * puis l'utilisateur enregistre le GameSystem via les endpoints habituels. + */ + @PostMapping(value = "/import-rules", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity importRules(@RequestParam("file") MultipartFile file) { + if (file == null || file.isEmpty()) { + return ResponseEntity.badRequest().build(); + } + try { + RulesImportResult result = gameSystemService.importRulesFromPdf( + file.getBytes(), file.getOriginalFilename()); + return ResponseEntity.ok(new RulesImportResponseDTO( + result.sections(), result.pageCount(), result.ocrPageCount())); + } catch (IOException e) { + return ResponseEntity.badRequest().build(); + } catch (RulesImportException e) { + // Brain injoignable / LLM en erreur / PDF illisible : 502 (dépendance amont). + // On loggue la vraie cause (message du Brain propagé) pour le diagnostic. + log.warn("Import de règles échoué : {}", e.getMessage()); + return ResponseEntity.status(HttpStatus.BAD_GATEWAY).build(); + } + } + + /** + * Variante streamée de l'import : émet l'avancement (SSE) puis le résultat, + * pour que l'UI affiche une progression pendant un import long. + *

+ * Événements : {@code progress} (current/total/pageCount/ocrPageCount/newSectionTitles), + * {@code done} (sections + compteurs), {@code error} (message). + */ + @PostMapping(value = "/import-rules/stream", + consumes = MediaType.MULTIPART_FORM_DATA_VALUE, + produces = MediaType.TEXT_EVENT_STREAM_VALUE) + public SseEmitter importRulesStream(@RequestParam("file") MultipartFile file) throws IOException { + SseEmitter emitter = new SseEmitter(IMPORT_SSE_TIMEOUT_MS); + if (file == null || file.isEmpty()) { + sendImportError(emitter, "Fichier PDF vide."); + return emitter; + } + // Les octets sont lus sur le thread servlet (le MultipartFile n'est plus + // disponible une fois la requête asynchrone) avant de partir en tâche de fond. + byte[] bytes = file.getBytes(); + String filename = file.getOriginalFilename(); + + taskExecutor.execute(() -> { + try { + gameSystemService.importRulesFromPdfStreaming( + bytes, filename, + progress -> sendImportEvent(emitter, "progress", progress), + result -> { + sendImportEvent(emitter, "done", result); + emitter.complete(); + }, + error -> { + log.warn("Import de règles (stream) échoué : {}", error.getMessage()); + sendImportError(emitter, error.getMessage()); + }); + } catch (Exception e) { + log.warn("Import de règles (stream) échoué : {}", e.getMessage()); + sendImportError(emitter, e.getMessage()); + } + }); + return emitter; + } + + /** Sérialise `payload` en JSON et l'envoie comme évènement SSE nommé. */ + private void sendImportEvent(SseEmitter emitter, String eventName, Object payload) { + try { + emitter.send(SseEmitter.event().name(eventName).data( + objectMapper.writeValueAsString(payload), MediaType.APPLICATION_JSON)); + } catch (IOException e) { + emitter.completeWithError(e); + } + } + + /** Envoie un évènement `error` {message} puis termine le flux. */ + private void sendImportError(SseEmitter emitter, String message) { + try { + emitter.send(SseEmitter.event().name("error").data( + objectMapper.writeValueAsString(Map.of( + "message", message != null ? message : "Erreur inconnue.")), + MediaType.APPLICATION_JSON)); + emitter.complete(); + } catch (IOException e) { + emitter.completeWithError(e); + } + } + private GameSystemService.GameSystemData toData(GameSystemDTO dto) { return new GameSystemService.GameSystemData( dto.getName(), diff --git a/core/src/main/java/com/loremind/infrastructure/web/dto/gamesystemcontext/RulesImportResponseDTO.java b/core/src/main/java/com/loremind/infrastructure/web/dto/gamesystemcontext/RulesImportResponseDTO.java new file mode 100644 index 0000000..159d67d --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/web/dto/gamesystemcontext/RulesImportResponseDTO.java @@ -0,0 +1,16 @@ +package com.loremind.infrastructure.web.dto.gamesystemcontext; + +import java.util.Map; + +/** + * Réponse de l'import d'un PDF de règles : proposition de sections à réviser. + * + * @param sections titre de section → contenu markdown (non persisté). + * @param pageCount nombre de pages extraites du PDF. + * @param ocrPageCount nombre de pages ayant nécessité l'OCR (0 = born-digital). + */ +public record RulesImportResponseDTO( + Map sections, + int pageCount, + int ocrPageCount) { +} diff --git a/core/src/main/resources/application.properties b/core/src/main/resources/application.properties index 7f49768..cec2dea 100644 --- a/core/src/main/resources/application.properties +++ b/core/src/main/resources/application.properties @@ -35,6 +35,9 @@ spring.web.cors.allow-credentials=true # Configuration du Brain (service IA Python) brain.base-url=${BRAIN_BASE_URL:http://localhost:8000} brain.timeout-seconds=120 +# Timeout dedie a l'import de PDF (regles/campagne) : map-reduce LLM sur un +# livre entier => potentiellement plusieurs minutes. Surchargeable via env. +brain.import-timeout-seconds=${BRAIN_IMPORT_TIMEOUT_SECONDS:600} # Secret partage Core <-> Brain (auth inter-service via entete X-Internal-Secret). # OBLIGATOIRE en prod : si non defini, le Brain rejette toutes les requetes (fail-closed). @@ -53,9 +56,10 @@ minio.access-key=${MINIO_ACCESS_KEY:minioadmin} minio.secret-key=${MINIO_SECRET_KEY:minioadmin} minio.bucket=${MINIO_BUCKET:loremind-images} -# Limites d'upload d'images (MB) -spring.servlet.multipart.max-file-size=10MB -spring.servlet.multipart.max-request-size=10MB +# Limites d'upload multipart (MB) : images ET PDF de regles. Releve a 64 Mo +# pour accepter les livres de regles illustres (le Brain plafonne aussi a 60 Mo). +spring.servlet.multipart.max-file-size=64MB +spring.servlet.multipart.max-request-size=64MB # Mode demo : masque Settings/Export cote front et bloque les PUT /api/settings # cote serveur. Activer via DEMO_MODE=true sur les deploiements publics. diff --git a/web/package-lock.json b/web/package-lock.json index bb77223..ed7e154 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "loremind-web", - "version": "0.9.2-beta", + "version": "0.10.0-beta", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "loremind-web", - "version": "0.9.2-beta", + "version": "0.10.0-beta", "dependencies": { "@angular/animations": "^17.0.0", "@angular/common": "^17.0.0", diff --git a/web/package.json b/web/package.json index 66dfe53..490b3af 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "loremind-web", - "version": "0.9.2-beta", + "version": "0.10.0-beta", "description": "LoreMind Frontend - Angular", "scripts": { "ng": "ng", diff --git a/web/src/app/app.component.html b/web/src/app/app.component.html index 8215e56..dc33ef7 100644 --- a/web/src/app/app.component.html +++ b/web/src/app/app.component.html @@ -6,6 +6,7 @@ diff --git a/web/src/app/app.routes.ts b/web/src/app/app.routes.ts index 0b54bbb..ffd38eb 100644 --- a/web/src/app/app.routes.ts +++ b/web/src/app/app.routes.ts @@ -16,11 +16,13 @@ export const routes: Routes = [ { path: 'lore/:loreId/pages/:pageId/edit', loadComponent: () => import('./lore/page-edit/page-edit.component').then(m => m.PageEditComponent) }, { path: 'campaigns', loadComponent: () => import('./campaigns/campaigns.component').then(m => m.CampaignsComponent) }, { path: 'campaigns/:id', loadComponent: () => import('./campaigns/campaign/campaign-detail/campaign-detail.component').then(m => m.CampaignDetailComponent) }, + { path: 'campaigns/:campaignId/import', loadComponent: () => import('./campaigns/campaign/campaign-import/campaign-import.component').then(m => m.CampaignImportComponent) }, + { path: 'campaigns/:campaignId/adapt', loadComponent: () => import('./campaigns/campaign/campaign-adapt/campaign-adapt.component').then(m => m.CampaignAdaptComponent) }, { path: 'campaigns/:campaignId/playthroughs/:playthroughId', loadComponent: () => import('./campaigns/playthrough/playthrough-detail/playthrough-detail.component').then(m => m.PlaythroughDetailComponent) }, { path: 'campaigns/:campaignId/playthroughs/:playthroughId/flags', loadComponent: () => import('./campaigns/playthrough/playthrough-flags-page/playthrough-flags-page.component').then(m => m.PlaythroughFlagsPageComponent) }, - { path: 'campaigns/:campaignId/characters/create', loadComponent: () => import('./campaigns/character/character-edit/character-edit.component').then(m => m.CharacterEditComponent) }, - { path: 'campaigns/:campaignId/characters/:characterId/edit', loadComponent: () => import('./campaigns/character/character-edit/character-edit.component').then(m => m.CharacterEditComponent) }, - { path: 'campaigns/:campaignId/characters/:characterId', loadComponent: () => import('./campaigns/character/character-view/character-view.component').then(m => m.CharacterViewComponent) }, + { path: 'campaigns/:campaignId/playthroughs/:playthroughId/characters/create', loadComponent: () => import('./campaigns/character/character-edit/character-edit.component').then(m => m.CharacterEditComponent) }, + { path: 'campaigns/:campaignId/playthroughs/:playthroughId/characters/:characterId/edit', loadComponent: () => import('./campaigns/character/character-edit/character-edit.component').then(m => m.CharacterEditComponent) }, + { path: 'campaigns/:campaignId/playthroughs/:playthroughId/characters/:characterId', loadComponent: () => import('./campaigns/character/character-view/character-view.component').then(m => m.CharacterViewComponent) }, { path: 'campaigns/:campaignId/npcs/create', loadComponent: () => import('./campaigns/npc/npc-edit/npc-edit.component').then(m => m.NpcEditComponent) }, { path: 'campaigns/:campaignId/npcs/:npcId/edit', loadComponent: () => import('./campaigns/npc/npc-edit/npc-edit.component').then(m => m.NpcEditComponent) }, { path: 'campaigns/:campaignId/npcs/:npcId', loadComponent: () => import('./campaigns/npc/npc-view/npc-view.component').then(m => m.NpcViewComponent) }, diff --git a/web/src/app/campaigns/arc/arc-view/arc-view.component.html b/web/src/app/campaigns/arc/arc-view/arc-view.component.html index 9131767..c688245 100644 --- a/web/src/app/campaigns/arc/arc-view/arc-view.component.html +++ b/web/src/app/campaigns/arc/arc-view/arc-view.component.html @@ -39,7 +39,7 @@

🗺️ Quêtes du hub (scénario)

- Aucune quête pour ce hub. Créez un chapitre pour ajouter une première quête. + Aucune quête pour ce hub. Créez-en une pour démarrer.

diff --git a/web/src/app/campaigns/campaign-tree.helper.ts b/web/src/app/campaigns/campaign-tree.helper.ts index 57782b7..521cceb 100644 --- a/web/src/app/campaigns/campaign-tree.helper.ts +++ b/web/src/app/campaigns/campaign-tree.helper.ts @@ -82,30 +82,9 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData): T // IDs préfixés par type pour éviter les collisions dans LayoutService.expanded // (chaque entité a sa propre séquence IDENTITY en base → arc.id=1 et chapter.id=1 // peuvent coexister et se marchaient sur les pieds dans le Set global). - const sortedCharacters = [...data.characters].sort(byName); - const characterItems: TreeItem[] = sortedCharacters.map(ch => ({ - id: `character-${ch.id}`, - label: ch.name, - route: `/campaigns/${campaignId}/characters/${ch.id}` - })); - - const charactersNode: TreeItem = { - id: 'characters-root', - label: 'PJ', - iconKey: 'users', - children: characterItems, - meta: characterItems.length ? String(characterItems.length) : undefined, - sectionHeaderBefore: 'Personnages', - // Note : le section header "Personnages" est porté par le premier nœud (PJ). - // Le filet au-dessus est masqué par CSS si c'est le tout premier item de la sidebar. - createActions: [{ - id: 'new-character', - label: 'Nouveau PJ', - route: `/campaigns/${campaignId}/characters/create`, - actionIcon: 'plus' - }] - }; - + // Note refonte Playthrough : les PJ ne sont plus rattachés à la campagne mais + // à une Partie (Playthrough). On ne les affiche donc plus dans la sidebar de + // campagne — seuls les PNJ (donnée de scénario) restent sous "Personnages". const sortedNpcs = [...data.npcs].sort(byName); const npcItems: TreeItem[] = sortedNpcs.map(n => ({ id: `npc-${n.id}`, @@ -119,7 +98,9 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData): T iconKey: 'c-drama', children: npcItems, meta: npcItems.length ? String(npcItems.length) : undefined, - // Pas de sectionHeaderBefore : on reste sous le header "Personnages" du nœud PJ. + // Porte le header de section "Personnages" (les PJ ayant migré vers la Partie). + // Le filet au-dessus est masqué par CSS si c'est le tout premier item de la sidebar. + sectionHeaderBefore: 'Personnages', createActions: [{ id: 'new-npc', label: 'Nouveau PNJ', @@ -166,14 +147,15 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData): T createActions: [{ id: `new-chapter-${arc.id}`, - label: 'Nouveau chapitre', + // Dans un arc hub, un "chapitre" est présenté comme une "quête". + label: arc.type === 'HUB' ? 'Nouvelle quête' : 'Nouveau chapitre', route: `/campaigns/${campaignId}/arcs/${arc.id}/chapters/create`, actionIcon: 'plus' }] }; }); - return [...arcNodes, charactersNode, npcsNode]; + return [...arcNodes, npcsNode]; } /** @@ -197,6 +179,8 @@ export function buildCampaignSidebarConfig( })); return { title: campaign.name, + // Titre cliquable → accueil de la campagne (raccourci depuis n'importe quelle sous-page). + titleRoute: `/campaigns/${campaignId}`, items: buildCampaignTree(campaignId, treeData), footerLabel: 'Toutes les campagnes', createActions: [ diff --git a/web/src/app/campaigns/campaign/campaign-adapt/campaign-adapt.component.html b/web/src/app/campaigns/campaign/campaign-adapt/campaign-adapt.component.html new file mode 100644 index 0000000..37471f7 --- /dev/null +++ b/web/src/app/campaigns/campaign/campaign-adapt/campaign-adapt.component.html @@ -0,0 +1,61 @@ +
+ + + + +
+ + + {{ fileName }} +
+ +

{{ error }}

+ + +
+
+
{{ m.role === 'user' ? 'Vous' : 'IA' }}
+ +
{{ m.content }}
+
+ +
+ +
+ + +
+
+ + +
+ + +
+ +
diff --git a/web/src/app/campaigns/campaign/campaign-adapt/campaign-adapt.component.scss b/web/src/app/campaigns/campaign/campaign-adapt/campaign-adapt.component.scss new file mode 100644 index 0000000..40280b4 --- /dev/null +++ b/web/src/app/campaigns/campaign/campaign-adapt/campaign-adapt.component.scss @@ -0,0 +1,184 @@ +.adapt-page { + padding: 2rem 2.5rem; + max-width: 900px; +} + +.page-header { + margin-bottom: 1.25rem; + + h1 { margin: 0.6rem 0 0.3rem; font-size: 1.5rem; color: #f3f4f6; } +} + +.btn-back { + display: inline-flex; + align-items: center; + gap: 0.4rem; + background: transparent; + border: none; + color: #9ca3af; + cursor: pointer; + font-size: 0.85rem; + padding: 0; + + &:hover { color: #c4b5fd; } +} + +.subtitle { margin: 0; color: #9ca3af; font-size: 0.9rem; strong { color: #d1d5db; } } + +// --- Barre PDF -------------------------------------------------------------- +.upload-bar { + display: flex; + align-items: center; + gap: 0.8rem; + flex-wrap: wrap; + margin-bottom: 1rem; +} + +.btn-primary { + display: inline-flex; + align-items: center; + gap: 0.45rem; + padding: 0.6rem 1.1rem; + border-radius: 8px; + font-size: 0.9rem; + font-weight: 500; + cursor: pointer; + border: none; + background: #6c63ff; + color: white; + + &:hover:not(:disabled) { background: #5b52e0; } + &:disabled { opacity: 0.6; cursor: progress; } +} + +.file-name { color: #9ca3af; font-size: 0.82rem; } + +.adapt-error { + margin: 0 0 1rem; + padding: 0.55rem 0.8rem; + background: rgba(248, 113, 113, 0.1); + border: 1px solid rgba(248, 113, 113, 0.35); + border-radius: 8px; + color: #fca5a5; + font-size: 0.85rem; +} + +// --- Conversation ----------------------------------------------------------- +.chat { + display: flex; + flex-direction: column; + gap: 0.9rem; + margin-bottom: 1rem; +} + +.msg { + border-radius: 12px; + padding: 0.7rem 0.9rem; + border: 1px solid #1f2937; + + &--user { + background: rgba(108, 99, 255, 0.1); + border-color: rgba(108, 99, 255, 0.3); + align-self: flex-end; + max-width: 85%; + } + + &--assistant { + background: #0b1220; + } +} + +.msg-role { + font-size: 0.68rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: #9ca3af; + margin-bottom: 0.35rem; +} + +.msg-body { + color: #d1d5db; + font-size: 0.92rem; + line-height: 1.6; + white-space: pre-wrap; + + &.markdown-body { white-space: normal; } + + ::ng-deep { + h1, h2, h3 { color: #f3f4f6; margin: 0.9rem 0 0.4rem; } + h2 { font-size: 1.08rem; } + h3 { font-size: 1rem; } + ul, ol { padding-left: 1.3rem; } + li { margin: 0.2rem 0; } + strong { color: #fff; } + code { background: #1f2937; padding: 0.1rem 0.3rem; border-radius: 4px; font-size: 0.85em; } + a { color: #a78bfa; } + p { margin: 0.4rem 0; } + } +} + +.msg-actions { margin-top: 0.5rem; } + +.btn-copy { + display: inline-flex; + align-items: center; + gap: 0.3rem; + background: transparent; + border: 1px solid #374151; + border-radius: 6px; + color: #9ca3af; + cursor: pointer; + font-size: 0.75rem; + padding: 0.2rem 0.5rem; + + &:hover { color: #c4b5fd; border-color: #6c63ff; } +} + +.streaming-cursor { + display: inline-block; + color: #6c63ff; + animation: blink 1s step-start infinite; +} + +@keyframes blink { 50% { opacity: 0; } } + +// --- Composer --------------------------------------------------------------- +.composer { + display: flex; + gap: 0.5rem; + align-items: flex-end; + position: sticky; + bottom: 0; + padding-top: 0.5rem; + background: linear-gradient(to top, var(--color-bg, #0a0f1a) 70%, transparent); + + textarea { + flex: 1; + background: #0b1220; + border: 1px solid #1f2937; + border-radius: 8px; + color: #f3f4f6; + padding: 0.55rem 0.7rem; + font-size: 0.9rem; + font-family: inherit; + resize: vertical; + &:focus { outline: none; border-color: #6c63ff; } + &:disabled { opacity: 0.6; } + } +} + +.btn-send { + display: inline-flex; + align-items: center; + justify-content: center; + width: 42px; + height: 42px; + border-radius: 8px; + border: none; + background: #6c63ff; + color: white; + cursor: pointer; + + &:hover:not(:disabled) { background: #5b52e0; } + &:disabled { opacity: 0.5; cursor: default; } +} diff --git a/web/src/app/campaigns/campaign/campaign-adapt/campaign-adapt.component.ts b/web/src/app/campaigns/campaign/campaign-adapt/campaign-adapt.component.ts new file mode 100644 index 0000000..81e32f9 --- /dev/null +++ b/web/src/app/campaigns/campaign/campaign-adapt/campaign-adapt.component.ts @@ -0,0 +1,130 @@ +import { Component, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { ActivatedRoute, Router } from '@angular/router'; +import { FormsModule } from '@angular/forms'; +import { LucideAngularModule, ArrowLeft, Upload, Copy, Check, Send } from 'lucide-angular'; +import { CampaignAdaptService, AdaptMessage } from '../../../services/campaign-adapt.service'; +import { CampaignSidebarService } from '../../../services/campaign-sidebar.service'; +import { PageTitleService } from '../../../services/page-title.service'; +import { MarkdownPipe } from '../../../shared/markdown.pipe'; + +const FIRST_PROMPT = 'Propose-moi comment intégrer et adapter ce PDF à ma campagne.'; + +/** + * Page « Adapter un PDF » — CONVERSATIONNELLE. L'IA connaît la campagne (structure, + * PNJ, univers) + lit le PDF, propose une 1re adaptation, puis l'utilisateur peut + * répondre (corriger, demander des alternatives…) et l'IA rebondit. + * Route : /campaigns/:campaignId/adapt — rien n'est créé, conseils à appliquer à la main. + */ +@Component({ + selector: 'app-campaign-adapt', + standalone: true, + imports: [CommonModule, FormsModule, LucideAngularModule, MarkdownPipe], + templateUrl: './campaign-adapt.component.html', + styleUrls: ['./campaign-adapt.component.scss'] +}) +export class CampaignAdaptComponent implements OnInit { + readonly ArrowLeft = ArrowLeft; + readonly Upload = Upload; + readonly Copy = Copy; + readonly Check = Check; + readonly Send = Send; + + campaignId = ''; + + /** PDF choisi, conservé pour les tours de conversation suivants. */ + private file: File | null = null; + fileName = ''; + + /** Conversation affichée (user + assistant). */ + messages: AdaptMessage[] = []; + streaming = false; + error: string | null = null; + + /** Saisie du message en cours. */ + input = ''; + copiedIndex: number | null = null; + + constructor( + private route: ActivatedRoute, + private router: Router, + private service: CampaignAdaptService, + private campaignSidebar: CampaignSidebarService, + private pageTitle: PageTitleService + ) {} + + ngOnInit(): void { + this.campaignId = this.route.snapshot.paramMap.get('campaignId')!; + this.pageTitle.set('Adapter un PDF'); + this.campaignSidebar.show(this.campaignId); + } + + get hasConversation(): boolean { return this.messages.length > 0; } + + // --- Choix du PDF (démarre / réinitialise la conversation) --------------- + + onPdfSelected(event: Event): void { + const input = event.target as HTMLInputElement; + const file = input.files?.[0]; + input.value = ''; + if (!file) return; + this.file = file; + this.fileName = file.name; + this.messages = []; + this.error = null; + this.send(FIRST_PROMPT); + } + + // --- Envoi d'un message (1er tour ou feedback) --------------------------- + + sendCurrent(): void { + const text = this.input.trim(); + if (!text || this.streaming) return; + this.input = ''; + this.send(text); + } + + private send(text: string): void { + if (!this.file || this.streaming) return; + this.error = null; + + this.messages.push({ role: 'user', content: text }); + // Historique envoyé = tout jusqu'au message user inclus (sans la bulle vide). + const payload: AdaptMessage[] = this.messages.map(m => ({ role: m.role, content: m.content })); + + const assistant: AdaptMessage = { role: 'assistant', content: '' }; + this.messages.push(assistant); + this.streaming = true; + + this.service.adviseStream(this.campaignId, this.file, payload).subscribe({ + next: (ev) => { + if (ev.type === 'token') { + assistant.content += ev.value; + } else if (ev.type === 'done') { + this.streaming = false; + } + }, + error: (err: Error) => { + this.streaming = false; + // Bulle assistant restée vide → on la retire pour ne pas afficher de vide. + if (!assistant.content) { + this.messages = this.messages.filter(m => m !== assistant); + } + this.error = err?.message ? `Échec : ${err.message}` : "Échec de l'adaptation."; + } + }); + } + + copy(index: number): void { + const msg = this.messages[index]; + if (!msg) return; + navigator.clipboard?.writeText(msg.content).then(() => { + this.copiedIndex = index; + setTimeout(() => (this.copiedIndex = null), 2000); + }); + } + + back(): void { + this.router.navigate(['/campaigns', this.campaignId]); + } +} diff --git a/web/src/app/campaigns/campaign/campaign-detail/campaign-detail.component.html b/web/src/app/campaigns/campaign/campaign-detail/campaign-detail.component.html index 65f8274..45ae8eb 100644 --- a/web/src/app/campaigns/campaign/campaign-detail/campaign-detail.component.html +++ b/web/src/app/campaigns/campaign/campaign-detail/campaign-detail.component.html @@ -102,38 +102,9 @@

Personnages

- -
-
-

- - Personnages joueurs - {{ characters.length }} -

- -
- -
-
- -
- {{ character.name }} - {{ personaSnippet(character) }} -
-
-
- -
-

Aucun personnage joueur pour le moment.

- -
-
+
@@ -172,10 +143,20 @@

Arcs narratifs

- +
+ + + +
diff --git a/web/src/app/campaigns/campaign/campaign-detail/campaign-detail.component.scss b/web/src/app/campaigns/campaign/campaign-detail/campaign-detail.component.scss index f2d5408..ab3754f 100644 --- a/web/src/app/campaigns/campaign/campaign-detail/campaign-detail.component.scss +++ b/web/src/app/campaigns/campaign/campaign-detail/campaign-detail.component.scss @@ -268,6 +268,12 @@ h2 { color: #d1d5db; font-size: 1rem; font-weight: 600; } } +.section-header-actions { + display: flex; + align-items: center; + gap: 0.5rem; +} + .btn-add { display: flex; align-items: center; @@ -283,6 +289,14 @@ transition: background 0.2s; &:hover { background: #5b52e0; } + + // Variante secondaire (ex: "Importer un PDF") : discrète à côté de l'action primaire. + &--secondary { + background: #1f2937; + color: #e5e7eb; + border: 1px solid #374151; + &:hover { background: #273244; border-color: #6c63ff; } + } } .arcs-grid { diff --git a/web/src/app/campaigns/campaign/campaign-detail/campaign-detail.component.ts b/web/src/app/campaigns/campaign/campaign-detail/campaign-detail.component.ts index 93fce7c..67fa029 100644 --- a/web/src/app/campaigns/campaign/campaign-detail/campaign-detail.component.ts +++ b/web/src/app/campaigns/campaign/campaign-detail/campaign-detail.component.ts @@ -2,7 +2,7 @@ import { Component, OnInit, OnDestroy } from '@angular/core'; import { CommonModule } from '@angular/common'; import { ActivatedRoute } from '@angular/router'; import { FormsModule } from '@angular/forms'; -import { LucideAngularModule, Swords, Plus, Globe, Pencil, Trash2, User, Dices, Drama, Check, Play } from 'lucide-angular'; +import { LucideAngularModule, Swords, Plus, Globe, Pencil, Trash2, Dices, Drama, Check, Play, Upload, Sparkles } from 'lucide-angular'; import { Router, RouterLink } from '@angular/router'; import { forkJoin, of } from 'rxjs'; import { catchError, switchMap, filter, map } from 'rxjs/operators'; @@ -16,7 +16,6 @@ import { SessionService } from '../../../services/session.service'; import { PlaythroughService } from '../../../services/playthrough.service'; import { Playthrough } from '../../../services/campaign.model'; import { Session } from '../../../services/session.model'; -import { Character } from '../../../services/character.model'; import { Npc } from '../../../services/npc.model'; import { LayoutService } from '../../../services/layout.service'; import { PageTitleService } from '../../../services/page-title.service'; @@ -38,11 +37,12 @@ export class CampaignDetailComponent implements OnInit, OnDestroy { readonly Globe = Globe; readonly Pencil = Pencil; readonly Trash2 = Trash2; - readonly User = User; readonly Dices = Dices; readonly Drama = Drama; readonly Check = Check; readonly Play = Play; + readonly Upload = Upload; + readonly Sparkles = Sparkles; campaign: Campaign | null = null; arcs: Arc[] = []; @@ -56,8 +56,6 @@ export class CampaignDetailComponent implements OnInit, OnDestroy { availableGameSystems: GameSystem[] = []; /** GameSystem associé si `campaign.gameSystemId` est renseigné ; sinon null. */ linkedGameSystem: GameSystem | null = null; - /** Fiches de personnages (PJ) de la campagne. */ - characters: Character[] = []; /** Fiches de personnages non-joueurs (PNJ) de la campagne. */ npcs: Npc[] = []; /** Sessions de jeu (passées et en cours) liées à cette campagne. */ @@ -73,8 +71,6 @@ export class CampaignDetailComponent implements OnInit, OnDestroy { /** Parties (Playthroughs) de cette campagne. */ playthroughs: Playthrough[] = []; - /** Partie par défaut (1re) — fallback pour les actions session/PJ tant que l'UI Playthrough n'est pas finie. */ - defaultPlaythroughId: string | null = null; /** Mode édition inline. */ editing = false; @@ -124,10 +120,8 @@ export class CampaignDetailComponent implements OnInit, OnDestroy { this.campaign = campaign; this.editing = false; this.playthroughs = playthroughs; - this.defaultPlaythroughId = playthroughs.length > 0 ? playthroughs[0].id! : null; this.loadLinkedLore(campaign); this.loadLinkedGameSystem(campaign); - this.loadCharacters(campaign.id!); this.loadNpcs(campaign.id!); this.loadSessions(campaign.id!); this.arcs = treeData.arcs; @@ -162,10 +156,8 @@ export class CampaignDetailComponent implements OnInit, OnDestroy { this.campaign = campaign; this.editing = false; this.playthroughs = playthroughs; - this.defaultPlaythroughId = playthroughs.length > 0 ? playthroughs[0].id! : null; this.loadLinkedLore(campaign); this.loadLinkedGameSystem(campaign); - this.loadCharacters(campaign.id!); this.loadNpcs(campaign.id!); this.loadSessions(campaign.id!); this.arcs = treeData.arcs; @@ -200,18 +192,7 @@ export class CampaignDetailComponent implements OnInit, OnDestroy { ).subscribe(gs => this.linkedGameSystem = gs); } - /** Charge les PJ de la Partie par défaut (refonte Playthrough). */ - private loadCharacters(_campaignId: string): void { - if (!this.defaultPlaythroughId) { - this.characters = []; - return; - } - this.characterService.getByPlaythrough(this.defaultPlaythroughId).pipe( - catchError(() => of([] as Character[])) - ).subscribe(list => this.characters = list); - } - - /** Symétrique pour les PNJ. */ + /** Charge les PNJ de la campagne (les PJ vivent désormais dans une Partie). */ private loadNpcs(campaignId: string): void { this.npcService.getByCampaign(campaignId).pipe( catchError(() => of([] as Npc[])) @@ -245,11 +226,6 @@ export class CampaignDetailComponent implements OnInit, OnDestroy { }); } - createCharacter(): void { - if (!this.campaign) return; - this.router.navigate(['/campaigns', this.campaign.id, 'characters', 'create']); - } - createNpc(): void { if (!this.campaign) return; this.router.navigate(['/campaigns', this.campaign.id, 'npcs', 'create']); @@ -260,17 +236,6 @@ export class CampaignDetailComponent implements OnInit, OnDestroy { this.router.navigate(['/campaigns', this.campaign.id, 'npcs', npc.id, 'edit']); } - editCharacter(character: Character): void { - if (!this.campaign || !character.id) return; - this.router.navigate(['/campaigns', this.campaign.id, 'characters', character.id, 'edit']); - } - - /** Ouvre la vue lecture seule (style WorldAnvil) — clic sur la carte. */ - viewCharacter(character: Character): void { - if (!this.campaign || !character.id) return; - this.router.navigate(['/campaigns', this.campaign.id, 'characters', character.id]); - } - viewNpc(npc: Npc): void { if (!this.campaign || !npc.id) return; this.router.navigate(['/campaigns', this.campaign.id, 'npcs', npc.id]); @@ -281,6 +246,18 @@ export class CampaignDetailComponent implements OnInit, OnDestroy { this.router.navigate(['/campaigns', this.campaign.id, 'arcs', 'create']); } + /** Ouvre la page d'import d'un PDF de campagne (proposition d'arbre à réviser). */ + importCampaign(): void { + if (!this.campaign) return; + this.router.navigate(['/campaigns', this.campaign.id, 'import']); + } + + /** Ouvre la page de conseils d'adaptation d'un PDF à cette campagne. */ + adaptCampaign(): void { + if (!this.campaign) return; + this.router.navigate(['/campaigns', this.campaign.id, 'adapt']); + } + openArc(arc: Arc): void { if (!this.campaign || !arc.id) return; this.router.navigate(['/campaigns', this.campaign.id, 'arcs', arc.id]); @@ -306,11 +283,6 @@ export class CampaignDetailComponent implements OnInit, OnDestroy { return '(Fiche vide)'; } - /** Alias gardé pour compatibilité avec les anciens templates. */ - characterSnippet(c: Character): string { - return this.personaSnippet(c); - } - private showLayout(allCampaigns: Campaign[], data: CampaignTreeData): void { this.layoutService.show(buildCampaignSidebarConfig(this.campaign!, allCampaigns, data, this.campaign!.id!)); } @@ -385,9 +357,9 @@ export class CampaignDetailComponent implements OnInit, OnDestroy { const newGameSystemId = this.editGameSystemId ? this.editGameSystemId : null; const currentGameSystemId = this.campaign.gameSystemId ?? null; const gameSystemChanged = newGameSystemId !== currentGameSystemId; - const hasSheets = this.characters.length > 0 || this.npcs.length > 0; + const hasSheets = this.npcs.length > 0; if (gameSystemChanged && hasSheets) { - const count = this.characters.length + this.npcs.length; + const count = this.npcs.length; this.confirmDialog.confirm({ title: 'Changer le systeme de jeu ?', message: diff --git a/web/src/app/campaigns/campaign/campaign-import/campaign-import.component.html b/web/src/app/campaigns/campaign/campaign-import/campaign-import.component.html new file mode 100644 index 0000000..e33c4d1 --- /dev/null +++ b/web/src/app/campaigns/campaign/campaign-import/campaign-import.component.html @@ -0,0 +1,183 @@ +
+ + + + +
+ + + + +
+

{{ importPhase }}

+
+
+
+
+

+ Trouvé jusqu'ici : {{ importCounts.arcs }} arc(s) · {{ importCounts.chapters }} chapitre(s) · {{ importCounts.scenes }} scène(s) +

+
+ +

{{ importError }}

+
+ + +
+
+

+ À créer : {{ arcCount }} nouvel(s) arc(s), + {{ chapterCount }} chapitre(s), + {{ sceneCount }} scène(s). + Les éléments « déjà présent » (grisés) sont affichés pour situer les ajouts ; + ils ne seront pas recréés. Modifiez les nouveaux, puis créez. +

+
+ +
+ +
+
+ + + + déjà présent + +
+ +
+
+ Type : + + +
+ + + + +
+
+ + + + déjà présent + +
+ +
+ + + +
+
+ +
+ + +
+ déjà présent + + +
+ +
+ + + + + + + + Pièces (lieu explorable) +
+
+ + + + + +
+ +
+
+
+ + +
+
+ + +
+
+ + +
+ +

{{ applyError }}

+ +
+ + +
+
+ +
diff --git a/web/src/app/campaigns/campaign/campaign-import/campaign-import.component.scss b/web/src/app/campaigns/campaign/campaign-import/campaign-import.component.scss new file mode 100644 index 0000000..b818dac --- /dev/null +++ b/web/src/app/campaigns/campaign/campaign-import/campaign-import.component.scss @@ -0,0 +1,323 @@ +.import-page { + padding: 2rem 2.5rem; + max-width: 900px; +} + +.page-header { + margin-bottom: 1.5rem; + + h1 { margin: 0.6rem 0 0.3rem; font-size: 1.5rem; color: #f3f4f6; } +} + +.btn-back { + display: inline-flex; + align-items: center; + gap: 0.4rem; + background: transparent; + border: none; + color: #9ca3af; + cursor: pointer; + font-size: 0.85rem; + padding: 0; + + &:hover { color: #c4b5fd; } +} + +.subtitle { margin: 0; color: #9ca3af; font-size: 0.9rem; } + +// --- Upload ----------------------------------------------------------------- +.upload-area { + padding: 2rem; + border: 1px dashed #374151; + border-radius: 12px; + text-align: center; + background: #0b1220; +} + +.btn-primary, +.btn-secondary { + display: inline-flex; + align-items: center; + gap: 0.45rem; + padding: 0.6rem 1.1rem; + border-radius: 8px; + font-size: 0.9rem; + font-weight: 500; + cursor: pointer; + border: none; +} + +.btn-primary { + background: #6c63ff; + color: white; + &:hover:not(:disabled) { background: #5b52e0; } + &:disabled { opacity: 0.6; cursor: progress; } + + &.big { padding: 0.8rem 1.4rem; font-size: 0.95rem; } +} + +.btn-secondary { + background: #1f2937; + color: #e5e7eb; + border: 1px solid #374151; + &:hover { background: #273244; } +} + +.import-progress { + margin: 1.25rem auto 0; + max-width: 460px; + text-align: left; +} + +.import-phase { margin: 0 0 0.5rem; color: #c4b5fd; font-size: 0.85rem; font-weight: 500; } + +.progress-bar { + height: 6px; + background: #1f2937; + border-radius: 999px; + overflow: hidden; +} + +.progress-fill { + height: 100%; + background: #6c63ff; + border-radius: 999px; + transition: width 0.3s ease; +} + +.import-counts { margin: 0.55rem 0 0; color: #9ca3af; font-size: 0.8rem; } + +.import-error, +.apply-error { + margin: 1rem 0 0; + padding: 0.55rem 0.8rem; + background: rgba(248, 113, 113, 0.1); + border: 1px solid rgba(248, 113, 113, 0.35); + border-radius: 8px; + color: #fca5a5; + font-size: 0.85rem; +} + +// --- Revue (arbre) ---------------------------------------------------------- +.review-summary { margin: 0 0 1rem; color: #d1d5db; font-size: 0.9rem; strong { color: #fff; } em { color: #9ca3af; font-style: italic; } } + +// Nœuds déjà présents dans la campagne : grisés, lecture seule. +.existing-node { + opacity: 0.6; + + .node-name { background: transparent; border-color: transparent; cursor: default; } +} + +.badge-existing { + font-size: 0.65rem; + text-transform: uppercase; + letter-spacing: 0.04em; + color: #9ca3af; + background: rgba(255, 255, 255, 0.06); + border: 1px solid #1f2937; + border-radius: 999px; + padding: 0.1rem 0.45rem; + white-space: nowrap; +} + +.tree { display: flex; flex-direction: column; gap: 0.75rem; } + +.arc-card { + border: 1px solid #1f2937; + border-radius: 10px; + background: #0b1220; + overflow: hidden; +} + +.node-head { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.55rem 0.7rem; +} + +.arc-head { background: #111827; } +.chapter-head { background: rgba(255, 255, 255, 0.02); } + +.btn-collapse { + background: transparent; + border: none; + color: #9ca3af; + cursor: pointer; + padding: 0; + display: flex; + &:hover { color: #c4b5fd; } +} + +.node-icon { color: #a78bfa; flex-shrink: 0; } +.scene-icon { color: #6b7280; } + +.node-name { + flex: 1; + background: #0b1220; + border: 1px solid #1f2937; + border-radius: 6px; + color: #f3f4f6; + padding: 0.35rem 0.5rem; + font-size: 0.88rem; + + &:focus { outline: none; border-color: #6c63ff; } +} + +.btn-remove { + background: transparent; + border: none; + color: #6b7280; + cursor: pointer; + padding: 0.2rem; + display: flex; + &:hover { color: #f87171; } +} + +.node-body { padding: 0.5rem 0.7rem 0.7rem 1.7rem; display: flex; flex-direction: column; gap: 0.5rem; } + +.node-desc, +.node-desc-inline { + background: #0b1220; + border: 1px solid #1f2937; + border-radius: 6px; + color: #d1d5db; + padding: 0.35rem 0.5rem; + font-size: 0.82rem; + font-family: inherit; + resize: vertical; + &:focus { outline: none; border-color: #6c63ff; } +} + +.chapter-card { + border: 1px solid #1f2937; + border-radius: 8px; + overflow: hidden; +} + +// Sélecteur Linéaire / Hub +.arc-type-toggle { + display: flex; + align-items: center; + gap: 0.4rem; +} + +.toggle-label { color: #9ca3af; font-size: 0.8rem; } + +.type-btn { + background: #0b1220; + border: 1px solid #1f2937; + border-radius: 6px; + color: #9ca3af; + cursor: pointer; + font-size: 0.78rem; + padding: 0.3rem 0.6rem; + + &:hover { border-color: #6c63ff; } + &.active { background: rgba(108, 99, 255, 0.18); border-color: #6c63ff; color: #c4b5fd; font-weight: 600; } +} + +.scene-card { + border: 1px solid #161e2e; + border-radius: 6px; + padding: 0.3rem 0.4rem; +} + +.scene-row { + display: flex; + align-items: center; + gap: 0.4rem; +} + +.btn-details { + display: inline-flex; + align-items: center; + gap: 0.25rem; + background: transparent; + border: 1px solid #1f2937; + border-radius: 6px; + color: #9ca3af; + cursor: pointer; + font-size: 0.75rem; + padding: 0.25rem 0.5rem; + white-space: nowrap; + + &:hover { color: #c4b5fd; border-color: #6c63ff; } +} + +.scene-details { + display: flex; + flex-direction: column; + gap: 0.3rem; + margin: 0.4rem 0 0.2rem 1.5rem; + padding-left: 0.6rem; + border-left: 2px solid #1f2937; +} + +.field-label { + color: #9ca3af; + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.03em; + margin-top: 0.3rem; +} + +.rooms { + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.room-row { + display: flex; + align-items: center; + gap: 0.35rem; +} + +.room-name, +.room-field { + background: #0b1220; + border: 1px solid #1f2937; + border-radius: 5px; + color: #d1d5db; + padding: 0.25rem 0.4rem; + font-size: 0.78rem; + &:focus { outline: none; border-color: #6c63ff; } +} + +.room-name { flex: 0 0 22%; } +.room-field { flex: 1; min-width: 0; } + +.scene-fields { + flex: 1; + display: flex; + gap: 0.4rem; + + .node-name { flex: 0 0 40%; } + .node-desc-inline { flex: 1; } +} + +.btn-add, +.btn-add-inline { + display: inline-flex; + align-items: center; + gap: 0.3rem; + background: transparent; + border: 1px dashed #374151; + border-radius: 6px; + color: #9ca3af; + cursor: pointer; + font-size: 0.8rem; + padding: 0.35rem 0.6rem; + align-self: flex-start; + + &:hover { color: #c4b5fd; border-color: #6c63ff; } +} + +.btn-add { margin-top: 0.25rem; } + +.review-actions { + display: flex; + gap: 0.6rem; + margin-top: 1.5rem; +} diff --git a/web/src/app/campaigns/campaign/campaign-import/campaign-import.component.ts b/web/src/app/campaigns/campaign/campaign-import/campaign-import.component.ts new file mode 100644 index 0000000..0d2d341 --- /dev/null +++ b/web/src/app/campaigns/campaign/campaign-import/campaign-import.component.ts @@ -0,0 +1,374 @@ +import { Component, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { ActivatedRoute, Router } from '@angular/router'; +import { FormsModule } from '@angular/forms'; +import { + LucideAngularModule, ArrowLeft, Upload, Plus, Trash2, + ChevronDown, ChevronRight, Swords, BookOpen, MapPin, Check +} from 'lucide-angular'; +import { CampaignImportService } from '../../../services/campaign-import.service'; +import { CampaignService } from '../../../services/campaign.service'; +import { CharacterService } from '../../../services/character.service'; +import { NpcService } from '../../../services/npc.service'; +import { CampaignSidebarService } from '../../../services/campaign-sidebar.service'; +import { PageTitleService } from '../../../services/page-title.service'; +import { ArcKind, ArcProposal, ChapterProposal, SceneProposal } from '../../../services/campaign-import.model'; +import { CampaignImportProposal } from '../../../services/campaign-import.model'; +import { loadCampaignTreeData, CampaignTreeData } from '../../campaign-tree.helper'; +import { of } from 'rxjs'; +import { catchError } from 'rxjs/operators'; + +/** + * Nœuds éditables (= proposition + état d'UI). `existing` = déjà présent dans la + * campagne (chargé pour la revue) : lecture seule, sert de parent aux ajouts. + * `existingId` = l'ID de l'entité existante (envoyé à l'apply pour s'y rattacher). + */ +interface RoomNode { name: string; description: string; enemies: string; loot: string; } +interface SceneNode { + name: string; description: string; playerNarration: string; gmNotes: string; + rooms: RoomNode[]; detailsOpen: boolean; existing: boolean; existingId?: string; +} +interface ChapterNode { + name: string; description: string; scenes: SceneNode[]; collapsed: boolean; + existing: boolean; existingId?: string; +} +interface ArcNode { + name: string; description: string; type: ArcKind; chapters: ChapterNode[]; collapsed: boolean; + existing: boolean; existingId?: string; +} + +/** + * Page d'import d'un PDF de campagne → arbre arc/chapitre/scène. + * Route : /campaigns/:campaignId/import + * + * Flux : upload → progression streamée → arbre éditable (revue) → création. + * Rien n'est créé tant que l'utilisateur n'a pas validé « Créer dans la campagne ». + */ +@Component({ + selector: 'app-campaign-import', + standalone: true, + imports: [CommonModule, FormsModule, LucideAngularModule], + templateUrl: './campaign-import.component.html', + styleUrls: ['./campaign-import.component.scss'] +}) +export class CampaignImportComponent implements OnInit { + readonly ArrowLeft = ArrowLeft; + readonly Upload = Upload; + readonly Plus = Plus; + readonly Trash2 = Trash2; + readonly ChevronDown = ChevronDown; + readonly ChevronRight = ChevronRight; + readonly Swords = Swords; + readonly BookOpen = BookOpen; + readonly MapPin = MapPin; + readonly Check = Check; + + campaignId = ''; + + // --- État import (streaming) --- + importing = false; + importPhase = ''; + importProgress: { current: number; total: number } | null = null; + importCounts: { arcs: number; chapters: number; scenes: number } | null = null; + importError: string | null = null; + /** Vrai une fois la proposition reçue (on affiche l'arbre éditable). */ + reviewing = false; + + // --- Arbre éditable --- + tree: ArcNode[] = []; + + /** Structure actuelle de la campagne (chargée pour la fusion à la revue). */ + private existingData: CampaignTreeData | null = null; + + // --- État application (création) --- + applying = false; + applyError: string | null = null; + + constructor( + private route: ActivatedRoute, + private router: Router, + private service: CampaignImportService, + private campaignService: CampaignService, + private characterService: CharacterService, + private npcService: NpcService, + private campaignSidebar: CampaignSidebarService, + private pageTitle: PageTitleService + ) {} + + ngOnInit(): void { + this.campaignId = this.route.snapshot.paramMap.get('campaignId')!; + this.pageTitle.set('Importer une campagne'); + this.campaignSidebar.show(this.campaignId); + + // Pré-chargement de l'arborescence existante (pour fusionner à la revue). + // En cas d'échec on dégrade : tout sera considéré comme nouveau. + loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService) + .pipe(catchError(() => of(null))) + .subscribe(data => this.existingData = data); + } + + // --- Upload + streaming -------------------------------------------------- + + onPdfSelected(event: Event): void { + const input = event.target as HTMLInputElement; + const file = input.files?.[0]; + input.value = ''; + if (file) this.importPdf(file); + } + + private importPdf(file: File): void { + this.importing = true; + this.reviewing = false; + this.importError = null; + this.applyError = null; + this.importPhase = 'Extraction du texte…'; + this.importProgress = null; + this.importCounts = null; + this.tree = []; + + this.service.importStructureStream(this.campaignId, file).subscribe({ + next: (ev) => { + if (ev.type === 'progress') { + if (ev.total === 0) { + this.importPhase = 'Extraction du texte…'; + this.importProgress = null; + } else { + this.importPhase = `Analyse de la campagne… (${ev.current}/${ev.total})`; + this.importProgress = { current: ev.current, total: ev.total }; + this.importCounts = { arcs: ev.arcCount, chapters: ev.chapterCount, scenes: ev.sceneCount }; + } + } else if (ev.type === 'done') { + this.importing = false; + this.importPhase = ''; + this.importProgress = null; + if ((ev.arcs ?? []).length === 0) { + this.importError = "Aucune structure narrative détectée dans ce PDF."; + this.reviewing = false; + } else { + this.tree = this.buildMergedTree(ev.arcs); + this.reviewing = true; + } + } + }, + error: (err: Error) => { + this.importing = false; + this.importPhase = ''; + this.importProgress = null; + this.importError = err?.message ? `Échec de l'import : ${err.message}` : "Échec de l'import du PDF."; + } + }); + } + + // --- Construction de l'arbre fusionné (existant + proposition) ----------- + + /** + * Construit l'arbre de revue : d'abord l'arborescence ACTUELLE de la campagne + * (nœuds `existing`, lecture seule), puis on y fusionne la proposition par NOM + * (insensible à la casse). Ce qui matche un nœud existant est rattaché ; ce qui + * ne matche pas devient un nouveau nœud éditable. + */ + private buildMergedTree(proposalArcs: ArcProposal[]): ArcNode[] { + const byOrder = (a: { order?: number }, b: { order?: number }) => (a.order ?? 0) - (b.order ?? 0); + const arcs: ArcNode[] = []; + + // 1. Arbre existant. + const data = this.existingData; + if (data) { + for (const arc of [...data.arcs].sort(byOrder)) { + const chapters: ChapterNode[] = []; + for (const ch of [...(data.chaptersByArc[arc.id!] ?? [])].sort(byOrder)) { + const scenes: SceneNode[] = []; + for (const sc of [...(data.scenesByChapter[ch.id!] ?? [])].sort(byOrder)) { + scenes.push(this.existingSceneNode(sc.id!, sc.name, sc.description)); + } + chapters.push({ + name: ch.name, description: ch.description ?? '', scenes, + collapsed: true, existing: true, existingId: ch.id + }); + } + arcs.push({ + name: arc.name, description: arc.description ?? '', + type: (arc.type === 'HUB' ? 'HUB' : 'LINEAR'), chapters, + collapsed: true, existing: true, existingId: arc.id + }); + } + } + + // 2. Fusion de la proposition. + for (const pa of proposalArcs ?? []) { + const match = arcs.find(a => a.existing && this.sameName(a.name, pa.name)); + if (match) { + this.mergeChaptersInto(match, pa.chapters ?? []); + match.collapsed = false; + } else { + arcs.push(this.newArcNode(pa)); + } + } + return arcs; + } + + private mergeChaptersInto(arc: ArcNode, propChapters: ChapterProposal[]): void { + for (const pc of propChapters) { + const match = arc.chapters.find(c => c.existing && this.sameName(c.name, pc.name)); + if (match) { + this.mergeScenesInto(match, pc.scenes ?? []); + match.collapsed = false; + } else { + arc.chapters.push(this.newChapterNode(pc)); + } + } + } + + private mergeScenesInto(chapter: ChapterNode, propScenes: SceneProposal[]): void { + for (const ps of propScenes) { + // Scène de même nom déjà présente → on ne duplique pas (dédup). + if (chapter.scenes.some(s => this.sameName(s.name, ps.name))) continue; + chapter.scenes.push(this.newSceneNode(ps)); + } + } + + private sameName(a: string, b: string): boolean { + return (a ?? '').trim().toLowerCase() === (b ?? '').trim().toLowerCase(); + } + + // --- Mappers proposition → nœud NEUF ------------------------------------- + + private newArcNode(a: ArcProposal): ArcNode { + return { + name: a.name ?? '', description: a.description ?? '', + type: (a.type === 'HUB' ? 'HUB' : 'LINEAR'), + collapsed: false, existing: false, + chapters: (a.chapters ?? []).map(c => this.newChapterNode(c)) + }; + } + + private newChapterNode(c: ChapterProposal): ChapterNode { + return { + name: c.name ?? '', description: c.description ?? '', + collapsed: false, existing: false, + scenes: (c.scenes ?? []).map(s => this.newSceneNode(s)) + }; + } + + private newSceneNode(s: SceneProposal): SceneNode { + return { + name: s.name ?? '', description: s.description ?? '', + playerNarration: s.playerNarration ?? '', gmNotes: s.gmNotes ?? '', + detailsOpen: false, existing: false, + rooms: (s.rooms ?? []).map(r => ({ + name: r.name ?? '', description: r.description ?? '', + enemies: r.enemies ?? '', loot: r.loot ?? '' + })) + }; + } + + private existingSceneNode(id: string, name: string, description?: string): SceneNode { + return { + name, description: description ?? '', playerNarration: '', gmNotes: '', + detailsOpen: false, existing: true, existingId: id, rooms: [] + }; + } + + setArcType(arc: ArcNode, type: ArcKind): void { arc.type = type; } + toggleDetails(scene: SceneNode): void { scene.detailsOpen = !scene.detailsOpen; } + addRoom(scene: SceneNode): void { + scene.rooms.push({ name: '', description: '', enemies: '', loot: '' }); + scene.detailsOpen = true; + } + removeRoom(scene: SceneNode, index: number): void { scene.rooms.splice(index, 1); } + + // --- Édition de l'arbre -------------------------------------------------- + + toggleArc(arc: ArcNode): void { arc.collapsed = !arc.collapsed; } + toggleChapter(chapter: ChapterNode): void { chapter.collapsed = !chapter.collapsed; } + + addArc(): void { + this.tree.push({ name: '', description: '', type: 'LINEAR', chapters: [], collapsed: false, existing: false }); + } + removeArc(index: number): void { this.tree.splice(index, 1); } + + addChapter(arc: ArcNode): void { + arc.chapters.push({ name: '', description: '', scenes: [], collapsed: false, existing: false }); + } + removeChapter(arc: ArcNode, index: number): void { arc.chapters.splice(index, 1); } + + addScene(chapter: ChapterNode): void { + chapter.scenes.push({ + name: '', description: '', playerNarration: '', gmNotes: '', rooms: [], detailsOpen: true, existing: false + }); + } + removeScene(chapter: ChapterNode, index: number): void { chapter.scenes.splice(index, 1); } + + /** Compteurs des nœuds NOUVEAUX (= ce qui sera réellement créé). */ + get arcCount(): number { return this.tree.filter(a => !a.existing && a.name.trim()).length; } + get chapterCount(): number { + return this.tree.reduce((n, a) => n + a.chapters.filter(c => !c.existing && c.name.trim()).length, 0); + } + get sceneCount(): number { + return this.tree.reduce((n, a) => + n + a.chapters.reduce((m, c) => m + c.scenes.filter(s => !s.existing && s.name.trim()).length, 0), 0); + } + + /** Vrai s'il y a au moins un nœud nouveau à créer (sinon « Créer » désactivé). */ + get hasNewContent(): boolean { + return this.arcCount > 0 || this.chapterCount > 0 || this.sceneCount > 0; + } + + // --- Application (création) ---------------------------------------------- + + apply(): void { + if (this.applying || !this.hasNewContent) return; + this.applying = true; + this.applyError = null; + + // On envoie l'arbre fusionné COMPLET (existants + nouveaux) : les nœuds + // `existing` portent leur existingId et servent de parents — l'apply ne + // recrée que les nœuds sans existingId. + const proposal: CampaignImportProposal = { + arcs: this.tree + .filter(a => a.name.trim()) + .map(a => ({ + name: a.name.trim(), + description: a.description.trim(), + type: a.type, + existingId: a.existingId ?? null, + chapters: a.chapters + .filter(c => c.name.trim()) + .map(c => ({ + name: c.name.trim(), + description: c.description.trim(), + existingId: c.existingId ?? null, + scenes: c.scenes + .filter(s => s.name.trim()) + .map(s => ({ + name: s.name.trim(), + description: s.description.trim(), + playerNarration: s.playerNarration.trim(), + gmNotes: s.gmNotes.trim(), + existingId: s.existingId ?? null, + rooms: s.rooms + .filter(r => r.name.trim()) + .map(r => ({ + name: r.name.trim(), + description: r.description.trim(), + enemies: r.enemies.trim(), + loot: r.loot.trim() + })) + })) + })) + })) + }; + + this.service.applyStructure(this.campaignId, proposal).subscribe({ + next: () => this.router.navigate(['/campaigns', this.campaignId]), + error: () => { + this.applying = false; + this.applyError = "Échec de la création. La campagne existe-t-elle toujours ?"; + } + }); + } + + cancel(): void { + this.router.navigate(['/campaigns', this.campaignId]); + } +} diff --git a/web/src/app/campaigns/chapter/chapter-create/chapter-create.component.html b/web/src/app/campaigns/chapter/chapter-create/chapter-create.component.html index 91441e6..dda032b 100644 --- a/web/src/app/campaigns/chapter/chapter-create/chapter-create.component.html +++ b/web/src/app/campaigns/chapter/chapter-create/chapter-create.component.html @@ -1,19 +1,19 @@
- +
@@ -23,7 +23,7 @@
@@ -35,7 +35,7 @@
diff --git a/web/src/app/campaigns/chapter/chapter-create/chapter-create.component.ts b/web/src/app/campaigns/chapter/chapter-create/chapter-create.component.ts index 418e1d6..250404c 100644 --- a/web/src/app/campaigns/chapter/chapter-create/chapter-create.component.ts +++ b/web/src/app/campaigns/chapter/chapter-create/chapter-create.component.ts @@ -31,6 +31,8 @@ export class ChapterCreateComponent implements OnInit, OnDestroy { campaignId = ''; arcId = ''; arcName = ''; + /** Arc parent de type hub : un "chapitre" y est présenté comme une "quête". */ + isHub = false; private existingChapterCount = 0; constructor( @@ -62,6 +64,7 @@ export class ChapterCreateComponent implements OnInit, OnDestroy { }).subscribe(({ campaign, allCampaigns, treeData }) => { const currentArc = treeData.arcs.find(a => a.id === this.arcId); this.arcName = currentArc?.name ?? ''; + this.isHub = currentArc?.type === 'HUB'; this.existingChapterCount = treeData.chaptersByArc[this.arcId]?.length ?? 0; this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, this.campaignId)); diff --git a/web/src/app/campaigns/character/character-edit/character-edit.component.ts b/web/src/app/campaigns/character/character-edit/character-edit.component.ts index 625e2f4..eb89713 100644 --- a/web/src/app/campaigns/character/character-edit/character-edit.component.ts +++ b/web/src/app/campaigns/character/character-edit/character-edit.component.ts @@ -5,7 +5,6 @@ import { FormsModule } from '@angular/forms'; import { LucideAngularModule, Save, ArrowLeft, User, Trash2, Sparkles } from 'lucide-angular'; import { CharacterService } from '../../../services/character.service'; import { CampaignService } from '../../../services/campaign.service'; -import { PlaythroughService } from '../../../services/playthrough.service'; import { GameSystemService } from '../../../services/game-system.service'; import { CampaignSidebarService } from '../../../services/campaign-sidebar.service'; import { TemplateField } from '../../../services/template.model'; @@ -49,7 +48,7 @@ export class CharacterEditComponent implements OnInit { toggleChat(): void { this.chatOpen = !this.chatOpen; } campaignId: string | null = null; - /** Partie cible — déduite du 1er Playthrough de la campagne. */ + /** Partie propriétaire du PJ — lue depuis la route (les PJ sont scoping Playthrough). */ playthroughId: string | null = null; characterId: string | null = null; @@ -67,7 +66,6 @@ export class CharacterEditComponent implements OnInit { private router: Router, private service: CharacterService, private campaignService: CampaignService, - private playthroughService: PlaythroughService, private gameSystemService: GameSystemService, private campaignSidebar: CampaignSidebarService, private confirmDialog: ConfirmDialogService @@ -76,15 +74,12 @@ export class CharacterEditComponent implements OnInit { ngOnInit(): void { const params = this.route.snapshot.paramMap; this.campaignId = params.get('campaignId'); + this.playthroughId = params.get('playthroughId'); this.characterId = params.get('characterId'); if (this.campaignId) { this.loadTemplateForCampaign(this.campaignId); this.campaignSidebar.show(this.campaignId); - // Résolution Partie par défaut (1er Playthrough) — nécessaire pour create/update. - this.playthroughService.listByCampaign(this.campaignId).subscribe({ - next: list => { this.playthroughId = list.length > 0 ? list[0].id! : null; } - }); } if (this.characterId) { @@ -138,7 +133,7 @@ export class CharacterEditComponent implements OnInit { req.subscribe({ next: (saved) => { if (isCreation && saved.id) { - this.router.navigate(['/campaigns', this.campaignId, 'characters', saved.id]); + this.router.navigate(['/campaigns', this.campaignId, 'playthroughs', this.playthroughId, 'characters', saved.id]); } else { this.back(); } @@ -165,7 +160,9 @@ export class CharacterEditComponent implements OnInit { } back(): void { - if (this.campaignId) { + if (this.campaignId && this.playthroughId) { + this.router.navigate(['/campaigns', this.campaignId, 'playthroughs', this.playthroughId]); + } else if (this.campaignId) { this.router.navigate(['/campaigns', this.campaignId]); } else { this.router.navigate(['/campaigns']); diff --git a/web/src/app/campaigns/character/character-view/character-view.component.ts b/web/src/app/campaigns/character/character-view/character-view.component.ts index 5c55b43..d305020 100644 --- a/web/src/app/campaigns/character/character-view/character-view.component.ts +++ b/web/src/app/campaigns/character/character-view/character-view.component.ts @@ -13,7 +13,7 @@ import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-dr /** * Vue lecture seule "WorldAnvil" d'une fiche PJ. - * Route : /campaigns/:campaignId/characters/:characterId + * Route : /campaigns/:campaignId/playthroughs/:playthroughId/characters/:characterId */ @Component({ selector: 'app-character-view', @@ -28,6 +28,7 @@ export class CharacterViewComponent implements OnInit { readonly Sparkles = Sparkles; campaignId: string | null = null; + playthroughId: string | null = null; characterId: string | null = null; character: Character | null = null; @@ -48,6 +49,7 @@ export class CharacterViewComponent implements OnInit { ngOnInit(): void { const params = this.route.snapshot.paramMap; this.campaignId = params.get('campaignId'); + this.playthroughId = params.get('playthroughId'); this.characterId = params.get('characterId'); if (this.characterId) { this.service.getById(this.characterId).subscribe({ @@ -68,13 +70,15 @@ export class CharacterViewComponent implements OnInit { } edit(): void { - if (this.campaignId && this.characterId) { - this.router.navigate(['/campaigns', this.campaignId, 'characters', this.characterId, 'edit']); + if (this.campaignId && this.playthroughId && this.characterId) { + this.router.navigate(['/campaigns', this.campaignId, 'playthroughs', this.playthroughId, 'characters', this.characterId, 'edit']); } } back(): void { - if (this.campaignId) { + if (this.campaignId && this.playthroughId) { + this.router.navigate(['/campaigns', this.campaignId, 'playthroughs', this.playthroughId]); + } else if (this.campaignId) { this.router.navigate(['/campaigns', this.campaignId]); } else { this.router.navigate(['/campaigns']); diff --git a/web/src/app/campaigns/playthrough/playthrough-detail/playthrough-detail.component.html b/web/src/app/campaigns/playthrough/playthrough-detail/playthrough-detail.component.html index d93fd2b..ea6fa4d 100644 --- a/web/src/app/campaigns/playthrough/playthrough-detail/playthrough-detail.component.html +++ b/web/src/app/campaigns/playthrough/playthrough-detail/playthrough-detail.component.html @@ -42,11 +42,17 @@
-

Personnages joueurs

+
+

Personnages joueurs

+ +

Aucun PJ pour cette partie.

diff --git a/web/src/app/campaigns/playthrough/playthrough-detail/playthrough-detail.component.scss b/web/src/app/campaigns/playthrough/playthrough-detail/playthrough-detail.component.scss index 908decc..dd4be89 100644 --- a/web/src/app/campaigns/playthrough/playthrough-detail/playthrough-detail.component.scss +++ b/web/src/app/campaigns/playthrough/playthrough-detail/playthrough-detail.component.scss @@ -46,6 +46,32 @@ } } +.block-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 0.7rem; + + h2 { margin: 0; } +} + +.btn-add { + display: flex; + align-items: center; + gap: 0.4rem; + padding: 0.5rem 1rem; + background: #6c63ff; + color: white; + border: none; + border-radius: 8px; + font-size: 0.85rem; + font-weight: 500; + cursor: pointer; + transition: background 0.2s; + + &:hover { background: #5b52e0; } +} + .empty { font-size: 0.88rem; color: var(--color-text-muted, #777); diff --git a/web/src/app/campaigns/playthrough/playthrough-detail/playthrough-detail.component.ts b/web/src/app/campaigns/playthrough/playthrough-detail/playthrough-detail.component.ts index 91b3192..500471c 100644 --- a/web/src/app/campaigns/playthrough/playthrough-detail/playthrough-detail.component.ts +++ b/web/src/app/campaigns/playthrough/playthrough-detail/playthrough-detail.component.ts @@ -3,7 +3,7 @@ import { CommonModule } from '@angular/common'; import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { forkJoin, of } from 'rxjs'; import { catchError } from 'rxjs/operators'; -import { LucideAngularModule, ArrowLeft, Play, Flag, Users, Trash2, Pencil } from 'lucide-angular'; +import { LucideAngularModule, ArrowLeft, Play, Flag, Users, Trash2, Pencil, Plus } from 'lucide-angular'; import { CampaignService } from '../../../services/campaign.service'; import { CharacterService } from '../../../services/character.service'; import { NpcService } from '../../../services/npc.service'; @@ -36,6 +36,7 @@ export class PlaythroughDetailComponent implements OnInit, OnDestroy { readonly Users = Users; readonly Trash2 = Trash2; readonly Pencil = Pencil; + readonly Plus = Plus; campaignId = ''; playthroughId = ''; @@ -105,6 +106,11 @@ export class PlaythroughDetailComponent implements OnInit, OnDestroy { this.router.navigate(['/sessions', s.id]); } + /** Crée un PJ rattaché à CETTE Partie (route scoping Playthrough). */ + createCharacter(): void { + this.router.navigate(['/campaigns', this.campaignId, 'playthroughs', this.playthroughId, 'characters', 'create']); + } + openFlags(): void { this.router.navigate(['/campaigns', this.campaignId, 'playthroughs', this.playthroughId, 'flags']); } diff --git a/web/src/app/game-systems/game-system-edit/game-system-edit.component.html b/web/src/app/game-systems/game-system-edit/game-system-edit.component.html index b3907a2..95a88c5 100644 --- a/web/src/app/game-systems/game-system-edit/game-system-edit.component.html +++ b/web/src/app/game-systems/game-system-edit/game-system-edit.component.html @@ -36,6 +36,33 @@ selon ce qu'elle génère (combat → Combat/Monstres, PNJ → Classes, arc → Lore/Factions).

+ +
+ + + L'IA propose un découpage en sections — vous révisez avant d'enregistrer. +
+ + +
+

{{ importPhase }}

+
+
+
+
+

+ Sections trouvées : {{ importFound.join(' · ') }} +

+
+ +

{{ importNote }}

+

{{ importError }}

+
diff --git a/web/src/app/game-systems/game-system-edit/game-system-edit.component.scss b/web/src/app/game-systems/game-system-edit/game-system-edit.component.scss index 1cee97a..5ec3253 100644 --- a/web/src/app/game-systems/game-system-edit/game-system-edit.component.scss +++ b/web/src/app/game-systems/game-system-edit/game-system-edit.component.scss @@ -95,6 +95,94 @@ margin: 0 0 1rem; } +// --- Import PDF de règles --------------------------------------------------- +.import-row { + display: flex; + align-items: center; + gap: 0.75rem; + flex-wrap: wrap; + margin: 0 0 1rem; +} + +.btn-import { + display: inline-flex; + align-items: center; + gap: 0.4rem; + padding: 0.5rem 0.9rem; + background: #1f2937; + color: #e5e7eb; + border: 1px solid #374151; + border-radius: 8px; + font-size: 0.85rem; + font-weight: 500; + cursor: pointer; + transition: background 0.2s, border-color 0.2s; + + &:hover:not(:disabled) { background: #273244; border-color: #6c63ff; } + &:disabled { opacity: 0.6; cursor: progress; } +} + +.import-hint { + color: #6b7280; + font-size: 0.8rem; +} + +.import-progress { + margin: -0.4rem 0 1rem; + padding: 0.7rem 0.85rem; + background: #0b1220; + border: 1px solid #1f2937; + border-radius: 8px; +} + +.import-phase { + margin: 0 0 0.5rem; + color: #c4b5fd; + font-size: 0.85rem; + font-weight: 500; +} + +.progress-bar { + height: 6px; + background: #1f2937; + border-radius: 999px; + overflow: hidden; +} + +.progress-fill { + height: 100%; + background: #6c63ff; + border-radius: 999px; + transition: width 0.3s ease; +} + +.import-found { + margin: 0.55rem 0 0; + color: #9ca3af; + font-size: 0.78rem; + line-height: 1.4; +} + +.import-note { + margin: -0.4rem 0 1rem; + padding: 0.55rem 0.8rem; + background: rgba(108, 99, 255, 0.12); + border: 1px solid rgba(108, 99, 255, 0.35); + border-radius: 8px; + color: #c4b5fd; + font-size: 0.85rem; +} + +.import-error { + margin: -0.4rem 0 1rem; + padding: 0.55rem 0.8rem; + background: rgba(248, 113, 113, 0.1); + border: 1px solid rgba(248, 113, 113, 0.35); + border-radius: 8px; + color: #fca5a5; + font-size: 0.85rem; +} + .section-list { display: flex; flex-direction: column; diff --git a/web/src/app/game-systems/game-system-edit/game-system-edit.component.ts b/web/src/app/game-systems/game-system-edit/game-system-edit.component.ts index 763e05d..0674e3d 100644 --- a/web/src/app/game-systems/game-system-edit/game-system-edit.component.ts +++ b/web/src/app/game-systems/game-system-edit/game-system-edit.component.ts @@ -2,7 +2,7 @@ import { Component, OnInit } from '@angular/core'; import { CommonModule } from '@angular/common'; import { ActivatedRoute, Router } from '@angular/router'; import { FormsModule } from '@angular/forms'; -import { LucideAngularModule, Save, ArrowLeft, Dices, Plus, Trash2, ChevronDown, ChevronRight } from 'lucide-angular'; +import { LucideAngularModule, Save, ArrowLeft, Dices, Plus, Trash2, ChevronDown, ChevronRight, Upload } from 'lucide-angular'; import { GameSystemService } from '../../services/game-system.service'; import { TemplateField } from '../../services/template.model'; import { TemplateFieldsEditorComponent } from '../../shared/template-fields-editor/template-fields-editor.component'; @@ -54,9 +54,23 @@ export class GameSystemEditComponent implements OnInit { readonly Trash2 = Trash2; readonly ChevronDown = ChevronDown; readonly ChevronRight = ChevronRight; + readonly Upload = Upload; id: string | null = null; + /** Import PDF en cours (appel LLM long) → désactive le bouton + spinner. */ + importing = false; + /** Message de succès post-import (sections ajoutées, pages, OCR). */ + importNote: string | null = null; + /** Message d'erreur d'import (Brain injoignable, PDF illisible…). */ + importError: string | null = null; + /** Libellé de l'étape courante (« Extraction… », « Analyse… (3/12) »). */ + importPhase = ''; + /** Avancement de la structuration ; null pendant l'extraction (total inconnu). */ + importProgress: { current: number; total: number } | null = null; + /** Titres de sections trouvés au fil de l'eau (affichage live). */ + importFound: string[] = []; + name = ''; description = ''; author = ''; @@ -108,6 +122,96 @@ export class GameSystemEditComponent implements OnInit { this.sections.push({ title: '', content: '', collapsed: false }); } + // --- Import d'un PDF de règles ------------------------------------------- + + /** Déclenché par le caché : lance l'import du PDF choisi. */ + onPdfSelected(event: Event): void { + const input = event.target as HTMLInputElement; + const file = input.files?.[0]; + // Reset de la valeur : re-sélectionner le même fichier doit re-déclencher. + input.value = ''; + if (file) this.importPdf(file); + } + + private importPdf(file: File): void { + this.importing = true; + this.importNote = null; + this.importError = null; + this.importPhase = 'Extraction du texte…'; + this.importProgress = null; + this.importFound = []; + + this.service.importRulesStream(file).subscribe({ + next: (ev) => { + if (ev.type === 'progress') { + if (ev.total === 0) { + // Phase d'extraction (total encore inconnu). + this.importPhase = 'Extraction du texte…'; + this.importProgress = null; + } else { + this.importPhase = `Analyse des règles… (${ev.current}/${ev.total})`; + this.importProgress = { current: ev.current, total: ev.total }; + for (const t of ev.newSectionTitles) { + if (!this.importFound.includes(t)) this.importFound.push(t); + } + } + } else if (ev.type === 'done') { + this.finishImport(ev.sections, ev.pageCount, ev.ocrPageCount); + } + }, + error: (err: Error) => { + this.resetImportProgress(); + this.importError = err?.message + ? `Échec de l'import : ${err.message}` + : "Échec de l'import du PDF."; + } + }); + } + + private finishImport(sections: Record, pageCount: number, ocrPageCount: number): void { + this.resetImportProgress(); + const added = this.mergeImportedSections(sections); + if (added === 0) { + this.importError = "Aucune règle exploitable n'a été extraite de ce PDF " + + "(scan sans OCR, ou contenu non reconnu)."; + return; + } + const ocr = ocrPageCount > 0 ? ` (dont ${ocrPageCount} page(s) via OCR)` : ''; + this.importNote = `${added} section(s) proposée(s) depuis ${pageCount} page(s)${ocr}. ` + + `Relisez et ajustez ci-dessous avant d'enregistrer.`; + } + + private resetImportProgress(): void { + this.importing = false; + this.importPhase = ''; + this.importProgress = null; + } + + /** + * Fusionne les sections proposées dans l'éditeur. Section de même titre + * (insensible à la casse) → contenu ajouté à la suite ; sinon nouvelle carte. + * Retourne le nombre de sections effectivement intégrées. + */ + private mergeImportedSections(sections: Record): number { + let count = 0; + for (const [rawTitle, rawContent] of Object.entries(sections ?? {})) { + const title = (rawTitle ?? '').trim(); + const content = (rawContent ?? '').trim(); + if (!title || !content) continue; + const existing = this.sections.find( + s => s.title.trim().toLowerCase() === title.toLowerCase() + ); + if (existing) { + existing.content = `${existing.content.trim()}\n\n${content}`.trim(); + existing.collapsed = false; + } else { + this.sections.push({ title, content, collapsed: false }); + } + count++; + } + return count; + } + removeSection(index: number): void { this.sections.splice(index, 1); } diff --git a/web/src/app/services/campaign-adapt.service.ts b/web/src/app/services/campaign-adapt.service.ts new file mode 100644 index 0000000..f520629 --- /dev/null +++ b/web/src/app/services/campaign-adapt.service.ts @@ -0,0 +1,108 @@ +import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; + +/** Évènements du flux SSE de conseils d'adaptation. */ +export type AdaptStreamEvent = + | { type: 'token'; value: string } + | { type: 'done' } + | { type: 'error'; message: string }; + +/** Message de la conversation d'adaptation. */ +export interface AdaptMessage { + role: 'user' | 'assistant'; + content: string; +} + +/** + * Service : adaptation conversationnelle d'un PDF à une campagne (streamée). + * fetch() + SSE (POST multipart impossible avec EventSource), décodage ligne à ligne. + */ +@Injectable({ providedIn: 'root' }) +export class CampaignAdaptService { + + adviseStream(campaignId: string, file: File, messages: AdaptMessage[]): Observable { + return new Observable((subscriber) => { + const controller = new AbortController(); + const form = new FormData(); + form.append('file', file); + form.append('messages', JSON.stringify(messages ?? [])); + + fetch(`/api/campaigns/${campaignId}/adapt-pdf/stream`, { + method: 'POST', + headers: { 'Accept': 'text/event-stream' }, + body: form, + signal: controller.signal + }) + .then(async (response) => { + if (!response.ok || !response.body) { + subscriber.error(new Error(`HTTP ${response.status}`)); + return; + } + await this.consume(response.body, subscriber); + }) + .catch((err) => { + if (controller.signal.aborted) return; + subscriber.error(err); + }); + + return () => controller.abort(); + }); + } + + private async consume( + body: ReadableStream, + subscriber: { next: (e: AdaptStreamEvent) => void; error: (e: unknown) => void; complete: () => void } + ): Promise { + const reader = body.getReader(); + const decoder = new TextDecoder('utf-8'); + let buffer = ''; + let currentEvent: string | null = null; + let currentData = ''; + + const dispatch = () => { + const name = currentEvent ?? 'message'; + if (name === 'error') { + let message = "Échec de l'adaptation."; + try { message = (JSON.parse(currentData) as { message?: string }).message ?? message; } catch { /* défaut */ } + subscriber.error(new Error(message)); + } else if (name === 'done') { + subscriber.next({ type: 'done' }); + subscriber.complete(); + } else if (name === 'token') { + try { + const tok = (JSON.parse(currentData) as { token?: string }).token; + if (tok) subscriber.next({ type: 'token', value: tok }); + } catch { /* fragment ignoré */ } + } + currentEvent = null; + currentData = ''; + }; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + let idx: number; + while ((idx = buffer.indexOf('\n')) >= 0) { + const line = buffer.slice(0, idx).replace(/\r$/, ''); + buffer = buffer.slice(idx + 1); + if (line === '') { + if (currentEvent !== null || currentData !== '') dispatch(); + continue; + } + if (line.startsWith('event:')) { + currentEvent = line.slice(6).trim(); + } else if (line.startsWith('data:')) { + const chunk = line.slice(5).replace(/^ /, ''); + currentData = currentData ? `${currentData}\n${chunk}` : chunk; + } + } + } + if (currentEvent !== null || currentData !== '') dispatch(); + subscriber.complete(); + } catch (err) { + subscriber.error(err); + } + } +} diff --git a/web/src/app/services/campaign-import.model.ts b/web/src/app/services/campaign-import.model.ts new file mode 100644 index 0000000..611a9e5 --- /dev/null +++ b/web/src/app/services/campaign-import.model.ts @@ -0,0 +1,72 @@ +/** + * Types de l'import de PDF de campagne (arbre arc → chapitre → scène). + * Jumeaux des records Java (CampaignImportProposal / CampaignImportProgress). + */ + +export interface RoomProposal { + name: string; + description: string; + enemies: string; + loot: string; +} + +export interface SceneProposal { + name: string; + description: string; + /** Texte d'encadré « à lire aux joueurs ». */ + playerNarration: string; + /** Secrets / développement MJ. */ + gmNotes: string; + /** Non vide ⇒ la scène est un lieu explorable (donjon). */ + rooms: RoomProposal[]; + /** ID si la scène existe déjà (revue pré-chargée) ; absent = à créer. */ + existingId?: string | null; +} + +export interface ChapterProposal { + name: string; + description: string; + scenes: SceneProposal[]; + existingId?: string | null; +} + +export type ArcKind = 'LINEAR' | 'HUB'; + +export interface ArcProposal { + name: string; + description: string; + type: ArcKind; + chapters: ChapterProposal[]; + existingId?: string | null; +} + +export interface CampaignImportProposal { + arcs: ArcProposal[]; +} + +/** Récapitulatif renvoyé après création effective des entités. */ +export interface CampaignImportApplyResult { + arcsCreated: number; + chaptersCreated: number; + scenesCreated: number; +} + +/** + * Évènements du flux SSE d'import streamé. + * - progress : avancement (total=0 ⇒ extraction en cours). + * - done : arbre proposé (à réviser). + * - error : message d'erreur côté serveur. + */ +export type CampaignImportStreamEvent = + | { + type: 'progress'; + current: number; + total: number; + pageCount: number; + ocrPageCount: number; + arcCount: number; + chapterCount: number; + sceneCount: number; + } + | { type: 'done'; arcs: ArcProposal[] } + | { type: 'error'; message: string }; diff --git a/web/src/app/services/campaign-import.service.ts b/web/src/app/services/campaign-import.service.ts new file mode 100644 index 0000000..40bd390 --- /dev/null +++ b/web/src/app/services/campaign-import.service.ts @@ -0,0 +1,113 @@ +import { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { + CampaignImportApplyResult, + CampaignImportProposal, + CampaignImportStreamEvent +} from './campaign-import.model'; + +/** + * Service HTTP pour l'import d'un PDF de campagne. + * + * `importStructureStream` utilise fetch() (POST multipart + SSE, impossible + * avec EventSource) et décode le flux ligne par ligne, comme le chat. + * `applyStructure` poste l'arbre révisé pour créer les entités. + */ +@Injectable({ providedIn: 'root' }) +export class CampaignImportService { + constructor(private http: HttpClient) {} + + importStructureStream(campaignId: string, file: File): Observable { + return new Observable((subscriber) => { + const controller = new AbortController(); + const form = new FormData(); + form.append('file', file); + + fetch(`/api/campaigns/${campaignId}/import-structure/stream`, { + method: 'POST', + headers: { 'Accept': 'text/event-stream' }, + body: form, + signal: controller.signal + }) + .then(async (response) => { + if (!response.ok || !response.body) { + subscriber.error(new Error(`HTTP ${response.status}`)); + return; + } + await this.consumeSse(response.body, subscriber); + }) + .catch((err) => { + if (controller.signal.aborted) return; + subscriber.error(err); + }); + + return () => controller.abort(); + }); + } + + applyStructure(campaignId: string, proposal: CampaignImportProposal): Observable { + return this.http.post( + `/api/campaigns/${campaignId}/import-structure/apply`, proposal); + } + + /** Décode un ReadableStream SSE et émet les évènements d'import typés. */ + private async consumeSse( + body: ReadableStream, + subscriber: { next: (e: CampaignImportStreamEvent) => void; error: (e: unknown) => void; complete: () => void } + ): Promise { + const reader = body.getReader(); + const decoder = new TextDecoder('utf-8'); + let buffer = ''; + let currentEvent: string | null = null; + let currentData = ''; + + const dispatch = () => { + const name = currentEvent ?? 'message'; + if (name === 'error') { + let message = 'Échec de l\'import.'; + try { message = (JSON.parse(currentData) as { message?: string }).message ?? message; } catch { /* défaut */ } + subscriber.error(new Error(message)); + } else if (name === 'progress' || name === 'done') { + try { + const obj = JSON.parse(currentData); + if (name === 'done') { + subscriber.next({ type: 'done', arcs: obj.arcs ?? [] }); + subscriber.complete(); + } else { + subscriber.next({ type: 'progress', ...obj }); + } + } catch { /* bloc malformé ignoré */ } + } + currentEvent = null; + currentData = ''; + }; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + let idx: number; + while ((idx = buffer.indexOf('\n')) >= 0) { + const line = buffer.slice(0, idx).replace(/\r$/, ''); + buffer = buffer.slice(idx + 1); + if (line === '') { + if (currentEvent !== null || currentData !== '') dispatch(); + continue; + } + if (line.startsWith('event:')) { + currentEvent = line.slice(6).trim(); + } else if (line.startsWith('data:')) { + const chunk = line.slice(5).replace(/^ /, ''); + currentData = currentData ? `${currentData}\n${chunk}` : chunk; + } + } + } + if (currentEvent !== null || currentData !== '') dispatch(); + subscriber.complete(); + } catch (err) { + subscriber.error(err); + } + } +} diff --git a/web/src/app/services/game-system.model.ts b/web/src/app/services/game-system.model.ts index 3ddd678..fbb5810 100644 --- a/web/src/app/services/game-system.model.ts +++ b/web/src/app/services/game-system.model.ts @@ -18,6 +18,28 @@ export interface GameSystem { isPublic?: boolean; } +/** + * Réponse de l'import d'un PDF de règles : proposition de sections à réviser. + * `sections` = {titre → contenu markdown}. `ocrPageCount` > 0 ⇒ le PDF était + * (au moins partiellement) un scan passé à l'OCR. + */ +export interface RulesImportResponse { + sections: Record; + pageCount: number; + ocrPageCount: number; +} + +/** + * Évènements du flux SSE d'import streamé. + * - progress : avancement (total=0 ⇒ phase d'extraction en cours). + * - done : résultat final (sections proposées). + * - error : message d'erreur côté serveur. + */ +export type RulesImportStreamEvent = + | { type: 'progress'; current: number; total: number; pageCount: number; ocrPageCount: number; newSectionTitles: string[] } + | { type: 'done'; sections: Record; pageCount: number; ocrPageCount: number } + | { type: 'error'; message: string }; + /** Payload de creation/mise a jour (sans id). */ export interface GameSystemCreate { name: string; diff --git a/web/src/app/services/game-system.service.ts b/web/src/app/services/game-system.service.ts index 3f4aaa1..0115d5c 100644 --- a/web/src/app/services/game-system.service.ts +++ b/web/src/app/services/game-system.service.ts @@ -1,7 +1,7 @@ import { Injectable } from '@angular/core'; import { HttpClient, HttpParams } from '@angular/common/http'; import { Observable } from 'rxjs'; -import { GameSystem, GameSystemCreate } from './game-system.model'; +import { GameSystem, GameSystemCreate, RulesImportResponse, RulesImportStreamEvent } from './game-system.model'; /** * Service HTTP pour les GameSystems (systèmes de JDR). @@ -36,4 +36,109 @@ export class GameSystemService { const params = new HttpParams().set('q', q); return this.http.get(`${this.apiUrl}/search`, { params }); } + + /** + * Importe un PDF de règles : renvoie une PROPOSITION de sections (titre → + * markdown). Rien n'est persisté côté serveur — l'appelant injecte les + * sections dans l'éditeur pour révision avant enregistrement. + */ + importRules(file: File): Observable { + const form = new FormData(); + form.append('file', file); + return this.http.post(`${this.apiUrl}/import-rules`, form); + } + + /** + * Variante streamée : émet l'avancement au fil de l'eau puis `done`. + * On utilise fetch() (pas EventSource : POST + multipart impossibles avec + * EventSource) et on décode le flux SSE ligne par ligne. Annuler la + * subscription annule le fetch (AbortController). + */ + importRulesStream(file: File): Observable { + return new Observable((subscriber) => { + const controller = new AbortController(); + const form = new FormData(); + form.append('file', file); + + fetch(`${this.apiUrl}/import-rules/stream`, { + method: 'POST', + headers: { 'Accept': 'text/event-stream' }, + body: form, + signal: controller.signal + }) + .then(async (response) => { + if (!response.ok || !response.body) { + subscriber.error(new Error(`HTTP ${response.status}`)); + return; + } + await this.consumeImportSse(response.body, subscriber); + }) + .catch((err) => { + if (controller.signal.aborted) return; + subscriber.error(err); + }); + + return () => controller.abort(); + }); + } + + /** Décode un ReadableStream SSE et émet les évènements d'import typés. */ + private async consumeImportSse( + body: ReadableStream, + subscriber: { next: (e: RulesImportStreamEvent) => void; error: (e: unknown) => void; complete: () => void } + ): Promise { + const reader = body.getReader(); + const decoder = new TextDecoder('utf-8'); + let buffer = ''; + let currentEvent: string | null = null; + let currentData = ''; + + const dispatch = () => { + const name = currentEvent ?? 'message'; + if (name === 'error') { + let message = 'Échec de l\'import.'; + try { message = (JSON.parse(currentData) as { message?: string }).message ?? message; } catch { /* garde le défaut */ } + subscriber.error(new Error(message)); + } else if (name === 'progress' || name === 'done') { + try { + const obj = JSON.parse(currentData); + if (name === 'done') { + subscriber.next({ type: 'done', ...obj }); + subscriber.complete(); + } else { + subscriber.next({ type: 'progress', ...obj }); + } + } catch { /* bloc malformé ignoré */ } + } + currentEvent = null; + currentData = ''; + }; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + let idx: number; + while ((idx = buffer.indexOf('\n')) >= 0) { + const line = buffer.slice(0, idx).replace(/\r$/, ''); + buffer = buffer.slice(idx + 1); + if (line === '') { + if (currentEvent !== null || currentData !== '') dispatch(); + continue; + } + if (line.startsWith('event:')) { + currentEvent = line.slice(6).trim(); + } else if (line.startsWith('data:')) { + const chunk = line.slice(5).replace(/^ /, ''); + currentData = currentData ? `${currentData}\n${chunk}` : chunk; + } + } + } + if (currentEvent !== null || currentData !== '') dispatch(); + subscriber.complete(); + } catch (err) { + subscriber.error(err); + } + } } diff --git a/web/src/app/services/layout.service.ts b/web/src/app/services/layout.service.ts index 87b58b0..15aa619 100644 --- a/web/src/app/services/layout.service.ts +++ b/web/src/app/services/layout.service.ts @@ -73,6 +73,12 @@ export interface BottomPanel { export interface SecondarySidebarConfig { title: string; + /** + * Si défini, le titre devient cliquable et navigue vers cette route + * (ex: accueil de la campagne depuis n'importe quelle sous-page). Absent = + * titre statique (cas du Lore). + */ + titleRoute?: string; items: TreeItem[]; createActions: SidebarAction[]; globalItems: GlobalItem[]; diff --git a/web/src/app/services/settings.service.ts b/web/src/app/services/settings.service.ts index 05ac9d4..2637ba6 100644 --- a/web/src/app/services/settings.service.ts +++ b/web/src/app/services/settings.service.ts @@ -13,6 +13,10 @@ export interface AppSettings { onemin_model: string; onemin_api_key_set: boolean; llm_num_ctx: number; + /** Taille cible d'un morceau (tokens) pour l'import de PDF (règles/campagne). */ + import_chunk_tokens: number; + /** Timeout HTTP des appels LLM (secondes). */ + llm_timeout_seconds: number; } /** @@ -26,6 +30,8 @@ export interface AppSettingsUpdate { onemin_model?: string; onemin_api_key?: string; llm_num_ctx?: number; + import_chunk_tokens?: number; + llm_timeout_seconds?: number; } /** Metadonnees d'un modele Ollama (issues de /api/show). */ diff --git a/web/src/app/sessions/session-reference-panel/session-reference-panel.component.html b/web/src/app/sessions/session-reference-panel/session-reference-panel.component.html index 10cd13c..6972544 100644 --- a/web/src/app/sessions/session-reference-panel/session-reference-panel.component.html +++ b/web/src/app/sessions/session-reference-panel/session-reference-panel.component.html @@ -61,7 +61,7 @@ diff --git a/web/src/app/settings/settings.component.html b/web/src/app/settings/settings.component.html index 6a8d4af..cebc77f 100644 --- a/web/src/app/settings/settings.component.html +++ b/web/src/app/settings/settings.component.html @@ -221,6 +221,45 @@
+ +
+

Import de PDF

+
+ + +

+ Lors de l'import d'un PDF (regles ou campagne), le texte est decoupe en morceaux + de cette taille avant d'etre analyse par l'IA. Plus gros = moins de morceaux, + donc plus rapide et moins de fragmentation/doublons — mais le morceau doit tenir dans + la fenetre du modele. +
+ Repere : ~10 000 pour Ollama (16k de contexte) ; jusqu'a ~100 000 pour un modele a + grand contexte (ex : GPT-5 mini, 400k) afin de traiter un livre entier en une seule passe. + Attention : un gros morceau = generation plus longue (voir le timeout ci-dessous). +

+
+ +
+ + +

+ Delai max d'attente d'une reponse du modele. Si un import lourd echoue avec + « delai depasse » (timeout), augmentez cette valeur (ex : 600) + ou reduisez la taille des morceaux ci-dessus. Defaut : 300 s. +

+
+
+

Mises a jour

diff --git a/web/src/app/settings/settings.component.ts b/web/src/app/settings/settings.component.ts index 88207ee..d525fb7 100644 --- a/web/src/app/settings/settings.component.ts +++ b/web/src/app/settings/settings.component.ts @@ -518,7 +518,9 @@ export class SettingsComponent implements OnInit, OnDestroy { ollama_base_url: this.settings.ollama_base_url, llm_model: this.settings.llm_model, onemin_model: this.settings.onemin_model, - llm_num_ctx: this.settings.llm_num_ctx + llm_num_ctx: this.settings.llm_num_ctx, + import_chunk_tokens: this.settings.import_chunk_tokens, + llm_timeout_seconds: this.settings.llm_timeout_seconds }; if (this.clearApiKey) { patch.onemin_api_key = ''; diff --git a/web/src/app/shared/secondary-sidebar/secondary-sidebar.component.html b/web/src/app/shared/secondary-sidebar/secondary-sidebar.component.html index efcdd4c..f578c27 100644 --- a/web/src/app/shared/secondary-sidebar/secondary-sidebar.component.html +++ b/web/src/app/shared/secondary-sidebar/secondary-sidebar.component.html @@ -8,7 +8,19 @@ - + + + +