Files
Forge-Engine/ai/chat.py
T
williamandClaude Sonnet 5 7db4803b93
Build and deploy / test-python (push) Successful in 12m10s
Build and deploy / test-js (push) Successful in 1m27s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Ajoute la fonctionnalite quiz autonome/plein ecran a la boite a quiz
Introduit la double categorie de modeles (boite de dialogue / page de
quiz plein ecran) avec plein ecran, minuteur, score integre et ecran de
resultat pour les modeles page ; ajoute les modeles "Manga" (boite et
page) et "Classique" (page), pilotables aussi par l'assistant IA Ruby.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 15:07:23 +02:00

310 lines
18 KiB
Python

"""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 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é."
)
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)
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):
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)"