Ajoute l'assistant IA "Ruby" (Claude + Scenario) et corrige plusieurs bugs de la scène
Intègre un chat IA capable de manipuler la scène via les mêmes fonctions que l'éditeur manuel (objets, variables, déclencheurs, images générées), avec conversations multiples par écran façon Claude. Corrige au passage le rafraîchissement pjax hors-ordre, l'onglet IA/déclencheurs vide après sélection d'un objet, la comparaison de booléens dans les conditions, et le blocage du glisser-déposer hors du cadre caméra après un redimensionnement de fond par l'IA. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
ab6635eee0
commit
b07b231a61
+229
@@ -0,0 +1,229 @@
|
||||
"""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
|
||||
|
||||
import db
|
||||
import screens
|
||||
|
||||
from .client import get_client, MODEL
|
||||
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 ne pilotent QUE la logique "
|
||||
"\"déclencheur de collision\" d'un objet de scène (dialogue, "
|
||||
"variable, interagir, condition). Forge Engine a un AUTRE système, "
|
||||
"séparé, de \"flow\" (nœuds/liaisons) pour changer d'écran, les "
|
||||
"minuteurs, jouer un son, une animation de sprite — tu n'as AUCUN "
|
||||
"outil pour ça. Si une demande a besoin de cette partie-là (\"passer "
|
||||
"à l'écran suivant\", \"jouer un son\", \"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"
|
||||
"É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)."
|
||||
"\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é."
|
||||
"\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"
|
||||
"Ton professionnel, adapté à une formation d'entreprise. Réponds "
|
||||
"toujours en français, de façon concise, en confirmant ce que tu as "
|
||||
"posé."
|
||||
)
|
||||
|
||||
|
||||
def _describe_scene_state(slug, screen_id):
|
||||
"""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)
|
||||
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)
|
||||
|
||||
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):
|
||||
return [{"role": m["role"], "content": m["content"]} for m in history]
|
||||
|
||||
|
||||
def run_chat_turn(slug, screen_id, conversation_id, user_id, user_message):
|
||||
"""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)."""
|
||||
client = get_client() # AnthropicNotConfiguredError si pas de clé
|
||||
messages = _history_to_messages(screens.list_ia_chat_messages(slug, conversation_id))
|
||||
messages.append({"role": "user", "content": user_message})
|
||||
# État réel de la scène RE-LU à chaque tour (jamais mémorisé par
|
||||
# Claude lui-même) — voir _describe_scene_state.
|
||||
system_prompt = _SYSTEM_PROMPT + "\n\n" + _describe_scene_state(slug, screen_id)
|
||||
|
||||
response = None
|
||||
for _ in range(_MAX_TOOL_ITERATIONS):
|
||||
response = client.messages.create(
|
||||
# 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).
|
||||
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 = []
|
||||
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})
|
||||
|
||||
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)"
|
||||
Reference in New Issue
Block a user