"""Use case : chat conversationnel LoreMind avec Structural Context. Construit un system prompt riche à partir de 4 contextes possibles (Lore, Page focalisée, Campagne, entité narrative focalisée) puis délègue au port `LLMChatProvider` pour le streaming token par token. Ne charge PAS le contenu détaillé des pages — l'IA doit savoir ce qui existe, pas être noyée sous le texte. Pattern "Structural Context", plus simple que le RAG sémantique tant que les univers restent de taille humaine. Combinaisons supportées : - lore seul → chat Lore (page-edit / page-create) - lore + page_context → chat Lore focalisé page - campaign (+lore si liée) + optional narrative_entity → chat Campagne """ from typing import AsyncIterator from app.domain.models import ( ArcSummary, CampaignStructuralContext, ChatMessage, ChapterSummary, LoreStructuralContext, NarrativeEntityContext, PageContext, PageSummary, ) from app.domain.ports import LLMChatProvider # Température moyenne : chat conversationnel créatif mais cohérent. # Plus élevée que le one-shot (0.4) car on veut de la variété d'idées, # mais sans partir en délire halluciné (1.0+). _DEFAULT_TEMPERATURE = 0.7 _BASE_SYSTEM = """Tu es un assistant d'écriture pour un Maître de Jeu de JDR. Tu dialogues avec le MJ pour l'aider à enrichir son univers et ses campagnes. Règles de ton : - Réponds en français, ton chaleureux et créatif. - Sois concis : listes à puces courtes plutôt que longs paragraphes. - Propose des idées qui s'intègrent dans le contexte existant ci-dessous. Règles de cohérence (IMPORTANT) : - Tu PEUX et DOIS inventer des éléments originaux (personnages, lieux, objets, intrigues, créatures, scènes) — c'est ton rôle d'assistant créatif. - Tu ne peux PAS faire référence à un élément du MJ (du Lore, des arcs, chapitres ou scènes) comme s'il existait déjà, SAUF s'il apparaît EXACTEMENT (même orthographe) dans l'une des sections de contexte ci-dessous. - Si l'utilisateur mentionne un nom que tu ne vois pas dans le contexte, ne fais surtout pas semblant de le connaître : dis clairement "Je ne vois pas [nom] dans le contexte actuel, veux-tu qu'on le crée ?" plutôt que d'inventer des détails à son sujet. - Évite les précisions inventées qu'on ne peut pas vérifier : dates exactes, chiffres de population, hiérarchies politiques complexes, généalogies détaillées. Préfère des formulations ouvertes que le MJ validera ("il y a longtemps", "de nombreux", "la haute noblesse").""" class ChatUseCase: """Orchestre un tour de conversation avec le LLM + contextes structurels.""" def __init__(self, llm: LLMChatProvider) -> None: self._llm = llm async def stream( self, messages: list[ChatMessage], *, lore_context: LoreStructuralContext | None = None, page_context: PageContext | None = None, campaign_context: CampaignStructuralContext | None = None, narrative_entity: NarrativeEntityContext | None = None, ) -> AsyncIterator[str]: """Streame les tokens de la réponse assistant pour le dernier message user. Les 4 contextes sont tous optionnels, mais au moins l'un des deux "niveaux haut" (lore_context ou campaign_context) doit être fourni pour que le prompt ait du sens. Le controller (main.py) applique cette règle à la frontière HTTP. """ system_prompt = self._build_system_prompt( lore_context, page_context, campaign_context, narrative_entity ) async for token in self._llm.stream_chat( messages, system_prompt=system_prompt, temperature=_DEFAULT_TEMPERATURE, ): yield token # --- Construction du system prompt -------------------------------------- def _build_system_prompt( self, lore: LoreStructuralContext | None, page: PageContext | None, campaign: CampaignStructuralContext | None, narrative: NarrativeEntityContext | None, ) -> str: sections = [_BASE_SYSTEM] if lore is not None: sections.append(self._format_lore(lore)) if campaign is not None: sections.append(self._format_campaign(campaign, lore_present=lore is not None)) if page is not None: sections.append(self._format_page(page)) if narrative is not None: sections.append(self._format_narrative_entity(narrative)) return "\n\n".join(sections) # --- Blocs Lore --------------------------------------------------------- @staticmethod def _format_lore(ctx: LoreStructuralContext) -> str: desc = f"\nDescription : {ctx.lore_description}" if ctx.lore_description else "" folders_block = ChatUseCase._format_folders(ctx.folders) tags_line = ", ".join(ctx.tags) if ctx.tags else "(aucun)" return ( "--- UNIVERS (Lore) ---\n" f"Nom : {ctx.lore_name}{desc}\n\n" f"Organisation :\n{folders_block}\n\n" f"Tags déjà utilisés : {tags_line}" ) @staticmethod def _format_folders(folders: dict[str, list[PageSummary]]) -> str: """Rend chaque page avec son contenu exploitable par le LLM. Depuis b9 : affiche en plus des champs values/tags/pages liées sous forme d'une fiche indentée par page, et seulement si l'info existe (prompt compact quand une page est vierge). """ if not folders: return "(Lore vide pour l'instant)" lines: list[str] = [] for folder_name, pages in folders.items(): lines.append(f"- {folder_name} (dossier)") if not pages: lines.append(" (vide)") continue for ps in pages: lines.append(f" - {ps.title} [template: {ps.template_name}]") for field_name, value in ps.values.items(): lines.append(f" · {field_name} : {value}") if ps.tags: lines.append(f" · tags : {', '.join(ps.tags)}") if ps.related_page_titles: lines.append( " · liée à : " + ", ".join(ps.related_page_titles) ) return "\n".join(lines) @staticmethod def _format_page(pc: PageContext) -> str: """Bloc "PAGE EN COURS" — oriente l'IA vers la page précise éditée.""" if pc.template_fields: fields_block = "\n".join( f'- "{f}" : {pc.values.get(f) or "(vide)"}' for f in pc.template_fields ) else: fields_block = "(aucun champ défini dans ce template)" return ( "--- PAGE EN COURS D'ÉDITION ---\n" f"Titre : {pc.title}\n" f"Template : {pc.template_name}\n" f"Champs et valeurs actuelles :\n{fields_block}\n\n" "IMPORTANT : concentre-toi EXCLUSIVEMENT sur cette page. " "Si l'utilisateur te demande de proposer des idées, elles doivent " "concerner UNIQUEMENT les champs listés ci-dessus. Ne déborde pas " "vers d'autres pages ou d'autres templates du Lore, même si ça te " "semblerait pertinent." ) # --- Blocs Campagne ----------------------------------------------------- @staticmethod def _format_campaign(ctx: CampaignStructuralContext, *, lore_present: bool) -> str: desc = f"\nDescription : {ctx.campaign_description}" if ctx.campaign_description else "" arcs_block = ChatUseCase._format_arcs(ctx.arcs) lore_note = ( "\n(Cette campagne est liée à l'univers ci-dessus : tu peux t'appuyer dessus.)" if lore_present else "\n(Cette campagne n'est associée à aucun univers — tu peux proposer des éléments d'ambiance libres.)" ) return ( "--- CAMPAGNE COURANTE ---\n" f"Nom : {ctx.campaign_name}{desc}{lore_note}\n\n" "Structure narrative (les flèches → indiquent des transitions de scène " "déclenchées par un choix des joueurs) :\n" f"{arcs_block}" ) @staticmethod def _format_arcs(arcs: list[ArcSummary]) -> str: if not arcs: return "(Aucun arc créé pour l'instant.)" lines: list[str] = [] for arc in arcs: lines.append(f"- {arc.name} (arc){ChatUseCase._illustration_hint(arc.illustration_count)}") if arc.description: lines.append(f" Synopsis : {arc.description}") if not arc.chapters: lines.append(" (aucun chapitre)") continue for chapter in arc.chapters: lines.extend(ChatUseCase._format_chapter_block(chapter)) return "\n".join(lines) @staticmethod def _format_chapter_block(chapter: ChapterSummary) -> list[str]: hint = ChatUseCase._illustration_hint(chapter.illustration_count) block = [f" - {chapter.name} (chapitre){hint}"] if chapter.description: block.append(f" Synopsis : {chapter.description}") if not chapter.scenes: block.append(" (aucune scène)") else: for scene in chapter.scenes: sc_hint = ChatUseCase._illustration_hint(scene.illustration_count) block.append(f" - {scene.name} (scène){sc_hint}") if scene.description: block.append(f" Description : {scene.description}") for br in scene.branches: cond = f" (si : {br.condition})" if br.condition else "" block.append( f' → "{br.label}" vers {br.target_scene_name}{cond}' ) return block @staticmethod def _illustration_hint(count: int) -> str: """Rend " [N illustrations]" si count > 0, sinon chaine vide. Informe l'IA que l'entite a deja un support visuel. Permet de prioriser les suggestions ecrites qui collent a l'existant visuel plutot que de diverger. """ if count <= 0: return "" noun = "illustration" if count == 1 else "illustrations" return f" [{count} {noun}]" @staticmethod def _format_narrative_entity(ne: NarrativeEntityContext) -> str: """Bloc équivalent à _format_page mais pour Arc/Chapter/Scene.""" type_label = {"arc": "ARC", "chapter": "CHAPITRE", "scene": "SCÈNE"}.get( ne.entity_type.lower(), ne.entity_type.upper() ) if ne.fields: fields_block = "\n".join( f'- "{key}" : {value or "(vide)"}' for key, value in ne.fields.items() ) else: fields_block = "(aucun champ renseigné)" return ( f"--- {type_label} EN COURS D'ÉDITION ---\n" f"Titre : {ne.title}\n" f"Champs et valeurs actuelles :\n{fields_block}\n\n" "IMPORTANT : concentre-toi EXCLUSIVEMENT sur cette entité narrative. " "Tes suggestions doivent enrichir UNIQUEMENT les champs listés ci-dessus. " "Ne déborde pas vers d'autres arcs, chapitres ou scènes de la campagne, " "même si ça te semblerait pertinent." )