diff --git a/brain/app/api/routers/generation.py b/brain/app/api/routers/generation.py index 5203b0f..d5df287 100644 --- a/brain/app/api/routers/generation.py +++ b/brain/app/api/routers/generation.py @@ -6,8 +6,9 @@ from pydantic import BaseModel, Field from app.api.deps import get_generate_page_use_case, get_llm_provider from app.application.generate_page import GeneratePageUseCase +from app.application.prompts import conversation_title as title_prompts from app.core.config import Settings, get_settings -from app.core.language import get_user_language, language_name +from app.core.language import get_user_language from app.domain.models import PageGenerationContext from app.domain.ports import LLMProvider, LLMProviderError @@ -103,20 +104,6 @@ class SummarizeTitleResponseDTO(BaseModel): title: str -# Titre de repli (LLM injoignable / réponse vide), localisé selon la langue UI. -_TITLE_FALLBACK = {"fr": "Nouvelle conversation", "en": "New conversation"} - - -def _title_system_prompt(language: str) -> str: - """Consigne d'auto-titre, avec la langue du titre pilotée par l'utilisateur.""" - return ( - "Tu generes un titre court (4 a 7 mots max) qui resume le sujet de la " - "conversation ci-dessous. Reponds UNIQUEMENT par le titre, sans guillemets, " - "sans ponctuation finale, sans prefixe type 'Titre :'. Le titre doit etre " - f"en {language_name(language)} et capturer le sujet metier (pas 'Conversation IA')." - ) - - @router.post("/summarize/conversation-title", response_model=SummarizeTitleResponseDTO) async def summarize_conversation_title( body: SummarizeTitleRequestDTO, @@ -132,7 +119,7 @@ async def summarize_conversation_title( raise HTTPException(status_code=422, detail="Au moins un message requis") transcript = "\n".join(f"{m.role.upper()}: {m.content}" for m in body.messages[:6]) - prompt = f"{_title_system_prompt(language)}\n\nConversation :\n{transcript}\n\nTitre :" + prompt = f"{title_prompts.title_system_prompt(language)}\n\nConversation :\n{transcript}\n\nTitre :" try: raw = await llm.generate(prompt) except LLMProviderError as exc: @@ -142,5 +129,5 @@ async def summarize_conversation_title( if len(title) > 80: title = title[:80].rstrip() if not title: - title = _TITLE_FALLBACK.get(language, _TITLE_FALLBACK["fr"]) + title = title_prompts.TITLE_FALLBACK.get(language, title_prompts.TITLE_FALLBACK["fr"]) return SummarizeTitleResponseDTO(title=title) diff --git a/brain/app/api/routers/tables.py b/brain/app/api/routers/tables.py index 1d42dee..7310f28 100644 --- a/brain/app/api/routers/tables.py +++ b/brain/app/api/routers/tables.py @@ -8,7 +8,8 @@ from pydantic import BaseModel, Field from app.api.deps import get_llm_provider from app.application.llm_json import load_json_object from app.application.llm_retry import generate_with_retry -from app.core.language import get_user_language, language_name +from app.application.prompts import tables as prompts +from app.core.language import get_user_language from app.domain.ports import LLMProvider, LLMProviderError router = APIRouter() @@ -59,22 +60,8 @@ async def generate_random_table( if rng is None: raise HTTPException(status_code=422, detail="Formule de dé invalide (ex. 1d20, 2d6, d100).") lo, hi = rng - context_block = f"\nContexte de la campagne :\n{body.context.strip()}\n" if body.context.strip() else "" - prompt = ( - "Tu es un assistant de jeu de rôle. Génère une TABLE ALÉATOIRE évocatrice.\n" - f"Dé : {body.dice_formula} (résultats possibles de {lo} à {hi}).\n" - f"Sujet : {body.description.strip()}\n" - f"{context_block}\n" - "Règles IMPÉRATIVES :\n" - "- Réponds UNIQUEMENT par un objet JSON valide, sans texte autour.\n" - '- Format : {"name": "...", "description": "...", "entries": ' - '[{"min_roll": N, "max_roll": M, "label": "résultat court", "detail": "1-2 phrases"}]}\n' - f"- Les plages (min_roll..max_roll) doivent COUVRIR EXACTEMENT {lo}..{hi}, " - "sans trou ni chevauchement, dans l'ordre croissant.\n" - "- Des résultats variés, cohérents avec le sujet (et le contexte s'il est fourni).\n" - f"- En {language_name(language)}. 'label' = résultat bref ; 'detail' = description/effet concret.\n" - "Renvoie maintenant le JSON." - ) + prompt = prompts.random_table_prompt( + body.description, body.dice_formula, lo, hi, body.context, language) try: raw = await generate_with_retry(llm, prompt, output_format="json", temperature=0.7) except LLMProviderError as exc: @@ -129,15 +116,8 @@ async def improvise_table_roll( language: Annotated[str, Depends(get_user_language)], ) -> ImproviseRollResponseDTO: """Brode un court récit (2-3 phrases) sur un résultat tiré, pour lancer la scène.""" - detail = f" ({body.result_detail.strip()})" if body.result_detail.strip() else "" - context_block = f"\nContexte : {body.context.strip()}" if body.context.strip() else "" - prompt = ( - "Tu es le Maître du Jeu. Les joueurs viennent de tirer sur la table " - f"« {body.table_name.strip()} » et ont obtenu : « {body.result_label.strip()} »{detail}." - f"{context_block}\n\n" - "Décris en 2-3 phrases vivantes et immédiates ce qui se passe, pour lancer la scène. " - f"Pas de méta, pas d'options : juste la narration, en {language_name(language)}." - ) + prompt = prompts.improvise_roll_prompt( + body.table_name, body.result_label, body.result_detail, body.context, language) try: raw = await llm.generate(prompt, temperature=0.8) except LLMProviderError as exc: @@ -173,20 +153,7 @@ async def generate_item_catalog( language: Annotated[str, Depends(get_user_language)], ) -> GenerateCatalogResponseDTO: """Génère un catalogue d'objets (boutique, butin…) — nom, prix, catégorie, description.""" - context_block = f"\nContexte de la campagne :\n{body.context.strip()}\n" if body.context.strip() else "" - prompt = ( - "Tu es un assistant de jeu de rôle. Génère un CATALOGUE D'OBJETS (boutique, butin, trésor…).\n" - f"Sujet : {body.description.strip()}\n" - f"{context_block}\n" - "Règles IMPÉRATIVES :\n" - "- Réponds UNIQUEMENT par un objet JSON valide, sans texte autour.\n" - '- Format : {"name": "...", "description": "...", "items": ' - '[{"name": "Objet", "price": "ex. 50 po", "category": "ex. Armes", "description": "effet/détails"}]}\n' - "- Des objets variés et cohérents avec le sujet (et le contexte s'il est fourni).\n" - "- 'price' = prix court dans la monnaie du jeu ; 'category' = regroupement (Armes, Potions…) ; " - f"'description' = effet/détails en une phrase. En {language_name(language)}.\n" - "Renvoie maintenant le JSON." - ) + prompt = prompts.item_catalog_prompt(body.description, body.context, language) try: raw = await generate_with_retry(llm, prompt, output_format="json", temperature=0.7) except LLMProviderError as exc: diff --git a/brain/app/application/adapt_campaign.py b/brain/app/application/adapt_campaign.py index 9727cf2..6a33bf3 100644 --- a/brain/app/application/adapt_campaign.py +++ b/brain/app/application/adapt_campaign.py @@ -13,7 +13,8 @@ from __future__ import annotations import logging from typing import AsyncIterator -from app.core.language import DEFAULT as _DEFAULT_LANG, language_name +from app.application.prompts import adapt_campaign as prompts +from app.core.language import DEFAULT as _DEFAULT_LANG from app.domain.models import ChatMessage from app.domain.ports import LLMChatProvider, PdfExtractionError, PdfTextExtractor @@ -22,30 +23,6 @@ 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." -) - - -def _system_suffix(language: str) -> str: - """Consignes de sortie, avec la langue des conseils pilotée par l'utilisateur.""" - return ( - f"Produis des CONSEILS D'ADAPTATION concrets, actionnables et en {language_name(language).upper()}, " - "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.""" @@ -92,12 +69,12 @@ class AdaptCampaignUseCase: ) # Concaténation (pas .format) : brief/PDF peuvent contenir des { } littéraux. system_prompt = ( - f"{_SYSTEM_PREFIX}\n\n" + f"{prompts.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(language)}\n\n" + f"{prompts.system_suffix(language)}\n\n" "Tu es en CONVERSATION : à chaque message de l'utilisateur, ajuste, corrige " "ou propose des alternatives en gardant tout ce contexte à l'esprit." ) diff --git a/brain/app/application/chat.py b/brain/app/application/chat.py index a8216b1..47c0429 100644 --- a/brain/app/application/chat.py +++ b/brain/app/application/chat.py @@ -31,7 +31,8 @@ from app.domain.models import ( QuestSummary, SessionContext, ) -from app.core.language import DEFAULT as _DEFAULT_LANG, language_name +from app.application.prompts import chat as prompts +from app.core.language import DEFAULT as _DEFAULT_LANG from app.domain.ports import LLMChatProvider @@ -41,23 +42,6 @@ from app.domain.ports import LLMChatProvider _DEFAULT_TEMPERATURE = 0.7 -def _base_system(language: str) -> str: - """System prompt de base, avec la langue de réponse pilotée par l'utilisateur.""" - return f"""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 {language_name(language)}, 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.""" @@ -124,7 +108,7 @@ class ChatUseCase: session: SessionContext | None = None, language: str = _DEFAULT_LANG, ) -> str: - sections = [_base_system(language)] + sections = [prompts.base_system(language)] if lore is not None: sections.append(self._format_lore(lore)) if campaign is not None: diff --git a/brain/app/application/generate_page.py b/brain/app/application/generate_page.py index 89bdecd..0c98df8 100644 --- a/brain/app/application/generate_page.py +++ b/brain/app/application/generate_page.py @@ -8,10 +8,13 @@ permet de tester ce use case avec un FakeLLMProvider, sans Ollama qui tourne. """ import json -from app.core.language import DEFAULT as _DEFAULT_LANG, language_name +from app.application.prompts import generate_page as prompts from app.domain.models import PageGenerationContext, PageGenerationResult from app.domain.ports import LLMProvider, LLMProviderError +# Langue de repli quand le router n'en fournit pas (appel direct / vieux client). +from app.core.language import DEFAULT as _DEFAULT_LANG + # Température basse : remplissage de champs = tâche factuelle, peu créative. # Une valeur trop haute (par défaut Ollama = 0.8) encourage l'IA à broder @@ -19,23 +22,6 @@ from app.domain.ports import LLMProvider, LLMProviderError _DEFAULT_TEMPERATURE = 0.4 -def _system_instructions(language: str) -> str: - """Consignes système, avec la langue des valeurs générées pilotée par l'utilisateur.""" - return f"""Tu es un assistant d'écriture pour un Maître de Jeu de JDR. -Tu vas générer le contenu d'une page appartenant à un univers fictionnel. - -Règles impératives de ta réponse : -- Tu réponds UNIQUEMENT par un objet JSON valide. -- Les clés du JSON correspondent EXACTEMENT aux noms de champs demandés. -- Les valeurs sont des chaînes de texte en {language_name(language)}, riches et évocatrices. -- Aucun markdown, aucune explication, aucun commentaire autour du JSON. - -Règles de cohérence (IMPORTANT) : -- Tu PEUX inventer des détails originaux pour CETTE page : apparence, traits de caractère, anecdotes, histoire personnelle. -- Tu ne dois PAS faire référence à d'autres personnages, lieux, organisations ou événements comme s'ils existaient déjà dans l'univers, sauf si le contexte ci-dessous les mentionne explicitement. -- Si un champ appelle une précision externe (date, nom d'un roi, ville voisine, guerre passée), reste volontairement vague : "il y a de nombreuses années", "un bourg voisin", "une époque troublée". Le MJ préfère combler lui-même les blancs plutôt que trouver des faits inventés contradictoires avec son univers.""" - - class GeneratePageUseCase: """Orchestre la génération d'une page LoreMind via un LLM.""" @@ -66,7 +52,7 @@ class GeneratePageUseCase: ) return ( - f"{_system_instructions(language)}\n\n" + f"{prompts.system_instructions(language)}\n\n" f"Univers : {context.lore_name}" f"{lore_desc_line}\n" f"Catégorie (dossier) : {context.folder_name}\n" diff --git a/brain/app/application/import_campaign.py b/brain/app/application/import_campaign.py index 00dee07..fb9d70c 100644 --- a/brain/app/application/import_campaign.py +++ b/brain/app/application/import_campaign.py @@ -21,6 +21,7 @@ from app.application.import_status import ( ) from app.application.llm_json import load_json_object, looks_like_truncated_json from app.application.llm_retry import generate_with_retry +from app.application.prompts import import_campaign as prompts from app.application.streaming import with_heartbeat # Repli anti-troncature : si la sortie d'un morceau est coupée, on le retraite en @@ -47,76 +48,11 @@ logger = logging.getLogger(__name__) # Plus la valeur est haute, plus le modèle "brode" (invente du contenu absent). _TEMPERATURE = 0.1 -# 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 []. - -PNJ ET CRÉATURES NOTABLES ("npcs", tableau au niveau racine) : -- Recense les PNJ NOMMÉS (alliés, marchands, antagonistes) et les créatures UNIQUES - (boss, monstre récurrent) présents dans l'extrait. -- `description` = courte fiche utile au MJ : rôle dans l'histoire, apparence, - motivations, où on le rencontre. 2 à 4 phrases, fidèles au livre. -- N'inclus PAS les monstres génériques sans nom (« 3 gobelins », « un loup »). -- Aucun PNJ nommé dans l'extrait → "npcs": []. - -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": "..."}}]}} - ]}}]}} - ], - "npcs": [{{"name": "...", "description": "..."}}]}} -- 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": []}}.""" - # Schéma de l'arbre attendu, passé aux providers à sorties structurées (Ollama # contraint la grammaire : un modèle local ne PEUT plus produire de clés # inventées, d'objets bavards type "thought" ni de texte hors JSON). Les @@ -195,47 +131,12 @@ _TREE_SCHEMA: dict = { "additionalProperties": False, } -# Bloc TOC injecté quand le PDF a des bookmarks : les morceaux étant traités -# séparément, c'est CE référentiel commun qui garantit que tous nomment les -# mêmes chapitres à l'identique → la fusion par nom du _TreeMerger recolle -# les chapitres coupés au lieu de créer des doublons. -_TOC_BLOCK = """ - ---- STRUCTURE OFFICIELLE DU LIVRE (table des matières du PDF) --- -{toc} ---- FIN DE LA STRUCTURE --- -IMPORTANT : pour nommer les arcs et chapitres, reprends EXACTEMENT les titres -de cette structure (caractère pour caractère). Rattache le contenu de l'extrait -au bon chapitre de la structure, même si son titre n'apparaît pas dans l'extrait.""" - # Garde-fou prompt : une TOC de gros livre peut compter des centaines d'entrées # (sous-sous-sections). On la limite aux niveaux hauts et à un nombre raisonnable. _TOC_MAX_LEVEL = 2 _TOC_MAX_ENTRIES = 80 -# Consolidation finale : le squelette (noms seuls) est minuscule, donc l'appel -# est quasi gratuit comparé aux MAP. Température 0 et consigne CONSERVATRICE : -# ne fusionner que les doublons évidents, jamais des entités distinctes. -_CONSOLIDATE_PROMPT = """Voici le squelette d'une arborescence arc → chapitre → scène issue d'une -fusion AUTOMATIQUE de morceaux d'un livre de campagne de jeu de rôle. La fusion par nom exact -peut avoir laissé des QUASI-DOUBLONS : le même chapitre ou la même scène sous deux libellés -légèrement différents (ex: "La Crypte" et "Crypte de Karrak", "3. Salle des gardes" et -"Salle des gardes"). - -{skeleton} - -Identifie UNIQUEMENT les fusions ÉVIDENTES (même entité du livre sous deux noms). Sois -CONSERVATEUR : dans le doute, ne fusionne PAS. Deux lieux/évènements distincts ne doivent -JAMAIS être fusionnés. - -Réponds UNIQUEMENT par un objet JSON valide : -{{"chapter_merges": [{{"into": "nom du chapitre à garder", "merge": ["nom à fusionner", ...]}}], - "scene_merges": [{{"chapter": "nom du chapitre", "into": "nom de la scène à garder", - "merge": ["nom à fusionner", ...]}}]}} -S'il n'y a RIEN à fusionner (cas le plus fréquent) : {{"chapter_merges": [], "scene_merges": []}}""" - - def _format_toc(toc) -> str: """Formate la TOC du PDF en liste indentée, bornée (niveaux hauts d'abord).""" entries = [e for e in toc if e.level <= _TOC_MAX_LEVEL][:_TOC_MAX_ENTRIES] @@ -626,7 +527,7 @@ class ImportCampaignUseCase: skeleton = merger.skeleton_text() try: raw = await generate_with_retry( - self._llm, _CONSOLIDATE_PROMPT.format(skeleton=skeleton), + self._llm, prompts.CONSOLIDATE_PROMPT.format(skeleton=skeleton), output_format="json", temperature=0.0) except Exception as exc: # noqa: BLE001 — best-effort STRICT : une erreur ici # (LLM, réseau, bug) ne doit JAMAIS faire perdre un import terminé. @@ -664,9 +565,9 @@ class ImportCampaignUseCase: """Extrait l'arborescence + les PNJ d'un texte. Si la SORTIE est tronquée, retraite le texte en DEUX moitiés et concatène — le `_TreeMerger` final dédoublonne par nom (un arc/chapitre coupé entre les moitiés est recollé).""" - toc_section = _TOC_BLOCK.format(toc=toc_block) if toc_block else "" + toc_section = prompts.TOC_BLOCK.format(toc=toc_block) if toc_block else "" prompt = ( - _MAP_SYSTEM.format(default_arc=_DEFAULT_ARC_NAME) + prompts.MAP_SYSTEM.format(default_arc=prompts.DEFAULT_ARC_NAME) + toc_section + f"\n\n--- EXTRAIT {index + 1}/{total} ---\n{text}\n\n" "Renvoie maintenant le JSON de l'arborescence." diff --git a/brain/app/application/import_rules.py b/brain/app/application/import_rules.py index 7891d89..196dea9 100644 --- a/brain/app/application/import_rules.py +++ b/brain/app/application/import_rules.py @@ -25,6 +25,7 @@ from app.application.import_status import ( ) from app.application.llm_json import load_json_object, looks_like_truncated_json from app.application.llm_retry import generate_with_retry +from app.application.prompts import import_rules as prompts from app.application.streaming import with_heartbeat from app.core.language import DEFAULT as _DEFAULT_LANG, language_name @@ -58,43 +59,6 @@ _SECTIONS_SCHEMA: dict = { "additionalProperties": {"type": "string"}, } -# 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. - -Format EXACT attendu — un objet JSON plat {{titre de section: contenu markdown}} : -{{"Combat": "## Initiative\\n\\nChaque participant lance 1d20...", "Magie et sorts": "## Sorts\\n\\n..."}} - -Règles impératives : -- Tu réponds UNIQUEMENT par cet objet JSON, sans texte avant ni après. -- Les CLÉS sont des titres de section (texte court). Les VALEURS sont le contenu de la règle en markdown (chaîne de caractères, jamais un objet ou une liste). -- INTERDIT : des clés génériques comme "title", "content", "sections", "thought" ou "notes" ; des objets imbriqués ; tout commentaire sur ta démarche ou ton raisonnement. -- 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 {language_name}). -- 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).""" - # --- Mode SEGMENTATION (modèles locaux) -------------------------------------- # Réécrire tout le texte en JSON impose une SORTIE ≈ taille de l'ENTRÉE : à # ~100 tokens/s en local, un livre = des dizaines de minutes et des troncatures @@ -103,25 +67,6 @@ Règles impératives : # qui découpons le texte original. ~50× plus rapide, fidélité parfaite du # contenu (texte source intact), plus de troncature possible. -_SEGMENT_SYSTEM = """Tu analyses un EXTRAIT brut d'un livre de règles de jeu de rôle. -Ta tâche : repérer où COMMENCENT les sections thématiques. Tu ne réécris RIEN. - -Format EXACT attendu : -{{"sections": [{{"titre": "Combat", "debut": "Le combat se déroule en tours de"}}, ...]}} - -Règles impératives : -- "debut" = les 5 à 10 PREMIERS MOTS du passage où la section commence, COPIÉS À L'IDENTIQUE - depuis l'extrait (même orthographe, même ponctuation, même langue). JAMAIS un résumé. -- La PREMIÈRE entrée commence aux tout premiers mots de l'extrait (même si le contenu - poursuit une section entamée avant cet extrait). -- Les entrées suivent l'ordre du texte. Vise des sections LARGES (un thème), pas un titre - par paragraphe : un extrait contient typiquement 1 à 6 sections. -- Titres : EN PRIORITÉ parmi : -{canonical} - sinon un titre court et clair en {language_name}. -- Pages de garde, sommaires, crédits : n'en fais pas des sections. Si l'extrait n'est que ça, - renvoie {{"sections": []}}.""" - # Schéma passé à Ollama (structured outputs) : un objet {"sections": [...]}. # Racine objet (pas tableau) car l'extraction côté Brain repère le premier {…}. _ANCHORS_SCHEMA: dict = { @@ -421,11 +366,11 @@ class ImportRulesUseCase: """Extrait les sections d'un texte. Si la SORTIE est tronquée, retraite le texte en DEUX moitiés (chacune produit une réponse complète) et fusionne — ainsi aucune section n'est perdue, quel que soit le plafond de sortie.""" - system = _SEGMENT_SYSTEM if self._segment_only else _MAP_SYSTEM + system = prompts.SEGMENT_SYSTEM if self._segment_only else prompts.MAP_SYSTEM schema = _ANCHORS_SCHEMA if self._segment_only else _SECTIONS_SCHEMA prompt = ( system.format( - canonical="\n".join(f" - {s}" for s in _CANONICAL_SECTIONS), + canonical="\n".join(f" - {s}" for s in prompts.CANONICAL_SECTIONS), language_name=language_name(language), ) + f"\n\n--- EXTRAIT {index + 1}/{total} ---\n{text}\n\n" diff --git a/brain/app/application/notebook_chat.py b/brain/app/application/notebook_chat.py index b2b27d5..88600ce 100644 --- a/brain/app/application/notebook_chat.py +++ b/brain/app/application/notebook_chat.py @@ -9,91 +9,13 @@ from __future__ import annotations from typing import AsyncIterator from app.application.notebook_rag import NotebookRagUseCase +from app.application.prompts import notebook as prompts from app.application.query_rewrite import standalone_question from app.application.rerank import pool_size, rerank from app.core.language import DEFAULT as _DEFAULT_LANG, language_name from app.domain.models import ChatMessage from app.domain.ports import LLMChatProvider -_SYSTEM_PROMPT = """Tu es un assistant de jeu de rôle qui aide à ADAPTER une source (PDF) à la CAMPAGNE de l'utilisateur. - -Tu disposes de DEUX connaissances, toutes deux ci-dessous : -1) LA CAMPAGNE de l'utilisateur (sa structure arcs/chapitres/scènes, ses PNJ, son univers) ; -2) LA SOURCE (extraits pertinents du PDF). - -Règles : -- Pour une question sur SA CAMPAGNE (ex. « mon chapitre 3 », « mes PNJ »), appuie-toi sur la section CAMPAGNE. -- Pour une question sur le livre, appuie-toi sur les EXTRAITS DE LA SOURCE. -- CROISE les deux pour proposer des adaptations cohérentes avec sa campagne existante. -- N'invente pas ce qui ne figure ni dans la campagne ni dans la source ; si tu ne sais pas, dis-le. -- Quand un extrait porte un numéro de page (« (p. 12) »), cite-le (« d'après la p. 12 »). - -{context_block} ---- EXTRAITS PERTINENTS DE LA SOURCE --- -{sources_block} ---- FIN DES EXTRAITS --- - -PROPOSITIONS D'INTÉGRATION (IMPORTANT) : -Quand l'utilisateur veut CRÉER ou ADAPTER un élément concret pour sa campagne (un PNJ, -une scène, un chapitre, une quête, un arc, une table aléatoire), termine ta réponse par -un ou plusieurs BLOCS D'ACTION — un objet JSON par bloc, dans une clôture -```loremind-action. L'interface les transformera en boutons « Créer dans la campagne ». -Si l'utilisateur demande PLUSIEURS éléments (« propose-moi 3 quêtes »), produis UN bloc -par élément. N'en mets pas si l'utilisateur pose une simple question. - -VOCABULAIRE DE LA CAMPAGNE : une « quête » n'est PAS un type à part — c'est un CHAPITRE -rangé dans un arc de type HUB (quêtes parallèles, sans ordre imposé), tandis qu'un arc -LINEAR contient des chapitres joués en séquence. Donc : -- demande de QUÊTE → action "chapter" (l'utilisateur la placera dans son arc HUB) ; - s'il n'a aucun arc HUB dans sa campagne, propose AUSSI une action "arc" avec - "arcType": "HUB" pour les accueillir. -- demande de CHAPITRE → action "chapter" (destinée plutôt à un arc LINEAR). - -RÈGLE CLÉ : remplis TOUS les champs pour lesquels tu as de la matière — pas seulement -le résumé ou les notes MJ. Chaque champ rempli atterrit au bon endroit de la fiche ; -un champ laissé vide est une fiche que l'utilisateur devra compléter à la main. Vise -2 à 5 phrases concrètes par champ narratif, tirées de la source et de la campagne. -Omets simplement un champ si tu n'as rien de précis à y mettre. Formats acceptés : - -```loremind-action -{{"type": "npc", "name": "Nom", - "description": "Résumé du PNJ (rôle, apparence, motivation).", - "values": {{"": "contenu", "": "contenu"}}}} -``` -(`values` : utilise comme clés les CHAMPS DE LA FICHE PNJ listés dans le contexte -campagne s'ils y figurent — ex. "Histoire", "Apparence" — sinon omets `values`.) - -```loremind-action -{{"type": "scene", "name": "Nom", - "description": "Résumé court de la scène.", - "location": "Lieu précis", "timing": "Quand elle survient", - "atmosphere": "Ambiance sensorielle (sons, odeurs, lumière…)", - "playerNarration": "Texte d'ambiance À LIRE AUX JOUEURS, immersif, à la 2e personne.", - "gmSecretNotes": "Secrets, vérités cachées, notes pour le MJ uniquement.", - "choicesConsequences": "Choix offerts aux joueurs et leurs conséquences.", - "combatDifficulty": "Difficulté du combat éventuel", "enemies": "Ennemis présents (effectifs, tactiques)"}} -``` -```loremind-action -{{"type": "chapter", "name": "Nom", - "description": "Résumé du chapitre (ou de la quête).", - "playerObjectives": "Objectifs tels que les joueurs les perçoivent.", - "narrativeStakes": "Enjeux narratifs (ce qui se joue vraiment).", - "gmNotes": "Notes MJ : fils à tirer, points d'attention."}} -``` -```loremind-action -{{"type": "arc", "name": "Nom", "description": "Résumé", "arcType": "LINEAR", - "themes": "Thèmes de l'arc", "stakes": "Enjeux", - "rewards": "Récompenses attendues", "resolution": "Issues possibles", - "gmNotes": "Notes MJ."}} -``` -(`arcType` : "LINEAR" pour des chapitres en séquence, "HUB" pour un recueil de -quêtes parallèles.) -```loremind-action -{{"type": "table", "name": "Nom", "diceFormula": "1d8", "entries": [{{"minRoll":1,"maxRoll":4,"label":"...","detail":"..."}}]}} -``` - -Réponds en {language_name}, de façon utile et concise. Mets le texte explicatif AVANT les blocs d'action.""" - class NotebookChatUseCase: def __init__( @@ -145,7 +67,7 @@ class NotebookChatUseCase: f"--- TA CAMPAGNE ---\n{context.strip()}\n--- FIN CAMPAGNE ---\n\n" if context.strip() else "--- TA CAMPAGNE ---\n(aucune donnée de campagne)\n--- FIN CAMPAGNE ---\n\n" ) - system_prompt = _SYSTEM_PROMPT.format( + system_prompt = prompts.CHAT_SYSTEM.format( context_block=context_block, sources_block=sources_block, language_name=language_name(language)) async for token in self._llm.stream_chat(messages, system_prompt=system_prompt): diff --git a/brain/app/application/notebook_deep.py b/brain/app/application/notebook_deep.py index 0369b65..eda2453 100644 --- a/brain/app/application/notebook_deep.py +++ b/brain/app/application/notebook_deep.py @@ -20,6 +20,7 @@ from typing import AsyncIterator import tiktoken from app.application.llm_retry import generate_with_retry +from app.application.prompts import notebook as prompts from app.application.query_rewrite import standalone_question from app.core.language import DEFAULT as _DEFAULT_LANG, language_name from app.domain.models import ChatMessage @@ -37,15 +38,6 @@ _MAP_TEMPERATURE = 0.2 # à la question par embedding, et seuls les lots plausiblement pertinents sont # relus. Sélection volontairement CONSERVATRICE (on préfère relire un lot de # trop que rater une mention) ; désactivable via deep_summary_filter=False. -_SUMMARY_PROMPT = """Résume l'EXTRAIT ci-dessous en 4 à 8 puces factuelles : lieux, PNJ et -créatures nommés, objets notables, évènements, règles particulières. Pas d'analyse, pas -d'introduction — uniquement les puces, pour servir d'index de recherche. - ---- EXTRAIT --- -{excerpt} ---- FIN EXTRAIT --- - -Résumé :""" # Un lot est gardé si son score est proche du meilleur (marge) OU bon dans # l'absolu ; et on garde toujours au moins _MIN_KEPT lots. @@ -53,38 +45,6 @@ _SELECT_MARGIN = 0.10 _SELECT_FLOOR = 0.5 _MIN_KEPT = 3 -_MAP_PROMPT = """Voici un EXTRAIT d'un document. Extrais UNIQUEMENT les informations -pertinentes pour répondre à la question ci-dessous. Conserve les détails utiles et -indique les numéros de page (format « p. X »). Si l'extrait ne contient RIEN de -pertinent, réponds EXACTEMENT « {no_match} » et rien d'autre. - -QUESTION : {question} - ---- EXTRAIT --- -{excerpt} ---- FIN EXTRAIT --- - -Informations pertinentes (ou « {no_match} ») :""" - -_REDUCE_SYSTEM = """Tu es l'assistant-MJ d'un jeu de rôle. Tu réponds à la demande du MJ en -t'appuyant sur TROIS sources : (1) des NOTES extraites de l'ENSEMBLE du document source (vue -complète — mais POSSIBLEMENT VIDE si rien d'utile n'y figure), (2) le contexte de sa CAMPAGNE, -(3) la conversation ci-dessous. - -- Si les notes contiennent des éléments utiles : exploite-les et CITE les pages (« p. X »). -- Si les notes sont VIDES ou pauvres (cas fréquent d'une demande CRÉATIVE portant sur des - éléments INVENTÉS par le MJ) : ne te bloque surtout PAS. Aide-le quand même en t'appuyant - sur sa CAMPAGNE, la CONVERSATION et ta connaissance du genre — propose des adaptations - concrètes (arcs, chapitres, scènes, PNJ), structurées et jouables. -- Sois concret et utile. N'affirme rien de FAUX sur le contenu du document. - -{context_block} ---- NOTES EXTRAITES DE TOUT LE DOCUMENT --- -{notes_block} ---- FIN DES NOTES --- - -Réponds en {language_name}.""" - class NotebookDeepUseCase: def __init__( @@ -177,7 +137,7 @@ class NotebookDeepUseCase: f"--- TA CAMPAGNE (structure, PNJ, univers) ---\n{context.strip()}\n--- FIN CAMPAGNE ---\n\n" if context.strip() else "" ) - system_prompt = _REDUCE_SYSTEM.format( + system_prompt = prompts.REDUCE_SYSTEM.format( context_block=context_block, notes_block=notes_block, language_name=language_name(language)) # Historique récent pour la cohérence des relances ; on garantit que le @@ -255,7 +215,7 @@ class NotebookDeepUseCase: async def _summarize_batch(self, batch: list[dict]) -> str: excerpt = "\n\n".join(c.get("text", "").strip() for c in batch) raw = await generate_with_retry( - self._llm, _SUMMARY_PROMPT.format(excerpt=excerpt), temperature=_MAP_TEMPERATURE) + self._llm, prompts.SUMMARY_PROMPT.format(excerpt=excerpt), temperature=_MAP_TEMPERATURE) return (raw or "").strip() async def _map_batch(self, question: str, batch: list[dict]) -> str: @@ -264,7 +224,7 @@ class NotebookDeepUseCase: f"(p. {c['page']}) {c['text'].strip()}" if c.get("page") else c["text"].strip() for c in batch ) - prompt = _MAP_PROMPT.format(no_match=_NO_MATCH, question=question, excerpt=excerpt) + prompt = prompts.MAP_PROMPT.format(no_match=_NO_MATCH, question=question, excerpt=excerpt) raw = await generate_with_retry(self._llm, prompt, temperature=_MAP_TEMPERATURE) answer = raw.strip() if answer and answer.upper().rstrip(".") != _NO_MATCH: diff --git a/brain/app/application/prompts/__init__.py b/brain/app/application/prompts/__init__.py new file mode 100644 index 0000000..2146204 --- /dev/null +++ b/brain/app/application/prompts/__init__.py @@ -0,0 +1,12 @@ +"""Prompts LLM, regroupés hors de la logique des use cases. + +Un prompt est du code (couplé à son schéma de sortie et à son parsing), mais +le mêler à la logique d'orchestration rend les use cases illisibles. Ce package +isole le TEXTE des prompts : un module par domaine fonctionnel, miroir des +modules de `app.application` / des routers. + +Convention : les use cases importent depuis ici et gardent la logique (chunking, +parsing, fusion, schémas de sortie JSON, températures, sentinelles). Les prompts +restent en français (langue de travail) — seule la langue de SORTIE est +paramétrée, cf. `app.core.language`. +""" diff --git a/brain/app/application/prompts/adapt_campaign.py b/brain/app/application/prompts/adapt_campaign.py new file mode 100644 index 0000000..098d561 --- /dev/null +++ b/brain/app/application/prompts/adapt_campaign.py @@ -0,0 +1,26 @@ +"""Prompts des conseils d'adaptation d'un PDF à une campagne (cf. adapt_campaign.py).""" +from app.core.language import language_name + +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." +) + + +def system_suffix(language: str) -> str: + """Consignes de sortie, avec la langue des conseils pilotée par l'utilisateur.""" + return ( + f"Produis des CONSEILS D'ADAPTATION concrets, actionnables et en {language_name(language).upper()}, " + "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." + ) diff --git a/brain/app/application/prompts/chat.py b/brain/app/application/prompts/chat.py new file mode 100644 index 0000000..0aba550 --- /dev/null +++ b/brain/app/application/prompts/chat.py @@ -0,0 +1,23 @@ +"""Prompt système de base du chat contextuel (cf. chat.py). + +Les blocs de contexte (Lore, page, campagne, session…) sont sérialisés par les +méthodes `_format_*` du use case ; seul le SYSTEM de base vit ici. +""" +from app.core.language import language_name + + +def base_system(language: str) -> str: + """System prompt de base, avec la langue de réponse pilotée par l'utilisateur.""" + return f"""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 {language_name(language)}, 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").""" diff --git a/brain/app/application/prompts/conversation_title.py b/brain/app/application/prompts/conversation_title.py new file mode 100644 index 0000000..82abd60 --- /dev/null +++ b/brain/app/application/prompts/conversation_title.py @@ -0,0 +1,15 @@ +"""Prompt & repli de l'auto-titre de conversation (cf. router generation.py).""" +from app.core.language import language_name + +# Titre de repli (LLM injoignable / réponse vide), localisé selon la langue UI. +TITLE_FALLBACK = {"fr": "Nouvelle conversation", "en": "New conversation"} + + +def title_system_prompt(language: str) -> str: + """Consigne d'auto-titre, avec la langue du titre pilotée par l'utilisateur.""" + return ( + "Tu generes un titre court (4 a 7 mots max) qui resume le sujet de la " + "conversation ci-dessous. Reponds UNIQUEMENT par le titre, sans guillemets, " + "sans ponctuation finale, sans prefixe type 'Titre :'. Le titre doit etre " + f"en {language_name(language)} et capturer le sujet metier (pas 'Conversation IA')." + ) diff --git a/brain/app/application/prompts/generate_page.py b/brain/app/application/prompts/generate_page.py new file mode 100644 index 0000000..b605776 --- /dev/null +++ b/brain/app/application/prompts/generate_page.py @@ -0,0 +1,19 @@ +"""Consignes système de la génération de page (cf. generate_page.py).""" +from app.core.language import language_name + + +def system_instructions(language: str) -> str: + """Consignes système, avec la langue des valeurs générées pilotée par l'utilisateur.""" + return f"""Tu es un assistant d'écriture pour un Maître de Jeu de JDR. +Tu vas générer le contenu d'une page appartenant à un univers fictionnel. + +Règles impératives de ta réponse : +- Tu réponds UNIQUEMENT par un objet JSON valide. +- Les clés du JSON correspondent EXACTEMENT aux noms de champs demandés. +- Les valeurs sont des chaînes de texte en {language_name(language)}, riches et évocatrices. +- Aucun markdown, aucune explication, aucun commentaire autour du JSON. + +Règles de cohérence (IMPORTANT) : +- Tu PEUX inventer des détails originaux pour CETTE page : apparence, traits de caractère, anecdotes, histoire personnelle. +- Tu ne dois PAS faire référence à d'autres personnages, lieux, organisations ou événements comme s'ils existaient déjà dans l'univers, sauf si le contexte ci-dessous les mentionne explicitement. +- Si un champ appelle une précision externe (date, nom d'un roi, ville voisine, guerre passée), reste volontairement vague : "il y a de nombreuses années", "un bourg voisin", "une époque troublée". Le MJ préfère combler lui-même les blancs plutôt que trouver des faits inventés contradictoires avec son univers.""" diff --git a/brain/app/application/prompts/import_campaign.py b/brain/app/application/prompts/import_campaign.py new file mode 100644 index 0000000..09e3fe5 --- /dev/null +++ b/brain/app/application/prompts/import_campaign.py @@ -0,0 +1,100 @@ +"""Prompts de l'import de campagne PDF (cf. import_campaign.py).""" + +# Nom de l'arc unique quand le livre n'est pas découpé en actes/parties. +DEFAULT_ARC_NAME = "Aventure principale" + +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 []. + +PNJ ET CRÉATURES NOTABLES ("npcs", tableau au niveau racine) : +- Recense les PNJ NOMMÉS (alliés, marchands, antagonistes) et les créatures UNIQUES + (boss, monstre récurrent) présents dans l'extrait. +- `description` = courte fiche utile au MJ : rôle dans l'histoire, apparence, + motivations, où on le rencontre. 2 à 4 phrases, fidèles au livre. +- N'inclus PAS les monstres génériques sans nom (« 3 gobelins », « un loup »). +- Aucun PNJ nommé dans l'extrait → "npcs": []. + +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": "..."}}]}} + ]}}]}} + ], + "npcs": [{{"name": "...", "description": "..."}}]}} +- 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": []}}.""" + +# Bloc TOC injecté quand le PDF a des bookmarks : les morceaux étant traités +# séparément, c'est CE référentiel commun qui garantit que tous nomment les +# mêmes chapitres à l'identique → la fusion par nom du _TreeMerger recolle +# les chapitres coupés au lieu de créer des doublons. +TOC_BLOCK = """ + +--- STRUCTURE OFFICIELLE DU LIVRE (table des matières du PDF) --- +{toc} +--- FIN DE LA STRUCTURE --- +IMPORTANT : pour nommer les arcs et chapitres, reprends EXACTEMENT les titres +de cette structure (caractère pour caractère). Rattache le contenu de l'extrait +au bon chapitre de la structure, même si son titre n'apparaît pas dans l'extrait.""" + +# Consolidation finale : le squelette (noms seuls) est minuscule, donc l'appel +# est quasi gratuit comparé aux MAP. Température 0 et consigne CONSERVATRICE : +# ne fusionner que les doublons évidents, jamais des entités distinctes. +CONSOLIDATE_PROMPT = """Voici le squelette d'une arborescence arc → chapitre → scène issue d'une +fusion AUTOMATIQUE de morceaux d'un livre de campagne de jeu de rôle. La fusion par nom exact +peut avoir laissé des QUASI-DOUBLONS : le même chapitre ou la même scène sous deux libellés +légèrement différents (ex: "La Crypte" et "Crypte de Karrak", "3. Salle des gardes" et +"Salle des gardes"). + +{skeleton} + +Identifie UNIQUEMENT les fusions ÉVIDENTES (même entité du livre sous deux noms). Sois +CONSERVATEUR : dans le doute, ne fusionne PAS. Deux lieux/évènements distincts ne doivent +JAMAIS être fusionnés. + +Réponds UNIQUEMENT par un objet JSON valide : +{{"chapter_merges": [{{"into": "nom du chapitre à garder", "merge": ["nom à fusionner", ...]}}], + "scene_merges": [{{"chapter": "nom du chapitre", "into": "nom de la scène à garder", + "merge": ["nom à fusionner", ...]}}]}} +S'il n'y a RIEN à fusionner (cas le plus fréquent) : {{"chapter_merges": [], "scene_merges": []}}""" diff --git a/brain/app/application/prompts/import_rules.py b/brain/app/application/prompts/import_rules.py new file mode 100644 index 0000000..ae47609 --- /dev/null +++ b/brain/app/application/prompts/import_rules.py @@ -0,0 +1,62 @@ +"""Prompts de l'import de règles PDF (cf. import_rules.py). + +Deux modes : MAP_SYSTEM (cloud, réécrit le contenu en sections markdown) et +SEGMENT_SYSTEM (local, ne renvoie que les frontières des sections). Les deux +templates attendent `.format(canonical=..., language_name=...)`. +""" + +# 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. + +Format EXACT attendu — un objet JSON plat {{titre de section: contenu markdown}} : +{{"Combat": "## Initiative\\n\\nChaque participant lance 1d20...", "Magie et sorts": "## Sorts\\n\\n..."}} + +Règles impératives : +- Tu réponds UNIQUEMENT par cet objet JSON, sans texte avant ni après. +- Les CLÉS sont des titres de section (texte court). Les VALEURS sont le contenu de la règle en markdown (chaîne de caractères, jamais un objet ou une liste). +- INTERDIT : des clés génériques comme "title", "content", "sections", "thought" ou "notes" ; des objets imbriqués ; tout commentaire sur ta démarche ou ton raisonnement. +- 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 {language_name}). +- 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).""" + +SEGMENT_SYSTEM = """Tu analyses un EXTRAIT brut d'un livre de règles de jeu de rôle. +Ta tâche : repérer où COMMENCENT les sections thématiques. Tu ne réécris RIEN. + +Format EXACT attendu : +{{"sections": [{{"titre": "Combat", "debut": "Le combat se déroule en tours de"}}, ...]}} + +Règles impératives : +- "debut" = les 5 à 10 PREMIERS MOTS du passage où la section commence, COPIÉS À L'IDENTIQUE + depuis l'extrait (même orthographe, même ponctuation, même langue). JAMAIS un résumé. +- La PREMIÈRE entrée commence aux tout premiers mots de l'extrait (même si le contenu + poursuit une section entamée avant cet extrait). +- Les entrées suivent l'ordre du texte. Vise des sections LARGES (un thème), pas un titre + par paragraphe : un extrait contient typiquement 1 à 6 sections. +- Titres : EN PRIORITÉ parmi : +{canonical} + sinon un titre court et clair en {language_name}. +- Pages de garde, sommaires, crédits : n'en fais pas des sections. Si l'extrait n'est que ça, + renvoie {{"sections": []}}.""" diff --git a/brain/app/application/prompts/notebook.py b/brain/app/application/prompts/notebook.py new file mode 100644 index 0000000..23092bc --- /dev/null +++ b/brain/app/application/prompts/notebook.py @@ -0,0 +1,134 @@ +"""Prompts des notebooks (atelier RAG) : chat ancré (cf. notebook_chat.py) et +analyse approfondie map-reduce (cf. notebook_deep.py). + +CHAT_SYSTEM attend `.format(context_block=..., sources_block=..., language_name=...)`. +REDUCE_SYSTEM attend `.format(context_block=..., notes_block=..., language_name=...)`. +MAP_PROMPT attend `.format(no_match=..., question=..., excerpt=...)`. +SUMMARY_PROMPT attend `.format(excerpt=...)`. +""" + +# --- Chat ancré (RAG) -------------------------------------------------------- + +CHAT_SYSTEM = """Tu es un assistant de jeu de rôle qui aide à ADAPTER une source (PDF) à la CAMPAGNE de l'utilisateur. + +Tu disposes de DEUX connaissances, toutes deux ci-dessous : +1) LA CAMPAGNE de l'utilisateur (sa structure arcs/chapitres/scènes, ses PNJ, son univers) ; +2) LA SOURCE (extraits pertinents du PDF). + +Règles : +- Pour une question sur SA CAMPAGNE (ex. « mon chapitre 3 », « mes PNJ »), appuie-toi sur la section CAMPAGNE. +- Pour une question sur le livre, appuie-toi sur les EXTRAITS DE LA SOURCE. +- CROISE les deux pour proposer des adaptations cohérentes avec sa campagne existante. +- N'invente pas ce qui ne figure ni dans la campagne ni dans la source ; si tu ne sais pas, dis-le. +- Quand un extrait porte un numéro de page (« (p. 12) »), cite-le (« d'après la p. 12 »). + +{context_block} +--- EXTRAITS PERTINENTS DE LA SOURCE --- +{sources_block} +--- FIN DES EXTRAITS --- + +PROPOSITIONS D'INTÉGRATION (IMPORTANT) : +Quand l'utilisateur veut CRÉER ou ADAPTER un élément concret pour sa campagne (un PNJ, +une scène, un chapitre, une quête, un arc, une table aléatoire), termine ta réponse par +un ou plusieurs BLOCS D'ACTION — un objet JSON par bloc, dans une clôture +```loremind-action. L'interface les transformera en boutons « Créer dans la campagne ». +Si l'utilisateur demande PLUSIEURS éléments (« propose-moi 3 quêtes »), produis UN bloc +par élément. N'en mets pas si l'utilisateur pose une simple question. + +VOCABULAIRE DE LA CAMPAGNE : une « quête » n'est PAS un type à part — c'est un CHAPITRE +rangé dans un arc de type HUB (quêtes parallèles, sans ordre imposé), tandis qu'un arc +LINEAR contient des chapitres joués en séquence. Donc : +- demande de QUÊTE → action "chapter" (l'utilisateur la placera dans son arc HUB) ; + s'il n'a aucun arc HUB dans sa campagne, propose AUSSI une action "arc" avec + "arcType": "HUB" pour les accueillir. +- demande de CHAPITRE → action "chapter" (destinée plutôt à un arc LINEAR). + +RÈGLE CLÉ : remplis TOUS les champs pour lesquels tu as de la matière — pas seulement +le résumé ou les notes MJ. Chaque champ rempli atterrit au bon endroit de la fiche ; +un champ laissé vide est une fiche que l'utilisateur devra compléter à la main. Vise +2 à 5 phrases concrètes par champ narratif, tirées de la source et de la campagne. +Omets simplement un champ si tu n'as rien de précis à y mettre. Formats acceptés : + +```loremind-action +{{"type": "npc", "name": "Nom", + "description": "Résumé du PNJ (rôle, apparence, motivation).", + "values": {{"": "contenu", "": "contenu"}}}} +``` +(`values` : utilise comme clés les CHAMPS DE LA FICHE PNJ listés dans le contexte +campagne s'ils y figurent — ex. "Histoire", "Apparence" — sinon omets `values`.) + +```loremind-action +{{"type": "scene", "name": "Nom", + "description": "Résumé court de la scène.", + "location": "Lieu précis", "timing": "Quand elle survient", + "atmosphere": "Ambiance sensorielle (sons, odeurs, lumière…)", + "playerNarration": "Texte d'ambiance À LIRE AUX JOUEURS, immersif, à la 2e personne.", + "gmSecretNotes": "Secrets, vérités cachées, notes pour le MJ uniquement.", + "choicesConsequences": "Choix offerts aux joueurs et leurs conséquences.", + "combatDifficulty": "Difficulté du combat éventuel", "enemies": "Ennemis présents (effectifs, tactiques)"}} +``` +```loremind-action +{{"type": "chapter", "name": "Nom", + "description": "Résumé du chapitre (ou de la quête).", + "playerObjectives": "Objectifs tels que les joueurs les perçoivent.", + "narrativeStakes": "Enjeux narratifs (ce qui se joue vraiment).", + "gmNotes": "Notes MJ : fils à tirer, points d'attention."}} +``` +```loremind-action +{{"type": "arc", "name": "Nom", "description": "Résumé", "arcType": "LINEAR", + "themes": "Thèmes de l'arc", "stakes": "Enjeux", + "rewards": "Récompenses attendues", "resolution": "Issues possibles", + "gmNotes": "Notes MJ."}} +``` +(`arcType` : "LINEAR" pour des chapitres en séquence, "HUB" pour un recueil de +quêtes parallèles.) +```loremind-action +{{"type": "table", "name": "Nom", "diceFormula": "1d8", "entries": [{{"minRoll":1,"maxRoll":4,"label":"...","detail":"..."}}]}} +``` + +Réponds en {language_name}, de façon utile et concise. Mets le texte explicatif AVANT les blocs d'action.""" + + +# --- Analyse approfondie (map-reduce) ---------------------------------------- + +SUMMARY_PROMPT = """Résume l'EXTRAIT ci-dessous en 4 à 8 puces factuelles : lieux, PNJ et +créatures nommés, objets notables, évènements, règles particulières. Pas d'analyse, pas +d'introduction — uniquement les puces, pour servir d'index de recherche. + +--- EXTRAIT --- +{excerpt} +--- FIN EXTRAIT --- + +Résumé :""" + +MAP_PROMPT = """Voici un EXTRAIT d'un document. Extrais UNIQUEMENT les informations +pertinentes pour répondre à la question ci-dessous. Conserve les détails utiles et +indique les numéros de page (format « p. X »). Si l'extrait ne contient RIEN de +pertinent, réponds EXACTEMENT « {no_match} » et rien d'autre. + +QUESTION : {question} + +--- EXTRAIT --- +{excerpt} +--- FIN EXTRAIT --- + +Informations pertinentes (ou « {no_match} ») :""" + +REDUCE_SYSTEM = """Tu es l'assistant-MJ d'un jeu de rôle. Tu réponds à la demande du MJ en +t'appuyant sur TROIS sources : (1) des NOTES extraites de l'ENSEMBLE du document source (vue +complète — mais POSSIBLEMENT VIDE si rien d'utile n'y figure), (2) le contexte de sa CAMPAGNE, +(3) la conversation ci-dessous. + +- Si les notes contiennent des éléments utiles : exploite-les et CITE les pages (« p. X »). +- Si les notes sont VIDES ou pauvres (cas fréquent d'une demande CRÉATIVE portant sur des + éléments INVENTÉS par le MJ) : ne te bloque surtout PAS. Aide-le quand même en t'appuyant + sur sa CAMPAGNE, la CONVERSATION et ta connaissance du genre — propose des adaptations + concrètes (arcs, chapitres, scènes, PNJ), structurées et jouables. +- Sois concret et utile. N'affirme rien de FAUX sur le contenu du document. + +{context_block} +--- NOTES EXTRAITES DE TOUT LE DOCUMENT --- +{notes_block} +--- FIN DES NOTES --- + +Réponds en {language_name}.""" diff --git a/brain/app/application/prompts/query_rewrite.py b/brain/app/application/prompts/query_rewrite.py new file mode 100644 index 0000000..d282648 --- /dev/null +++ b/brain/app/application/prompts/query_rewrite.py @@ -0,0 +1,20 @@ +"""Prompt de réécriture en question autonome (cf. query_rewrite.py). + +Attend `.format(conversation=...)`. +""" + +REWRITE_PROMPT = """Voici la fin d'une conversation entre un Maître de Jeu et son assistant. +Réécris le DERNIER message de l'utilisateur en une question AUTONOME et complète : +remplace les pronoms et références implicites (« il », « ses », « ce lieu », « et pour +les autres ? ») par ce qu'ils désignent dans la conversation. + +Règles : +- Réponds UNIQUEMENT par la question réécrite, sans guillemets ni préfixe. +- Conserve la langue et l'intention d'origine. N'ajoute RIEN qui n'est pas demandé. +- Si le dernier message est déjà autonome, recopie-le tel quel. + +--- CONVERSATION --- +{conversation} +--- FIN --- + +Question autonome :""" diff --git a/brain/app/application/prompts/rerank.py b/brain/app/application/prompts/rerank.py new file mode 100644 index 0000000..d031465 --- /dev/null +++ b/brain/app/application/prompts/rerank.py @@ -0,0 +1,14 @@ +"""Prompt de reranking LLM des passages RAG (cf. rerank.py). + +Attend `.format(question=..., passages=..., count=...)`. +""" + +RERANK_PROMPT = """Tu évalues la PERTINENCE d'extraits d'un document pour répondre à une question. +Note chaque extrait de 0 (sans rapport) à 10 (répond directement), indépendamment des autres. + +QUESTION : {question} + +{passages} + +Réponds UNIQUEMENT par un objet JSON : {{"scores": [note_extrait_1, note_extrait_2, ...]}} +Le tableau doit contenir EXACTEMENT {count} notes, dans l'ordre des extraits.""" diff --git a/brain/app/application/prompts/tables.py b/brain/app/application/prompts/tables.py new file mode 100644 index 0000000..41bd18c --- /dev/null +++ b/brain/app/application/prompts/tables.py @@ -0,0 +1,60 @@ +"""Prompts des outils de table (tables aléatoires, improvisation, catalogues). + +Ces prompts étaient auparavant construits en ligne dans le router `tables.py` ; +isolés ici pour garder la frontière HTTP fine. Le router calcule les plages de +dés et passe les champs bruts ; ces fonctions façonnent le texte. +""" +from app.core.language import language_name + + +def random_table_prompt(description: str, dice_formula: str, lo: int, hi: int, + context: str, language: str) -> str: + """Prompt de génération d'une table aléatoire couvrant lo..hi.""" + context_block = f"\nContexte de la campagne :\n{context.strip()}\n" if context.strip() else "" + return ( + "Tu es un assistant de jeu de rôle. Génère une TABLE ALÉATOIRE évocatrice.\n" + f"Dé : {dice_formula} (résultats possibles de {lo} à {hi}).\n" + f"Sujet : {description.strip()}\n" + f"{context_block}\n" + "Règles IMPÉRATIVES :\n" + "- Réponds UNIQUEMENT par un objet JSON valide, sans texte autour.\n" + '- Format : {"name": "...", "description": "...", "entries": ' + '[{"min_roll": N, "max_roll": M, "label": "résultat court", "detail": "1-2 phrases"}]}\n' + f"- Les plages (min_roll..max_roll) doivent COUVRIR EXACTEMENT {lo}..{hi}, " + "sans trou ni chevauchement, dans l'ordre croissant.\n" + "- Des résultats variés, cohérents avec le sujet (et le contexte s'il est fourni).\n" + f"- En {language_name(language)}. 'label' = résultat bref ; 'detail' = description/effet concret.\n" + "Renvoie maintenant le JSON." + ) + + +def improvise_roll_prompt(table_name: str, result_label: str, result_detail: str, + context: str, language: str) -> str: + """Prompt de narration brodée sur un résultat tiré.""" + detail = f" ({result_detail.strip()})" if result_detail.strip() else "" + context_block = f"\nContexte : {context.strip()}" if context.strip() else "" + return ( + "Tu es le Maître du Jeu. Les joueurs viennent de tirer sur la table " + f"« {table_name.strip()} » et ont obtenu : « {result_label.strip()} »{detail}." + f"{context_block}\n\n" + "Décris en 2-3 phrases vivantes et immédiates ce qui se passe, pour lancer la scène. " + f"Pas de méta, pas d'options : juste la narration, en {language_name(language)}." + ) + + +def item_catalog_prompt(description: str, context: str, language: str) -> str: + """Prompt de génération d'un catalogue d'objets (boutique, butin…).""" + context_block = f"\nContexte de la campagne :\n{context.strip()}\n" if context.strip() else "" + return ( + "Tu es un assistant de jeu de rôle. Génère un CATALOGUE D'OBJETS (boutique, butin, trésor…).\n" + f"Sujet : {description.strip()}\n" + f"{context_block}\n" + "Règles IMPÉRATIVES :\n" + "- Réponds UNIQUEMENT par un objet JSON valide, sans texte autour.\n" + '- Format : {"name": "...", "description": "...", "items": ' + '[{"name": "Objet", "price": "ex. 50 po", "category": "ex. Armes", "description": "effet/détails"}]}\n' + "- Des objets variés et cohérents avec le sujet (et le contexte s'il est fourni).\n" + "- 'price' = prix court dans la monnaie du jeu ; 'category' = regroupement (Armes, Potions…) ; " + f"'description' = effet/détails en une phrase. En {language_name(language)}.\n" + "Renvoie maintenant le JSON." + ) diff --git a/brain/app/application/query_rewrite.py b/brain/app/application/query_rewrite.py index 2107169..438e55c 100644 --- a/brain/app/application/query_rewrite.py +++ b/brain/app/application/query_rewrite.py @@ -11,6 +11,7 @@ from __future__ import annotations import logging +from app.application.prompts import query_rewrite as prompts from app.domain.models import ChatMessage logger = logging.getLogger(__name__) @@ -23,22 +24,6 @@ _MAX_HISTORY = 6 # modèle a divagué) → on retombe sur la question brute. _MAX_REWRITE_CHARS = 400 -_REWRITE_PROMPT = """Voici la fin d'une conversation entre un Maître de Jeu et son assistant. -Réécris le DERNIER message de l'utilisateur en une question AUTONOME et complète : -remplace les pronoms et références implicites (« il », « ses », « ce lieu », « et pour -les autres ? ») par ce qu'ils désignent dans la conversation. - -Règles : -- Réponds UNIQUEMENT par la question réécrite, sans guillemets ni préfixe. -- Conserve la langue et l'intention d'origine. N'ajoute RIEN qui n'est pas demandé. -- Si le dernier message est déjà autonome, recopie-le tel quel. - ---- CONVERSATION --- -{conversation} ---- FIN --- - -Question autonome :""" - async def standalone_question(llm, messages: list[ChatMessage]) -> str: """Condense `messages` en une question autonome pour la RECHERCHE. @@ -56,7 +41,7 @@ async def standalone_question(llm, messages: list[ChatMessage]) -> str: conversation = "\n".join(f"{m.role.upper()}: {m.content.strip()}" for m in recent) try: raw = await llm.generate( - _REWRITE_PROMPT.format(conversation=conversation), temperature=0.0) + prompts.REWRITE_PROMPT.format(conversation=conversation), temperature=0.0) except Exception as exc: # noqa: BLE001 — la recherche dégradée vaut mieux que pas de réponse logger.warning("Réécriture de question ignorée (échec LLM) : %s", exc) return last_user diff --git a/brain/app/application/rerank.py b/brain/app/application/rerank.py index c7e6147..46e94a3 100644 --- a/brain/app/application/rerank.py +++ b/brain/app/application/rerank.py @@ -11,6 +11,7 @@ from __future__ import annotations import logging from app.application.llm_json import load_json_object +from app.application.prompts import rerank as prompts logger = logging.getLogger(__name__) @@ -23,16 +24,6 @@ POOL_MAX = 24 # prompt sans changer le jugement de pertinence. _EXCERPT_CHARS = 600 -_RERANK_PROMPT = """Tu évalues la PERTINENCE d'extraits d'un document pour répondre à une question. -Note chaque extrait de 0 (sans rapport) à 10 (répond directement), indépendamment des autres. - -QUESTION : {question} - -{passages} - -Réponds UNIQUEMENT par un objet JSON : {{"scores": [note_extrait_1, note_extrait_2, ...]}} -Le tableau doit contenir EXACTEMENT {count} notes, dans l'ordre des extraits.""" - def pool_size(top_k: int) -> int: """Taille du pool à récupérer avant reranking.""" @@ -52,7 +43,7 @@ async def rerank(llm, question: str, passages: list[dict], top_k: int) -> list[d f"--- EXTRAIT {i + 1} ---\n{(p.get('text') or '')[:_EXCERPT_CHARS]}" for i, p in enumerate(passages) ) - prompt = _RERANK_PROMPT.format( + prompt = prompts.RERANK_PROMPT.format( question=question, passages=numbered, count=len(passages)) try: raw = await llm.generate(prompt, temperature=0.0)