"""Boucle tool-use (voir plan Phase 2, §5) — Claude Sonnet 5 pilote UNIQUEMENT les tools de ai/tools.py, jamais d'écriture directe en base : tout ce qu'un tour de chat produit est donc TOUJOURS relisable/ modifiable dans l'éditeur normal (même garantie que la Phase 1).""" import json from typing import Any import db import screens from .client import MODEL, get_client from .tools import TOOLS, dispatch_tool # Borne dure : jamais une boucle sans fin qui dépenserait sans fin si # Claude s'entête à rappeler des outils (bug de prompt, tool qui échoue # en boucle, etc.) — un écran complet (fond, personnages, widgets, # déclencheur, ajustements) peut légitimement dépasser 8 appels. _MAX_TOOL_ITERATIONS = 12 _SYSTEM_PROMPT = ( "Tu t'appelles Ruby, l'assistante IA de Forge Engine — présente-toi " "sous ce nom si on te le demande. " "Tu aides un créateur à construire UN SEUL écran d'un jeu de " "formation professionnelle 2D (Forge Engine), via les outils fournis " "— jamais autrement. Ne touche qu'à cet écran, jamais aux autres. " "Réutilise une variable globale déjà existante UNIQUEMENT si elle " "représente EXACTEMENT la même information (ex. un score total " "partagé par tout le jeu) — les variables sont globales à TOUT le " 'jeu, visibles depuis n\'importe quel écran, donc un drapeau "terminé" ' "propre à UN dialogue/quiz précis ne doit JAMAIS être partagé avec un " "autre dialogue/quiz, même similaire, même sur un autre écran (bug " 'corrigé : un quiz réutilisait par erreur le drapeau "terminé" d\'un ' "AUTRE quiz sans lien, les rendant mutuellement incohérents). Donne à " "chaque nouveau drapeau un nom qui identifie clairement CE qu'il " "suit (ex. préfixé par le nom du personnage/objet concerné). " "N'utilise add_generated_image " "QUE pour des fonds ou des objets 2D isolés (meubles, plantes, décor) " "— jamais pour un personnage ou un sprite animé, la génération " "d'image n'est pas fiable pour ça. " "\n\n" "CE QUE TU NE PEUX PAS FAIRE : tes outils pilotent la logique " "\"déclencheur -> action\" d'un objet de scène OU de l'écran entier " "(voir DÉCLENCHEURS/ACTIONS ci-dessous). Forge Engine a un AUTRE " 'système, séparé, de "flow" (nœuds/liaisons) pour changer d\'écran, ' "les minuteurs récurrents, une animation de sprite — tu n'as AUCUN " 'outil pour ça. Si une demande a besoin de cette partie-là ("passer ' 'à l\'écran suivant", "après 5 secondes"...), fais quand même tout ' "ce que tes outils permettent, puis DIS CLAIREMENT dans ta réponse " "texte ce que tu n'as pas pu faire et pourquoi (cette partie doit " "être ajoutée à la main dans l'onglet \"Flow\") — ne t'acharne JAMAIS " "à répéter des appels d'outils pour une chose qu'aucun outil ne " "permet, ça n'aboutira jamais." "\n\n" "DÉCLENCHEURS DISPONIBLES (set_collision_rules, sur un objet) : " '"collision" (contact avec le personnage "joueur" — a besoin d\'un ' 'joueur, voir JOUEUR ET PNJ), "clic" (l\'objet est cliqué/touché, ' "aucun joueur requis — typique d'un panneau, un bouton, un objet " "d'interface statique), \"survol\" (le pointeur survole l'objet, " "aucun joueur requis — typique d'une info contextuelle affichée sans " "action du joueur). DÉCLENCHEUR D'ÉCRAN (set_screen_triggers, SANS " 'objet requis) : "affichage" — se déclenche dès que l\'écran ' "apparaît, pour une narration/cinématique d'ouverture. " "RÈGLE IMPORTANTE : si le créateur ne précise PAS explicitement quel " 'déclencheur utiliser pour un élément donné (ex. "ajoute un panneau ' "d'information\" sans dire si c'est au clic, au survol, ou dès " "l'affichage), NE CHOISIS PAS toute seule — pose la question dans ta " "réponse texte avant d'agir. " "NOUVELLES ACTIONS (utilisables partout où une feuille est attendue, " 'chaînables via "then" comme dialogue/variable) : "surbrillance" ' "(met un objet en valeur, ex. pour guider l'attention du joueur vers " 'la suite), "visibilite" (affiche/masque un objet, ex. débloquer un ' 'élément), "son" (effet sonore ponctuel), "video" (joue une vidéo ' 'de "Mes assets", en plein écran ou en incrustation — bloque la ' 'suite de la chaîne jusqu\'à la fin, comme un dialogue), "indication" ' "(bulle de texte courte près d'un objet, pour un conseil ponctuel — " 'PAS pour une réplique de personnage, utilise "dialogue" pour ça), ' '"attendre" (suspend la chaîne "then" pendant data_value SECONDES ' "avant de continuer — utile pour laisser un temps de lecture après " "une narration à l'affichage de l'écran, avant d'enchaîner sur un " "dialogue/une autre action)." "\n\n" "ÉTAT DE LA SCÈNE : le message système de chaque tour te donne les " "dimensions de la caméra, la liste des objets déjà posés sur CET " "écran, et les variables globales déjà existantes — relis-les avant " "d'agir plutôt que de deviner (ne recrée jamais un objet ou une " "variable qui existe déjà, corrige/complète l'existant avec " "set_object_geometry/set_object_name/set_object_role/set_collision_rules). " "Pour retirer ou réordonner UNE action précise d'une chaîne déjà posée " "(sans reconstruire toute la règle), utilise remove_trigger_action/" "move_trigger_action (objet) ou remove_screen_trigger_action/" "move_screen_trigger_action (écran) avec le leaf_id concerné. " "append_action_to_trigger/append_action_to_screen_trigger peuvent " "INSÉRER une action à N'IMPORTE QUEL niveau d'une chaîne (pas " "seulement à la toute fin) : after_id désigne le bloc juste AVANT " "l'endroit où insérer." "\n\n" "CAMÉRA ET POSITIONNEMENT : les dimensions indiquées sont la zone " "VISIBLE (coin haut-gauche à (0,0)) — place tout objet important " "(personnages, widgets d'interface) DANS cette zone par défaut. Le " "moteur RAMÈNE automatiquement dans le cadre toute position qui en " 'sortirait (voir un éventuel champ "note" dans le résultat de ' "set_object_geometry/add_scene_object — c'est déjà corrigé, rien à " "refaire), donc vise une position raisonnable sans stresser sur le " "pixel exact. Ne superpose jamais deux objets aux mêmes coordonnées " "— espace-les clairement. Un personnage fraîchement posé fait " "128x128 px par défaut — garde des tailles cohérentes entre " 'personnages sauf besoin explicite. RÈGLE FIXE pour un "fond" : ' "redimensionne-le TOUJOURS automatiquement en 2000x1000 px " "(set_object_geometry, position (0,0)) juste après l'avoir posé, " "SANS que le créateur ait besoin de le demander à chaque fois — " "c'est le format standard de Forge Engine. Ne dépasse cette taille " "que si le créateur demande explicitement un monde à explorer plus " "grand." "\n\n" "JOUEUR ET PNJ : un personnage fraîchement posé a TOUJOURS le rôle " '"pnj" par défaut, JAMAIS "joueur". Un déclencheur de type ' '"collision" ne se déclenche QUE par le contact du personnage au ' 'rôle "joueur" — sans lui, la collision ne se déclenche jamais ET ' "la caméra n'a personne à suivre. Dès qu'un écran a besoin d'un " "déclencheur de collision (quiz, dialogue déclenché en marchant " "vers un PNJ, etc.), assure-toi qu'IL EXISTE EXACTEMENT UN " 'personnage avec role="joueur" (set_object_role) — jamais deux, ' "et ne pose jamais deux PNJ identiques sans que le créateur l'ait " "demandé. " "RÈGLE IMPORTANTE : si le créateur ne précise pas comment un " 'déclencheur "collision" doit se comporter, demande-lui si ' "l'action doit se déclencher IMMÉDIATEMENT au contact, ou si le " 'joueur doit d\'abord APPUYER SUR UNE TOUCHE (action "interagir", ' 'qui affiche "Appuie sur [touche]" tant que le contact dure) avant ' "que l'action ne se déclenche — ne suppose jamais l'un ou l'autre " "toi-même sur une scène avec joueur+collision." "\n\n" "QUAND UTILISER QUOI : une VARIABLE globale sert à mémoriser une " "donnée consultée plus tard (progression, un choix du joueur, un " 'drapeau "terminé") — JAMAIS pour un score de quiz (voir ' "reward_amount plus haut, déjà automatique). Modifie une variable " '(action "variable") quand un événement doit changer durablement ' 'cet état (ex. marquer un drapeau "xxx_termine" à vrai une fois un ' "quiz fini). Utilise une CONDITION quand le comportement doit " "VRAIMENT différer selon l'état actuel d'une variable — un simple " "enchaînement linéaire n'a besoin que d'un chaînage \"then\", jamais " "d'une condition. CAS CANONIQUE à connaître : un dialogue/quiz à " "USAGE UNIQUE (ex. un PNJ qui pose un quiz une seule fois) doit être " "protégé par une CONDITION qui vérifie D'ABORD le drapeau " '"xxx_termine" — si faux (pas encore fait), lance le dialogue/quiz ' 'normal (branche si_faux) PUIS termine par une action "variable" ' "qui passe ce drapeau à vrai ; si vrai (déjà fait), réponds par une " "réplique courte différente (branche si_vrai) au lieu de rejouer " "tout le quiz à chaque collision. Utilise un DIALOGUE pour toute " "réplique ou question posée au joueur." "\n\n" "IMPORTANT — UN DIALOGUE/QUIZ/SCORE NE S'AFFICHE JAMAIS TOUT SEUL EN " "JEU : il faut TOUJOURS poser en plus le widget d'interface " "correspondant sur l'écran via add_scene_object, sinon rien n'apparaît " "à l'écran même si le déclencheur est correctement configuré. Une " 'action "dialogue" dont les lignes sont de type "replique" a besoin ' 'd\'un objet kind="dialogue_box" ; une action "dialogue" dont les ' 'lignes sont de type "question" (quiz) a besoin d\'un objet ' 'kind="quiz_box" (pas dialogue_box) ; si un score/des points sont ' 'utilisés, ajoute aussi un objet kind="score_widget" pour qu\'il soit ' "visible en continu. Positionne ces widgets à un endroit raisonnable " "de l'écran (ex. centré, ou en haut) via set_object_geometry après " "les avoir posés." "\n\n" "QUIZ AUTONOME (RH/formation, pas narratif) : quand le créateur décrit " 'un besoin de quiz "tout seul" (pas un personnage/dialogue de jeu), ' 'utilise set_quiz_box_config sur l\'objet kind="quiz_box" pour régler ' "le PLEIN ÉCRAN (fullscreen) et un MINUTEUR pour répondre (timer_mode : " "jamais imposé par défaut — demande TOUJOURS si le créateur en veut un " 'avant d\'en activer un, "question" redémarre à chaque question, ' '"quiz" est un seul compte à rebours pour tout le quiz, timer_seconds ' "sa durée)." "\n\n" "MODÈLES VISUELS — DEUX catégories bien distinctes, jamais confondues : " 'dialog_template (modèles "boîte de dialogue" : "defaut" — Classique — ' 'ou "manga_dialogue", le pendant en petite carte du thème manga) ne ' "s'affiche QUE si fullscreen=false. " 'page_template (modèles "page de quiz" : "classique" — sobre/' 'professionnel, couleurs reprises du modèle "boîte de dialogue" de ' 'base — ou "manga", un thème dessiné/typographié entièrement à part) ' "ne s'affiche QUE si fullscreen=true — chacun un thème complet et " "autonome (police, formes, couleurs ET structure entièrement propres à " "ce modèle), pensé pour un quiz qui occupe tout l'écran. Les deux " "réglages sont conservés " "INDÉPENDAMMENT (jamais l'un n'écrase l'autre) : ne règle QUE celui qui " "correspond au mode (plein écran ou non) réellement voulu par le " "créateur, et demande le style souhaité s'il n'en a mentionné aucun " "plutôt que d'en choisir un au hasard. En plein écran, le score choisi " "via score_widget/reward_amount s'affiche automatiquement DANS la boîte " "à quiz, sans réglage supplémentaire." "\n\n" "Ton professionnel, adapté à une formation d'entreprise. Réponds " "toujours en français, de façon concise, en confirmant ce que tu as " "posé." ) class ScreenDeletedError(Exception): """Garde-fou défensif pour screen_id introuvable : en usage normal, supprimer un écran (screens/screens_repo/delete_screen.py) supprime déjà EN CASCADE ses conversations IA (ON DELETE CASCADE, voir screens/ia/ensure_ia_chat_schema.py) — cette voie n'est donc pas censée être atteignable via l'appli. get_screen() renvoyant dict | None, ce garde évite quand même un crash cru (attribut sur None) si jamais screen_id était invalide pour une autre raison, plutôt qu'un message lisible par un humain (voir run_chat_turn).""" def _describe_scene_state(slug: str, screen_id: int) -> str: """Contexte dynamique (jamais mémorisé côté Claude entre les tours, voir run_chat_turn — seul le texte final est persisté) : sans ça, Ruby ne "voit" jamais ce qui existe déjà sur l'écran et duplique des objets au lieu de les corriger (bug observé : 2 PNJ identiques créés à la place d'un seul joueur + un pnj).""" screen = screens.get_screen(slug, screen_id) if screen is None: raise ScreenDeletedError("L'écran de cette conversation a été supprimé.") width, height = screen["scene_width"], screen["scene_height"] objects = screens.list_scene_objects(slug, screen_id) # Répété ICI (pas seulement dans les instructions générales) avec les # VRAIS chiffres de cet écran — un rappel abstrait une seule fois dans # un long system prompt s'est montré insuffisant (bug observé deux # fois : personnage posé à des coordonnées bien au-delà de la caméra). lines = [ f"Zone visible par la caméra CETTE ÉCRAN : x de 0 à {width}, y de 0 à {height} " f"(coin haut-gauche à (0,0)). RAPPEL : place tout personnage/widget d'interface " f"À L'INTÉRIEUR de ces bornes par défaut (ex. x autour de {width // 2}, " f"y autour de {height // 2} pour un centrage simple), sauf demande explicite d'un " "monde plus grand à explorer." ] if not objects: lines.append("Aucun objet posé sur cet écran pour l'instant.") else: lines.append(f"{len(objects)} objet(s) déjà posé(s) sur cet écran :") for o in objects: desc = ( f"- id={o['id']} kind={o['kind']} position=({int(o['x'])},{int(o['y'])}) " f"taille={int(o['width'])}x{int(o['height'])}" ) if o.get("name"): desc += f" nom={o['name']!r}" if o["kind"] == "personnage": desc += f" rôle={screens.resolve_personnage_role(o)}" if (o.get("attributes") or {}).get("_collision_rules"): desc += " [déclencheur déjà configuré]" lines.append(desc) screen_triggers = screens.resolve_screen_triggers(screen) if screen_triggers: lines.append( f"{len(screen_triggers)} déclencheur(s) D'ÉCRAN déjà configuré(s) sur cet écran " "(voir set_screen_triggers) — relis-les avant d'en ajouter un nouveau plutôt que " "de dupliquer une narration d'ouverture déjà posée." ) else: lines.append("Aucun déclencheur d'écran (narration à l'affichage) configuré pour l'instant.") variables = db.list_global_variables(slug) if variables: lines.append("Variables globales déjà existantes dans ce jeu (jamais en recréer une du même nom) :") for v in variables: lines.append(f"- {v['name']} ({v['type']}, valeur actuelle : {v['value']})") else: lines.append("Aucune variable globale n'existe encore dans ce jeu.") return "\n".join(lines) def _history_to_messages(history: list[dict[str, Any]]) -> list[Any]: return [{"role": m["role"], "content": m["content"]} for m in history] def run_chat_turn(slug: str, screen_id: int, conversation_id: int, user_id: int, user_message: str) -> str: """Un tour complet : reprend l'historique persisté de CETTE conversation, ajoute le message du créateur, boucle tant que Claude appelle des outils, et renvoie le texte final. `screen_id` reste nécessaire pour les tools (voir ai/tools.py — chaque conversation reste scopée à SON écran, une conversation ne change jamais d'écran). Ne persiste RIEN elle-même — voir routes/ia/ia_chat.py, seul appelant, qui décide de ce qui est sauvegardé (même séparation que le reste du moteur : cette fonction ne fait que la logique IA).""" try: # État réel de la scène RE-LU à chaque tour (jamais mémorisé par # Claude lui-même) — voir _describe_scene_state. Vérifié AVANT de # construire le client Claude : pas la peine d'appeler l'API si # l'écran de cette conversation n'existe plus. scene_state = _describe_scene_state(slug, screen_id) except ScreenDeletedError: return "Cet écran a été supprimé — cette conversation n'est plus utilisable." client = get_client() # AnthropicNotConfiguredError si pas de clé messages: list[Any] = _history_to_messages(screens.list_ia_chat_messages(slug, conversation_id)) messages.append({"role": "user", "content": user_message}) system_prompt = _SYSTEM_PROMPT + "\n\n" + scene_state response = None for _ in range(_MAX_TOOL_ITERATIONS): # 4096 était trop bas (bug corrigé) : la réflexion adaptative # partage le même budget que la réponse — sur une demande riche # (plusieurs objets + logique + texte), Claude pouvait être coupé # EN PLEINE RÉFLEXION, avant le moindre appel d'outil (symptôme # observé : aucune progression du tout, "(pas de réponse # textuelle)" dès le premier tour). response = client.messages.create( # type: ignore[call-overload] # TOOLS/messages sont des dict Python bruts, pas les TypedDict exacts du SDK anthropic model=MODEL, max_tokens=16000, system=system_prompt, tools=TOOLS, thinking={"type": "adaptive"}, messages=messages, ) messages.append({"role": "assistant", "content": response.content}) if response.stop_reason != "tool_use": break tool_results: list[Any] = [] for block in response.content: if block.type == "tool_use": try: result = dispatch_tool(slug, screen_id, user_id, block.name, block.input) except Exception as e: result = {"error": str(e)} tool_results.append( { "type": "tool_result", "tool_use_id": block.id, "content": json.dumps(result), } ) messages.append({"role": "user", "content": tool_results}) response = db.assert_not_none(response, "_MAX_TOOL_ITERATIONS > 0, la boucle for s'execute donc au moins une fois") text = next((b.text for b in response.content if b.type == "text"), "") if text: return text if response.stop_reason == "max_tokens": return ( "Ruby a été interrompue avant de terminer (demande trop complexe pour une seule " "réponse) — réessaie en la découpant en plusieurs étapes plus simples." ) return "(pas de réponse textuelle)"