From 17f197484acc496a99f387bba9e24d3c9ff51ba8 Mon Sep 17 00:00:00 2001 From: "IETM_FIXE\\ietm6" Date: Tue, 21 Apr 2026 02:47:09 +0200 Subject: [PATCH] =?UTF-8?q?Mise=20=C3=A0=20jour=20avec=20la=20possibilit?= =?UTF-8?q?=C3=A9=20de=20mettre=20des=20images?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- brain/app/application/chat.py | 227 ++++++++++++++---- brain/app/core/config.py | 9 +- brain/app/domain/models.py | 96 +++++++- brain/app/infrastructure/ollama_adapter.py | 21 +- brain/app/main.py | 215 ++++++++++++++--- core/pom.xml | 7 + .../campaigncontext/ArcService.java | 1 + .../campaigncontext/ChapterService.java | 1 + .../campaigncontext/SceneService.java | 1 + .../CampaignStructuralContextBuilder.java | 111 +++++++++ .../GeneratePageValuesUseCase.java | 10 +- .../LoreStructuralContextBuilder.java | 175 ++++++++++++++ .../NarrativeEntityContextBuilder.java | 126 ++++++++++ .../StreamChatForCampaignUseCase.java | 105 ++++++++ .../StreamChatForLoreUseCase.java | 88 +------ .../application/images/ImageService.java | 112 +++++++++ .../application/lorecontext/PageService.java | 3 + .../lorecontext/TemplateService.java | 7 +- .../loremind/domain/campaigncontext/Arc.java | 7 + .../domain/campaigncontext/Chapter.java | 6 + .../domain/campaigncontext/Scene.java | 7 + .../CampaignStructuralContext.java | 63 +++++ .../domain/generationcontext/ChatRequest.java | 26 +- .../LoreStructuralContext.java | 29 ++- .../NarrativeEntityContext.java | 29 +++ .../com/loremind/domain/images/Image.java | 56 +++++ .../domain/images/ports/ImageRepository.java | 26 ++ .../domain/images/ports/ImageStorage.java | 34 +++ .../domain/lorecontext/FieldType.java | 15 ++ .../com/loremind/domain/lorecontext/Page.java | 25 +- .../loremind/domain/lorecontext/Template.java | 42 +++- .../domain/lorecontext/TemplateField.java | 39 +++ .../infrastructure/ai/BrainAiChatClient.java | 128 ++++++++-- .../converter/StringListMapJsonConverter.java | 54 +++++ .../TemplateFieldListJsonConverter.java | 97 ++++++++ .../persistence/entity/ArcJpaEntity.java | 6 + .../persistence/entity/ChapterJpaEntity.java | 5 + .../persistence/entity/ImageJpaEntity.java | 49 ++++ .../persistence/entity/PageJpaEntity.java | 6 + .../persistence/entity/SceneJpaEntity.java | 5 + .../persistence/entity/TemplateJpaEntity.java | 10 +- .../persistence/jpa/ImageJpaRepository.java | 13 + .../postgres/PostgresArcRepository.java | 6 + .../postgres/PostgresChapterRepository.java | 6 + .../postgres/PostgresImageRepository.java | 69 ++++++ .../postgres/PostgresPageRepository.java | 2 + .../postgres/PostgresSceneRepository.java | 6 + .../postgres/PostgresTemplateRepository.java | 9 +- .../infrastructure/storage/MinioConfig.java | 61 +++++ .../storage/MinioImageStorageAdapter.java | 97 ++++++++ .../web/controller/AiChatController.java | 53 +++- .../web/controller/ImageController.java | 103 ++++++++ .../web/controller/TemplateController.java | 13 +- .../web/dto/campaigncontext/ArcDTO.java | 3 + .../web/dto/campaigncontext/ChapterDTO.java | 3 + .../web/dto/campaigncontext/SceneDTO.java | 3 + .../ChatStreamCampaignRequestDTO.java | 31 +++ .../web/dto/images/ImageDTO.java | 24 ++ .../web/dto/lorecontext/PageDTO.java | 2 + .../web/dto/lorecontext/TemplateDTO.java | 2 +- .../web/dto/lorecontext/TemplateFieldDTO.java | 20 ++ .../infrastructure/web/mapper/ArcMapper.java | 6 + .../web/mapper/ChapterMapper.java | 6 + .../infrastructure/web/mapper/PageMapper.java | 2 + .../web/mapper/SceneMapper.java | 6 + .../web/mapper/TemplateFieldMapper.java | 34 +++ .../web/mapper/TemplateMapper.java | 32 ++- .../src/main/resources/application.properties | 11 + docker-compose.yml | 51 ++++ docs/plan.md | 176 +++++++++++++- progress.txt | 139 +++++++++++ web/src/app/app.routes.ts | 12 +- .../arc-create/arc-create.component.ts | 2 +- .../arc-edit/arc-edit.component.html | 37 ++- .../arc-edit/arc-edit.component.scss | 13 + .../campaigns/arc-edit/arc-edit.component.ts | 28 ++- .../arc-view/arc-view.component.html | 74 ++++++ .../arc-view/arc-view.component.scss | 1 + .../campaigns/arc-view/arc-view.component.ts | 107 +++++++++ web/src/app/campaigns/campaign-tree.helper.ts | 26 +- .../chapter-create.component.ts | 2 +- .../chapter-edit/chapter-edit.component.html | 37 ++- .../chapter-edit/chapter-edit.component.scss | 13 + .../chapter-edit/chapter-edit.component.ts | 26 +- .../chapter-view/chapter-view.component.html | 60 +++++ .../chapter-view/chapter-view.component.scss | 1 + .../chapter-view/chapter-view.component.ts | 111 +++++++++ .../scene-create/scene-create.component.ts | 2 +- .../scene-edit/scene-edit.component.html | 37 ++- .../scene-edit/scene-edit.component.scss | 13 + .../scene-edit/scene-edit.component.ts | 26 +- .../scene-view/scene-view.component.html | 90 +++++++ .../scene-view/scene-view.component.scss | 1 + .../scene-view/scene-view.component.ts | 116 +++++++++ web/src/app/lore/lore-sidebar.helper.ts | 7 +- .../lore/page-create/page-create.component.ts | 13 +- .../lore/page-edit/page-edit.component.html | 31 ++- .../app/lore/page-edit/page-edit.component.ts | 34 ++- .../lore/page-view/page-view.component.html | 65 +++++ .../lore/page-view/page-view.component.scss | 4 + .../app/lore/page-view/page-view.component.ts | 127 ++++++++++ .../template-create.component.html | 24 +- .../template-create.component.scss | 33 +++ .../template-create.component.ts | 33 ++- .../template-edit.component.html | 21 +- .../template-edit.component.scss | 34 ++- .../template-edit/template-edit.component.ts | 29 ++- web/src/app/services/ai-chat.service.ts | 44 +++- web/src/app/services/campaign.model.ts | 8 + web/src/app/services/image.model.ts | 15 ++ web/src/app/services/image.service.ts | 43 ++++ web/src/app/services/page.model.ts | 5 + web/src/app/services/template.model.ts | 20 +- .../ai-chat-drawer.component.ts | 21 +- .../image-gallery.component.html | 47 ++++ .../image-gallery.component.scss | 107 +++++++++ .../image-gallery/image-gallery.component.ts | 76 ++++++ .../image-uploader.component.html | 51 ++++ .../image-uploader.component.scss | 88 +++++++ .../image-uploader.component.ts | 100 ++++++++ .../secondary-sidebar.component.html | 31 ++- .../secondary-sidebar.component.scss | 24 +- web/src/styles.scss | 1 + web/src/styles/_buttons.scss | 25 ++ web/src/styles/_view.scss | 150 ++++++++++++ 125 files changed, 4866 insertions(+), 348 deletions(-) create mode 100644 core/src/main/java/com/loremind/application/generationcontext/CampaignStructuralContextBuilder.java create mode 100644 core/src/main/java/com/loremind/application/generationcontext/LoreStructuralContextBuilder.java create mode 100644 core/src/main/java/com/loremind/application/generationcontext/NarrativeEntityContextBuilder.java create mode 100644 core/src/main/java/com/loremind/application/generationcontext/StreamChatForCampaignUseCase.java create mode 100644 core/src/main/java/com/loremind/application/images/ImageService.java create mode 100644 core/src/main/java/com/loremind/domain/generationcontext/CampaignStructuralContext.java create mode 100644 core/src/main/java/com/loremind/domain/generationcontext/NarrativeEntityContext.java create mode 100644 core/src/main/java/com/loremind/domain/images/Image.java create mode 100644 core/src/main/java/com/loremind/domain/images/ports/ImageRepository.java create mode 100644 core/src/main/java/com/loremind/domain/images/ports/ImageStorage.java create mode 100644 core/src/main/java/com/loremind/domain/lorecontext/FieldType.java create mode 100644 core/src/main/java/com/loremind/domain/lorecontext/TemplateField.java create mode 100644 core/src/main/java/com/loremind/infrastructure/persistence/converter/StringListMapJsonConverter.java create mode 100644 core/src/main/java/com/loremind/infrastructure/persistence/converter/TemplateFieldListJsonConverter.java create mode 100644 core/src/main/java/com/loremind/infrastructure/persistence/entity/ImageJpaEntity.java create mode 100644 core/src/main/java/com/loremind/infrastructure/persistence/jpa/ImageJpaRepository.java create mode 100644 core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresImageRepository.java create mode 100644 core/src/main/java/com/loremind/infrastructure/storage/MinioConfig.java create mode 100644 core/src/main/java/com/loremind/infrastructure/storage/MinioImageStorageAdapter.java create mode 100644 core/src/main/java/com/loremind/infrastructure/web/controller/ImageController.java create mode 100644 core/src/main/java/com/loremind/infrastructure/web/dto/generationcontext/ChatStreamCampaignRequestDTO.java create mode 100644 core/src/main/java/com/loremind/infrastructure/web/dto/images/ImageDTO.java create mode 100644 core/src/main/java/com/loremind/infrastructure/web/dto/lorecontext/TemplateFieldDTO.java create mode 100644 core/src/main/java/com/loremind/infrastructure/web/mapper/TemplateFieldMapper.java create mode 100644 docker-compose.yml create mode 100644 progress.txt create mode 100644 web/src/app/campaigns/arc-view/arc-view.component.html create mode 100644 web/src/app/campaigns/arc-view/arc-view.component.scss create mode 100644 web/src/app/campaigns/arc-view/arc-view.component.ts create mode 100644 web/src/app/campaigns/chapter-view/chapter-view.component.html create mode 100644 web/src/app/campaigns/chapter-view/chapter-view.component.scss create mode 100644 web/src/app/campaigns/chapter-view/chapter-view.component.ts create mode 100644 web/src/app/campaigns/scene-view/scene-view.component.html create mode 100644 web/src/app/campaigns/scene-view/scene-view.component.scss create mode 100644 web/src/app/campaigns/scene-view/scene-view.component.ts create mode 100644 web/src/app/lore/page-view/page-view.component.html create mode 100644 web/src/app/lore/page-view/page-view.component.scss create mode 100644 web/src/app/lore/page-view/page-view.component.ts create mode 100644 web/src/app/services/image.model.ts create mode 100644 web/src/app/services/image.service.ts create mode 100644 web/src/app/shared/image-gallery/image-gallery.component.html create mode 100644 web/src/app/shared/image-gallery/image-gallery.component.scss create mode 100644 web/src/app/shared/image-gallery/image-gallery.component.ts create mode 100644 web/src/app/shared/image-uploader/image-uploader.component.html create mode 100644 web/src/app/shared/image-uploader/image-uploader.component.scss create mode 100644 web/src/app/shared/image-uploader/image-uploader.component.ts create mode 100644 web/src/styles/_view.scss diff --git a/brain/app/application/chat.py b/brain/app/application/chat.py index 1f582d4..fae44e8 100644 --- a/brain/app/application/chat.py +++ b/brain/app/application/chat.py @@ -1,16 +1,30 @@ """Use case : chat conversationnel LoreMind avec Structural Context. -Construit un system prompt riche à partir du contexte structurel du Lore -(noms des dossiers, titres des pages, templates, tags) puis délègue au port -`LLMChatProvider` pour le streaming token par token. +Construit un system prompt riche à partir de 4 contextes possibles +(Lore, Page focalisée, Campagne, entité narrative focalisée) puis délègue +au port `LLMChatProvider` pour le streaming token par token. -Ne charge PAS les valeurs des pages — l'IA doit être au courant de ce qui -existe, pas être noyée sous le contenu. Pattern "Structural Context", plus -simple que le RAG sémantique tant que le Lore reste de taille humaine. +Ne charge PAS le contenu détaillé des pages — l'IA doit savoir ce qui +existe, pas être noyée sous le texte. Pattern "Structural Context", plus +simple que le RAG sémantique tant que les univers restent de taille humaine. + +Combinaisons supportées : + - lore seul → chat Lore (page-edit / page-create) + - lore + page_context → chat Lore focalisé page + - campaign (+lore si liée) + optional narrative_entity → chat Campagne """ from typing import AsyncIterator -from app.domain.models import ChatMessage, LoreStructuralContext, PageContext +from app.domain.models import ( + ArcSummary, + CampaignStructuralContext, + ChatMessage, + ChapterSummary, + LoreStructuralContext, + NarrativeEntityContext, + PageContext, + PageSummary, +) from app.domain.ports import LLMChatProvider @@ -21,22 +35,22 @@ _DEFAULT_TEMPERATURE = 0.7 _BASE_SYSTEM = """Tu es un assistant d'écriture pour un Maître de Jeu de JDR. -Tu dialogues avec le MJ pour l'aider à enrichir son univers. +Tu dialogues avec le MJ pour l'aider à enrichir son univers et ses campagnes. Règles de ton : - Réponds en français, ton chaleureux et créatif. - Sois concis : listes à puces courtes plutôt que longs paragraphes. -- Propose des idées qui s'intègrent dans l'univers existant ci-dessous. +- Propose des idées qui s'intègrent dans le contexte existant ci-dessous. Règles de cohérence (IMPORTANT) : -- Tu PEUX et DOIS inventer des éléments originaux (personnages, lieux, objets, intrigues, créatures) — c'est ton rôle d'assistant créatif. -- Tu ne peux PAS faire référence à un élément du Lore du MJ comme s'il existait déjà, SAUF s'il apparaît EXACTEMENT (même orthographe) dans la section "Organisation" ci-dessous. -- Si l'utilisateur mentionne un nom que tu ne vois pas dans l'organisation, ne fais surtout pas semblant de le connaître : dis clairement "Je ne vois pas [nom] dans ton univers actuel, veux-tu qu'on le crée ?" plutôt que d'inventer des détails à son sujet. +- Tu PEUX et DOIS inventer des éléments originaux (personnages, lieux, objets, intrigues, créatures, scènes) — c'est ton rôle d'assistant créatif. +- Tu ne peux PAS faire référence à un élément du MJ (du Lore, des arcs, chapitres ou scènes) comme s'il existait déjà, SAUF s'il apparaît EXACTEMENT (même orthographe) dans l'une des sections de contexte ci-dessous. +- Si l'utilisateur mentionne un nom que tu ne vois pas dans le contexte, ne fais surtout pas semblant de le connaître : dis clairement "Je ne vois pas [nom] dans le contexte actuel, veux-tu qu'on le crée ?" plutôt que d'inventer des détails à son sujet. - Évite les précisions inventées qu'on ne peut pas vérifier : dates exactes, chiffres de population, hiérarchies politiques complexes, généalogies détaillées. Préfère des formulations ouvertes que le MJ validera ("il y a longtemps", "de nombreux", "la haute noblesse").""" class ChatUseCase: - """Orchestre un tour de conversation avec le LLM + contexte Lore.""" + """Orchestre un tour de conversation avec le LLM + contextes structurels.""" def __init__(self, llm: LLMChatProvider) -> None: self._llm = llm @@ -44,17 +58,22 @@ class ChatUseCase: async def stream( self, messages: list[ChatMessage], - context: LoreStructuralContext, + *, + lore_context: LoreStructuralContext | None = None, page_context: PageContext | None = None, + campaign_context: CampaignStructuralContext | None = None, + narrative_entity: NarrativeEntityContext | None = None, ) -> AsyncIterator[str]: """Streame les tokens de la réponse assistant pour le dernier message user. - Si `page_context` est fourni, le system prompt gagne une section - "PAGE EN COURS" qui oriente l'IA vers cette page précise (titre, - template, champs, valeurs actuelles). Sans ce contexte, le chat - reste générique au Lore (comportement avant b8). + Les 4 contextes sont tous optionnels, mais au moins l'un des deux + "niveaux haut" (lore_context ou campaign_context) doit être fourni + pour que le prompt ait du sens. Le controller (main.py) applique + cette règle à la frontière HTTP. """ - system_prompt = self._build_system_prompt(context, page_context) + system_prompt = self._build_system_prompt( + lore_context, page_context, campaign_context, narrative_entity + ) async for token in self._llm.stream_chat( messages, system_prompt=system_prompt, @@ -62,28 +81,70 @@ class ChatUseCase: ): yield token + # --- Construction du system prompt -------------------------------------- + def _build_system_prompt( self, - ctx: LoreStructuralContext, - page_ctx: PageContext | None, + lore: LoreStructuralContext | None, + page: PageContext | None, + campaign: CampaignStructuralContext | None, + narrative: NarrativeEntityContext | None, ) -> str: - desc = f"\nDescription : {ctx.lore_description}" if ctx.lore_description else "" - folders_block = self._format_folders(ctx.folders) - tags_line = ", ".join(ctx.tags) if ctx.tags else "(aucun)" + sections = [_BASE_SYSTEM] + if lore is not None: + sections.append(self._format_lore(lore)) + if campaign is not None: + sections.append(self._format_campaign(campaign, lore_present=lore is not None)) + if page is not None: + sections.append(self._format_page(page)) + if narrative is not None: + sections.append(self._format_narrative_entity(narrative)) + return "\n\n".join(sections) - prompt = ( - f"{_BASE_SYSTEM}\n\n" - f"--- UNIVERS COURANT ---\n" + # --- Blocs Lore --------------------------------------------------------- + + @staticmethod + def _format_lore(ctx: LoreStructuralContext) -> str: + desc = f"\nDescription : {ctx.lore_description}" if ctx.lore_description else "" + folders_block = ChatUseCase._format_folders(ctx.folders) + tags_line = ", ".join(ctx.tags) if ctx.tags else "(aucun)" + return ( + "--- UNIVERS (Lore) ---\n" f"Nom : {ctx.lore_name}{desc}\n\n" f"Organisation :\n{folders_block}\n\n" f"Tags déjà utilisés : {tags_line}" ) - if page_ctx is not None: - prompt += "\n\n" + self._format_page_context(page_ctx) - return prompt @staticmethod - def _format_page_context(pc: PageContext) -> str: + def _format_folders(folders: dict[str, list[PageSummary]]) -> str: + """Rend chaque page avec son contenu exploitable par le LLM. + + Depuis b9 : affiche en plus des champs values/tags/pages liées sous + forme d'une fiche indentée par page, et seulement si l'info existe + (prompt compact quand une page est vierge). + """ + if not folders: + return "(Lore vide pour l'instant)" + lines: list[str] = [] + for folder_name, pages in folders.items(): + lines.append(f"- {folder_name} (dossier)") + if not pages: + lines.append(" (vide)") + continue + for ps in pages: + lines.append(f" - {ps.title} [template: {ps.template_name}]") + for field_name, value in ps.values.items(): + lines.append(f" · {field_name} : {value}") + if ps.tags: + lines.append(f" · tags : {', '.join(ps.tags)}") + if ps.related_page_titles: + lines.append( + " · liée à : " + ", ".join(ps.related_page_titles) + ) + return "\n".join(lines) + + @staticmethod + def _format_page(pc: PageContext) -> str: """Bloc "PAGE EN COURS" — oriente l'IA vers la page précise éditée.""" if pc.template_fields: fields_block = "\n".join( @@ -92,29 +153,99 @@ class ChatUseCase: ) else: fields_block = "(aucun champ défini dans ce template)" - return ( - f"--- PAGE EN COURS D'ÉDITION ---\n" + "--- PAGE EN COURS D'ÉDITION ---\n" f"Titre : {pc.title}\n" f"Template : {pc.template_name}\n" f"Champs et valeurs actuelles :\n{fields_block}\n\n" - f"IMPORTANT : concentre-toi EXCLUSIVEMENT sur cette page. " - f"Si l'utilisateur te demande de proposer des idées, elles doivent " - f"concerner UNIQUEMENT les champs listés ci-dessus. Ne déborde pas " - f"vers d'autres pages ou d'autres templates du Lore, même si ça te " - f"semblerait pertinent." + "IMPORTANT : concentre-toi EXCLUSIVEMENT sur cette page. " + "Si l'utilisateur te demande de proposer des idées, elles doivent " + "concerner UNIQUEMENT les champs listés ci-dessus. Ne déborde pas " + "vers d'autres pages ou d'autres templates du Lore, même si ça te " + "semblerait pertinent." + ) + + # --- Blocs Campagne ----------------------------------------------------- + + @staticmethod + def _format_campaign(ctx: CampaignStructuralContext, *, lore_present: bool) -> str: + desc = f"\nDescription : {ctx.campaign_description}" if ctx.campaign_description else "" + arcs_block = ChatUseCase._format_arcs(ctx.arcs) + lore_note = ( + "\n(Cette campagne est liée à l'univers ci-dessus : tu peux t'appuyer dessus.)" + if lore_present + else "\n(Cette campagne n'est associée à aucun univers — tu peux proposer des éléments d'ambiance libres.)" + ) + return ( + "--- CAMPAGNE COURANTE ---\n" + f"Nom : {ctx.campaign_name}{desc}{lore_note}\n\n" + f"Structure narrative :\n{arcs_block}" ) @staticmethod - def _format_folders(folders: dict[str, list[tuple[str, str]]]) -> str: - if not folders: - return "(Lore vide pour l'instant)" + def _format_arcs(arcs: list[ArcSummary]) -> str: + if not arcs: + return "(Aucun arc créé pour l'instant.)" lines: list[str] = [] - for folder_name, pages in folders.items(): - lines.append(f"- {folder_name} (dossier)") - if not pages: - lines.append(" (vide)") - else: - for title, template in pages: - lines.append(f" - {title} [template: {template}]") + for arc in arcs: + lines.append(f"- {arc.name} (arc){ChatUseCase._illustration_hint(arc.illustration_count)}") + if arc.description: + lines.append(f" Synopsis : {arc.description}") + if not arc.chapters: + lines.append(" (aucun chapitre)") + continue + for chapter in arc.chapters: + lines.extend(ChatUseCase._format_chapter_block(chapter)) return "\n".join(lines) + + @staticmethod + def _format_chapter_block(chapter: ChapterSummary) -> list[str]: + hint = ChatUseCase._illustration_hint(chapter.illustration_count) + block = [f" - {chapter.name} (chapitre){hint}"] + if chapter.description: + block.append(f" Synopsis : {chapter.description}") + if not chapter.scenes: + block.append(" (aucune scène)") + else: + for scene in chapter.scenes: + sc_hint = ChatUseCase._illustration_hint(scene.illustration_count) + block.append(f" - {scene.name} (scène){sc_hint}") + if scene.description: + block.append(f" Description : {scene.description}") + return block + + @staticmethod + def _illustration_hint(count: int) -> str: + """Rend " [N illustrations]" si count > 0, sinon chaine vide. + + Informe l'IA que l'entite a deja un support visuel. Permet de prioriser + les suggestions ecrites qui collent a l'existant visuel plutot que de + diverger. + """ + if count <= 0: + return "" + noun = "illustration" if count == 1 else "illustrations" + return f" [{count} {noun}]" + + @staticmethod + def _format_narrative_entity(ne: NarrativeEntityContext) -> str: + """Bloc équivalent à _format_page mais pour Arc/Chapter/Scene.""" + type_label = {"arc": "ARC", "chapter": "CHAPITRE", "scene": "SCÈNE"}.get( + ne.entity_type.lower(), ne.entity_type.upper() + ) + if ne.fields: + fields_block = "\n".join( + f'- "{key}" : {value or "(vide)"}' + for key, value in ne.fields.items() + ) + else: + fields_block = "(aucun champ renseigné)" + return ( + f"--- {type_label} EN COURS D'ÉDITION ---\n" + f"Titre : {ne.title}\n" + f"Champs et valeurs actuelles :\n{fields_block}\n\n" + "IMPORTANT : concentre-toi EXCLUSIVEMENT sur cette entité narrative. " + "Tes suggestions doivent enrichir UNIQUEMENT les champs listés ci-dessus. " + "Ne déborde pas vers d'autres arcs, chapitres ou scènes de la campagne, " + "même si ça te semblerait pertinent." + ) diff --git a/brain/app/core/config.py b/brain/app/core/config.py index 5f623ba..adfa942 100644 --- a/brain/app/core/config.py +++ b/brain/app/core/config.py @@ -19,9 +19,16 @@ class Settings(BaseSettings): ) ollama_base_url: str = "http://localhost:11434" - llm_model: str = "gemma4:e2b" + llm_model: str = "gemma4:26b" llm_timeout_seconds: int = 120 + # Fenêtre de contexte (num_ctx Ollama). Défaut Ollama = 2048, trop étroit + # dès que le Structural Context du Lore dépasse ~10 pages (b9). On monte + # à 16384 pour tenir ~100 pages enrichies. Coût VRAM : ~600 MB de KV cache + # supplémentaire (vs 2048) pour le modèle gemma 2B. Surchargeable via + # LLM_NUM_CTX dans .env si besoin (ex: VRAM limitée → 8192). + llm_num_ctx: int = 16384 + @lru_cache def get_settings() -> Settings: diff --git a/brain/app/domain/models.py b/brain/app/domain/models.py index a714422..7e861dc 100644 --- a/brain/app/domain/models.py +++ b/brain/app/domain/models.py @@ -53,20 +53,40 @@ class ChatMessage: @dataclass(frozen=True) -class LoreStructuralContext: - """Carte structurelle d'un Lore pour nourrir l'IA sans tout lui envoyer. +class PageSummary: + """Résumé enrichi d'une page du Lore, projeté pour alimenter le prompt. - Pas de contenu des pages — uniquement noms, dossiers, templates, tags. - Suffit pour que l'IA propose des suggestions cohérentes avec l'existant. + Depuis b9 : on ne se contente plus du nom + template, on embarque aussi + les valeurs des champs dynamiques (tronquées côté Core Java à 500 car.), + les tags, et les titres des pages liées (les IDs techniques sont déjà + résolus en titres lisibles côté Java — voir LoreStructuralContextBuilder). + + Les notes privées du MJ restent volontairement absentes ici (confinées + à leur page d'édition via PageContext quand l'utilisateur y travaille). + """ + + title: str + template_name: str + values: dict[str, str] + tags: list[str] + related_page_titles: list[str] + + +@dataclass(frozen=True) +class LoreStructuralContext: + """Carte structurelle enrichie d'un Lore pour nourrir l'IA. + + Depuis b9 : chaque page expose son contenu (values, tags, liens) via + PageSummary. Le prompt n'est plus qu'une table des matières — c'est + une encyclopédie condensée que le LLM peut directement citer. Le dict `folders` est indexé par nom de dossier et mappe vers la liste - des pages qu'il contient, chaque page étant représentée par le tuple - (page_title, template_name). + des pages qu'il contient (PageSummary). """ lore_name: str lore_description: str | None - folders: dict[str, list[tuple[str, str]]] + folders: dict[str, list[PageSummary]] tags: list[str] @@ -87,3 +107,65 @@ class PageContext: template_name: str template_fields: list[str] values: dict[str, str] + + +@dataclass(frozen=True) +class SceneSummary: + """Résumé d'une scène : nom + description courte + nb illustrations.""" + + name: str + description: str | None + # Depuis l'etape 6 : permet a l'IA de savoir qu'une scene a des illustrations + # attachees. 0 par defaut pour retrocompat si le Core n'envoie rien. + illustration_count: int = 0 + + +@dataclass(frozen=True) +class ChapterSummary: + """Résumé d'un chapitre : nom + description courte + ses scènes.""" + + name: str + description: str | None + scenes: list[SceneSummary] + illustration_count: int = 0 + + +@dataclass(frozen=True) +class ArcSummary: + """Résumé d'un arc narratif : nom + description courte + ses chapitres.""" + + name: str + description: str | None + chapters: list[ChapterSummary] + illustration_count: int = 0 + + +@dataclass(frozen=True) +class CampaignStructuralContext: + """Carte narrative enrichie d'une Campagne pour nourrir l'IA. + + Jumeau de LoreStructuralContext côté Campaign. On décrit l'arbre + arcs → chapitres → scènes en donnant le NOM + une DESCRIPTION courte + (synopsis) à chaque niveau. Les champs longs (notes MJ, narration + joueur, combat) restent réservés à l'entité focus via + NarrativeEntityContext. Ordre narratif préservé dans la liste `arcs`. + """ + + campaign_name: str + campaign_description: str | None + arcs: list[ArcSummary] + + +@dataclass(frozen=True) +class NarrativeEntityContext: + """Contexte d'une entité narrative précise en cours d'édition. + + Équivalent de PageContext côté Campaign. Focalise l'IA sur un Arc, + Chapter ou Scene en particulier. `entity_type` ∈ {"arc","chapter","scene"}. + Les `fields` sont une map ordonnée nomChamp → valeurActuelle (chaîne + vide si non renseigné). + """ + + entity_type: str + title: str + fields: dict[str, str] diff --git a/brain/app/infrastructure/ollama_adapter.py b/brain/app/infrastructure/ollama_adapter.py index 9a0b12d..560d2b7 100644 --- a/brain/app/infrastructure/ollama_adapter.py +++ b/brain/app/infrastructure/ollama_adapter.py @@ -26,6 +26,20 @@ class OllamaLLMProvider: self._base_url = settings.ollama_base_url self._model = settings.llm_model self._timeout = settings.llm_timeout_seconds + self._num_ctx = settings.llm_num_ctx + + def _build_options(self, temperature: float | None) -> dict[str, object]: + """Construit le dict `options` attendu par Ollama (hyperparamètres). + + `num_ctx` est TOUJOURS envoyé — sinon Ollama retombe sur son défaut + 2048 et tronque silencieusement les gros prompts (Structural Context + du Lore enrichi depuis b9). `temperature` n'est ajoutée que si + fournie par le use case (sinon Ollama utilise son défaut). + """ + options: dict[str, object] = {"num_ctx": self._num_ctx} + if temperature is not None: + options["temperature"] = temperature + return options async def generate( self, @@ -39,12 +53,10 @@ class OllamaLLMProvider: "model": self._model, "prompt": prompt, "stream": False, + "options": self._build_options(temperature), } if output_format is not None: payload["format"] = output_format - if temperature is not None: - # Ollama attend les hyperparamètres sous la clé "options". - payload["options"] = {"temperature": temperature} async with httpx.AsyncClient(timeout=self._timeout) as client: try: @@ -87,9 +99,8 @@ class OllamaLLMProvider: "model": self._model, "messages": payload_messages, "stream": True, + "options": self._build_options(temperature), } - if temperature is not None: - payload["options"] = {"temperature": temperature} async with httpx.AsyncClient(timeout=self._timeout) as client: try: diff --git a/brain/app/main.py b/brain/app/main.py index 06560e8..6245601 100644 --- a/brain/app/main.py +++ b/brain/app/main.py @@ -14,7 +14,18 @@ from pydantic import BaseModel, Field from app.application.chat import ChatUseCase from app.application.generate_page import GeneratePageUseCase from app.core.config import Settings, get_settings -from app.domain.models import ChatMessage, LoreStructuralContext, PageContext, PageGenerationContext +from app.domain.models import ( + ArcSummary, + CampaignStructuralContext, + ChapterSummary, + ChatMessage, + LoreStructuralContext, + NarrativeEntityContext, + PageContext, + PageGenerationContext, + PageSummary, + SceneSummary, +) from app.domain.ports import LLMProvider, LLMProviderError from app.infrastructure.ollama_adapter import OllamaLLMProvider @@ -61,19 +72,27 @@ class ChatMessageDTO(BaseModel): content: str -class FolderPageDTO(BaseModel): - """Résumé d'une page dans un dossier (titre + nom de template).""" +class PageSummaryDTO(BaseModel): + """Résumé enrichi d'une page : identité + contenu + interconnexions. + + Depuis b9 : values/tags/related_page_titles sont optionnels côté JSON — + le Core Java ne les sérialise que s'ils sont non-vides (payload léger + pour un Lore avec beaucoup de pages vierges). + """ title: str template_name: str + values: dict[str, str] = Field(default_factory=dict) + tags: list[str] = Field(default_factory=list) + related_page_titles: list[str] = Field(default_factory=list) class LoreContextDTO(BaseModel): - """Carte structurelle du Lore : on envoie des noms, pas des contenus.""" + """Carte structurelle du Lore avec contenu des pages (b9+).""" lore_name: str lore_description: str | None = None - folders: dict[str, list[FolderPageDTO]] = Field(default_factory=dict) + folders: dict[str, list[PageSummaryDTO]] = Field(default_factory=dict) tags: list[str] = Field(default_factory=list) @@ -86,12 +105,68 @@ class PageContextDTO(BaseModel): values: dict[str, str] = Field(default_factory=dict) +class SceneSummaryDTO(BaseModel): + """Résumé d'une scène : nom + description courte (synopsis).""" + + name: str + description: str | None = None + # Optionnel : le Core Java ne serialise illustration_count QUE si > 0 + # (payload plus leger). Defaut 0 = pas d'illustrations ou champ absent. + illustration_count: int = 0 + + +class ChapterSummaryDTO(BaseModel): + """Résumé d'un chapitre : nom + description courte + ses scènes.""" + + name: str + description: str | None = None + scenes: list[SceneSummaryDTO] = Field(default_factory=list) + illustration_count: int = 0 + + +class ArcSummaryDTO(BaseModel): + """Résumé d'un arc narratif : nom + description courte + ses chapitres.""" + + name: str + description: str | None = None + chapters: list[ChapterSummaryDTO] = Field(default_factory=list) + illustration_count: int = 0 + + +class CampaignContextDTO(BaseModel): + """Carte narrative enrichie : arcs → chapitres → scènes avec synopsis.""" + + campaign_name: str + campaign_description: str | None = None + arcs: list[ArcSummaryDTO] = Field(default_factory=list) + + +class NarrativeEntityDTO(BaseModel): + """Entité narrative (arc/chapter/scene) en cours d'édition — focus optionnel.""" + + entity_type: str = Field(pattern="^(arc|chapter|scene)$") + title: str + fields: dict[str, str] = Field(default_factory=dict) + + class ChatStreamRequestDTO(BaseModel): - """Requête de chat streamé : historique + contexte Lore (+ page éditée).""" + """Requête de chat streamé : historique + contextes structurels. + + Les 4 contextes (lore, page, campaign, narrative_entity) sont optionnels, + mais au moins l'un des deux "niveaux haut" (lore_context ou + campaign_context) doit être fourni. Le validateur `check_scope` applique + cette règle à la frontière HTTP. + """ messages: list[ChatMessageDTO] = Field(min_length=1) - lore_context: LoreContextDTO + lore_context: LoreContextDTO | None = None page_context: PageContextDTO | None = None + campaign_context: CampaignContextDTO | None = None + narrative_entity: NarrativeEntityDTO | None = None + + def has_scope(self) -> bool: + """Vrai si au moins un contexte racine (Lore ou Campagne) est fourni.""" + return self.lore_context is not None or self.campaign_context is not None # --- Factories d'injection de dépendance --- @@ -185,38 +260,38 @@ async def chat_stream( body: ChatStreamRequestDTO, use_case: Annotated[ChatUseCase, Depends(get_chat_use_case)], ) -> StreamingResponse: - """Chat streamé (Server-Sent Events) avec Structural Context du Lore. + """Chat streamé (Server-Sent Events) avec Structural Context. + + Accepte jusqu'à 4 contextes optionnels (Lore, Page focalisée, Campagne, + entité narrative focalisée). Au moins un contexte racine (Lore ou + Campagne) est requis pour que la requête ait du sens. Format de flux : - Chaque token : `data: {"token": "..."}\\n\\n` - Fin normale : `event: done\\ndata: {}\\n\\n` - Erreur LLM : `event: error\\ndata: {"message": "..."}\\n\\n` - - Le media_type `text/event-stream` déclenche le comportement SSE côté - navigateur (objet EventSource) et la désactivation automatique du buffer. """ - messages = [ChatMessage(role=m.role, content=m.content) for m in body.messages] - context = LoreStructuralContext( - lore_name=body.lore_context.lore_name, - lore_description=body.lore_context.lore_description, - folders={ - folder: [(p.title, p.template_name) for p in pages] - for folder, pages in body.lore_context.folders.items() - }, - tags=body.lore_context.tags, - ) - page_context: PageContext | None = None - if body.page_context is not None: - page_context = PageContext( - title=body.page_context.title, - template_name=body.page_context.template_name, - template_fields=body.page_context.template_fields, - values=body.page_context.values, + if not body.has_scope(): + raise HTTPException( + status_code=422, + detail="Au moins un des deux contextes racines (lore_context ou campaign_context) est requis.", ) + messages = [ChatMessage(role=m.role, content=m.content) for m in body.messages] + lore_context = _to_lore_context(body.lore_context) + page_context = _to_page_context(body.page_context) + campaign_context = _to_campaign_context(body.campaign_context) + narrative_entity = _to_narrative_entity(body.narrative_entity) + async def event_stream() -> AsyncIterator[str]: try: - async for token in use_case.stream(messages, context, page_context): + async for token in use_case.stream( + messages, + lore_context=lore_context, + page_context=page_context, + campaign_context=campaign_context, + narrative_entity=narrative_entity, + ): # json.dumps avec ensure_ascii=False pour préserver les accents yield f"data: {json.dumps({'token': token}, ensure_ascii=False)}\n\n" yield "event: done\ndata: {}\n\n" @@ -224,3 +299,85 @@ async def chat_stream( yield f"event: error\ndata: {json.dumps({'message': str(exc)})}\n\n" return StreamingResponse(event_stream(), media_type="text/event-stream") + + +# --- Mapping DTO → domaine (frontière HTTP) --------------------------------- + + +def _to_lore_context(dto: LoreContextDTO | None) -> LoreStructuralContext | None: + if dto is None: + return None + return LoreStructuralContext( + lore_name=dto.lore_name, + lore_description=dto.lore_description, + folders={ + folder: [_to_page_summary(p) for p in pages] + for folder, pages in dto.folders.items() + }, + tags=dto.tags, + ) + + +def _to_page_summary(dto: PageSummaryDTO) -> PageSummary: + return PageSummary( + title=dto.title, + template_name=dto.template_name, + values=dict(dto.values), + tags=list(dto.tags), + related_page_titles=list(dto.related_page_titles), + ) + + +def _to_page_context(dto: PageContextDTO | None) -> PageContext | None: + if dto is None: + return None + return PageContext( + title=dto.title, + template_name=dto.template_name, + template_fields=dto.template_fields, + values=dto.values, + ) + + +def _to_campaign_context(dto: CampaignContextDTO | None) -> CampaignStructuralContext | None: + if dto is None: + return None + arcs = [ + ArcSummary( + name=arc.name, + description=arc.description, + illustration_count=arc.illustration_count, + chapters=[ + ChapterSummary( + name=ch.name, + description=ch.description, + illustration_count=ch.illustration_count, + scenes=[ + SceneSummary( + name=sc.name, + description=sc.description, + illustration_count=sc.illustration_count, + ) + for sc in ch.scenes + ], + ) + for ch in arc.chapters + ], + ) + for arc in dto.arcs + ] + return CampaignStructuralContext( + campaign_name=dto.campaign_name, + campaign_description=dto.campaign_description, + arcs=arcs, + ) + + +def _to_narrative_entity(dto: NarrativeEntityDTO | None) -> NarrativeEntityContext | None: + if dto is None: + return None + return NarrativeEntityContext( + entity_type=dto.entity_type, + title=dto.title, + fields=dict(dto.fields), + ) diff --git a/core/pom.xml b/core/pom.xml index e578539..1e97d1f 100644 --- a/core/pom.xml +++ b/core/pom.xml @@ -69,6 +69,13 @@ spring-boot-starter-test test + + + + io.minio + minio + 8.5.11 + diff --git a/core/src/main/java/com/loremind/application/campaigncontext/ArcService.java b/core/src/main/java/com/loremind/application/campaigncontext/ArcService.java index 691ac43..c61e0e1 100644 --- a/core/src/main/java/com/loremind/application/campaigncontext/ArcService.java +++ b/core/src/main/java/com/loremind/application/campaigncontext/ArcService.java @@ -63,6 +63,7 @@ public class ArcService { arc.setRewards(updated.getRewards()); arc.setResolution(updated.getResolution()); arc.setRelatedPageIds(updated.getRelatedPageIds()); + arc.setIllustrationImageIds(updated.getIllustrationImageIds()); return arcRepository.save(arc); } diff --git a/core/src/main/java/com/loremind/application/campaigncontext/ChapterService.java b/core/src/main/java/com/loremind/application/campaigncontext/ChapterService.java index 427e960..d305071 100644 --- a/core/src/main/java/com/loremind/application/campaigncontext/ChapterService.java +++ b/core/src/main/java/com/loremind/application/campaigncontext/ChapterService.java @@ -60,6 +60,7 @@ public class ChapterService { chapter.setPlayerObjectives(updated.getPlayerObjectives()); chapter.setNarrativeStakes(updated.getNarrativeStakes()); chapter.setRelatedPageIds(updated.getRelatedPageIds()); + chapter.setIllustrationImageIds(updated.getIllustrationImageIds()); return chapterRepository.save(chapter); } 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 810a977..44f453e 100644 --- a/core/src/main/java/com/loremind/application/campaigncontext/SceneService.java +++ b/core/src/main/java/com/loremind/application/campaigncontext/SceneService.java @@ -65,6 +65,7 @@ public class SceneService { scene.setCombatDifficulty(updated.getCombatDifficulty()); scene.setEnemies(updated.getEnemies()); scene.setRelatedPageIds(updated.getRelatedPageIds()); + scene.setIllustrationImageIds(updated.getIllustrationImageIds()); return sceneRepository.save(scene); } diff --git a/core/src/main/java/com/loremind/application/generationcontext/CampaignStructuralContextBuilder.java b/core/src/main/java/com/loremind/application/generationcontext/CampaignStructuralContextBuilder.java new file mode 100644 index 0000000..0b5ddf8 --- /dev/null +++ b/core/src/main/java/com/loremind/application/generationcontext/CampaignStructuralContextBuilder.java @@ -0,0 +1,111 @@ +package com.loremind.application.generationcontext; + +import com.loremind.domain.campaigncontext.Arc; +import com.loremind.domain.campaigncontext.Campaign; +import com.loremind.domain.campaigncontext.Chapter; +import com.loremind.domain.campaigncontext.Scene; +import com.loremind.domain.campaigncontext.ports.ArcRepository; +import com.loremind.domain.campaigncontext.ports.CampaignRepository; +import com.loremind.domain.campaigncontext.ports.ChapterRepository; +import com.loremind.domain.campaigncontext.ports.SceneRepository; +import com.loremind.domain.generationcontext.CampaignStructuralContext; +import com.loremind.domain.generationcontext.CampaignStructuralContext.ArcSummary; +import com.loremind.domain.generationcontext.CampaignStructuralContext.ChapterSummary; +import com.loremind.domain.generationcontext.CampaignStructuralContext.SceneSummary; +import org.springframework.stereotype.Component; + +import java.util.Comparator; +import java.util.List; +import java.util.stream.Collectors; + +/** + * Service applicatif qui construit un {@link CampaignStructuralContext} + * depuis le Campaign Context (projection Campaign → GenerationContext). + * + * Traverse l'arbre arcs → chapitres → scènes en respectant l'ordre narratif + * (tri sur le champ `order` de chaque entité). Charge le NOM + le SYNOPSIS + * (description courte) de chaque niveau : l'IA sait donc de quoi parle + * chaque scène/chapitre/arc sans qu'on lui passe les notes MJ ou la + * narration détaillée — celles-ci restent réservées à l'entité focus via + * NarrativeEntityContext. + */ +@Component +public class CampaignStructuralContextBuilder { + + private final CampaignRepository campaignRepository; + private final ArcRepository arcRepository; + private final ChapterRepository chapterRepository; + private final SceneRepository sceneRepository; + + public CampaignStructuralContextBuilder( + CampaignRepository campaignRepository, + ArcRepository arcRepository, + ChapterRepository chapterRepository, + SceneRepository sceneRepository) { + this.campaignRepository = campaignRepository; + this.arcRepository = arcRepository; + this.chapterRepository = chapterRepository; + this.sceneRepository = sceneRepository; + } + + /** + * Construit la carte narrative d'une Campagne (arcs → chapitres → scènes, + * nom + description courte à chaque niveau). + * @throws IllegalArgumentException si la Campagne est introuvable + */ + public CampaignStructuralContext build(String campaignId) { + Campaign campaign = campaignRepository.findById(campaignId) + .orElseThrow(() -> new IllegalArgumentException( + "Campagne non trouvée avec l'ID: " + campaignId)); + + List arcs = arcRepository.findByCampaignId(campaignId).stream() + .sorted(Comparator.comparingInt(Arc::getOrder)) + .map(this::toArcSummary) + .collect(Collectors.toList()); + + return CampaignStructuralContext.builder() + .campaignName(campaign.getName()) + .campaignDescription(campaign.getDescription()) + .arcs(arcs) + .build(); + } + + private ArcSummary toArcSummary(Arc arc) { + List chapters = chapterRepository.findByArcId(arc.getId()).stream() + .sorted(Comparator.comparingInt(Chapter::getOrder)) + .map(this::toChapterSummary) + .collect(Collectors.toList()); + return ArcSummary.builder() + .name(arc.getName()) + .description(arc.getDescription()) + .illustrationCount(countImages(arc.getIllustrationImageIds())) + .chapters(chapters) + .build(); + } + + private ChapterSummary toChapterSummary(Chapter chapter) { + List scenes = sceneRepository.findByChapterId(chapter.getId()).stream() + .sorted(Comparator.comparingInt(Scene::getOrder)) + .map(this::toSceneSummary) + .collect(Collectors.toList()); + return ChapterSummary.builder() + .name(chapter.getName()) + .description(chapter.getDescription()) + .illustrationCount(countImages(chapter.getIllustrationImageIds())) + .scenes(scenes) + .build(); + } + + private SceneSummary toSceneSummary(Scene scene) { + return SceneSummary.builder() + .name(scene.getName()) + .description(scene.getDescription()) + .illustrationCount(countImages(scene.getIllustrationImageIds())) + .build(); + } + + /** Helper defensif : compte les illustrations attachees (null-safe). */ + private static int countImages(List ids) { + return ids == null ? 0 : ids.size(); + } +} diff --git a/core/src/main/java/com/loremind/application/generationcontext/GeneratePageValuesUseCase.java b/core/src/main/java/com/loremind/application/generationcontext/GeneratePageValuesUseCase.java index 559d84d..9dd95b2 100644 --- a/core/src/main/java/com/loremind/application/generationcontext/GeneratePageValuesUseCase.java +++ b/core/src/main/java/com/loremind/application/generationcontext/GeneratePageValuesUseCase.java @@ -72,7 +72,9 @@ public class GeneratePageValuesUseCase { .loreDescription(lore.getDescription()) .folderName(folder.getName()) .templateName(template.getName()) - .templateFields(template.getFields()) + // Seuls les champs TEXT sont envoyes a l'IA : les champs IMAGE + // necessitent un workflow different (pas de generation LLM texte). + .templateFields(template.textFieldNames()) .pageTitle(page.getTitle()) .build(); @@ -114,10 +116,12 @@ public class GeneratePageValuesUseCase { } private void requireNonEmptyFields(Template template) { - if (template.getFields() == null || template.getFields().isEmpty()) { + // On exige au moins un champ TEXT : les champs IMAGE ne sont pas genereables + // par l'IA (pas de text-to-image pour l'instant). + if (template.textFieldNames().isEmpty()) { throw new IllegalStateException( "Le template '" + template.getName() - + "' n'a aucun champ à générer."); + + "' n'a aucun champ texte à générer."); } } } diff --git a/core/src/main/java/com/loremind/application/generationcontext/LoreStructuralContextBuilder.java b/core/src/main/java/com/loremind/application/generationcontext/LoreStructuralContextBuilder.java new file mode 100644 index 0000000..f732d8e --- /dev/null +++ b/core/src/main/java/com/loremind/application/generationcontext/LoreStructuralContextBuilder.java @@ -0,0 +1,175 @@ +package com.loremind.application.generationcontext; + +import com.loremind.domain.generationcontext.LoreStructuralContext; +import com.loremind.domain.generationcontext.LoreStructuralContext.PageSummary; +import com.loremind.domain.lorecontext.Lore; +import com.loremind.domain.lorecontext.LoreNode; +import com.loremind.domain.lorecontext.Page; +import com.loremind.domain.lorecontext.Template; +import com.loremind.domain.lorecontext.ports.LoreNodeRepository; +import com.loremind.domain.lorecontext.ports.LoreRepository; +import com.loremind.domain.lorecontext.ports.PageRepository; +import com.loremind.domain.lorecontext.ports.TemplateRepository; +import org.springframework.stereotype.Component; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; + +/** + * Service applicatif qui construit un {@link LoreStructuralContext} + * depuis le Lore Context (Single Responsibility : projection LoreContext → GenerationContext). + * + * Partagé entre {@link StreamChatForLoreUseCase} (Lore) et + * {@link StreamChatForCampaignUseCase} (Campagne liée à un Lore) pour + * respecter DRY — la carte structurelle d'un Lore se calcule de la même + * manière des deux côtés. + * + * Depuis b9 : chaque PageSummary embarque values/tags/relatedPageTitles + * (résolus en titres), avec troncature à {@value #MAX_VALUE_LENGTH} caractères + * par valeur pour garder le prompt sous contrôle. + */ +@Component +public class LoreStructuralContextBuilder { + + /** Garde-fou : évite qu'un champ énorme (ex: "Histoire" de 5000 car.) ne sature le prompt. */ + private static final int MAX_VALUE_LENGTH = 500; + + private final LoreRepository loreRepository; + private final LoreNodeRepository loreNodeRepository; + private final PageRepository pageRepository; + private final TemplateRepository templateRepository; + + public LoreStructuralContextBuilder( + LoreRepository loreRepository, + LoreNodeRepository loreNodeRepository, + PageRepository pageRepository, + TemplateRepository templateRepository) { + this.loreRepository = loreRepository; + this.loreNodeRepository = loreNodeRepository; + this.pageRepository = pageRepository; + this.templateRepository = templateRepository; + } + + /** + * Construit la carte structurelle pour un Lore obligatoire. + * @throws IllegalArgumentException si le Lore est introuvable + */ + public LoreStructuralContext build(String loreId) { + return buildOptional(loreId).orElseThrow(() -> + new IllegalArgumentException("Lore non trouvé avec l'ID: " + loreId)); + } + + /** + * Variante non-strict : renvoie Optional.empty() si le Lore a été supprimé + * (cas d'une Campagne dont le loreId pointe sur un Lore effacé entre-temps). + */ + public Optional buildOptional(String loreId) { + return loreRepository.findById(loreId).map(this::buildFromLore); + } + + private LoreStructuralContext buildFromLore(Lore lore) { + List nodes = loreNodeRepository.findByLoreId(lore.getId()); + List pages = pageRepository.findByLoreId(lore.getId()); + List