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>
417 lines
20 KiB
Python
417 lines
20 KiB
Python
"""Outils exposés à l'agent IA (voir plan Phase 1, §4) — chaque tool
|
|
appelle DIRECTEMENT une fonction déjà utilisée par l'éditeur no-code
|
|
manuel, jamais une structure parallèle : ce qu'un créateur voit ensuite
|
|
dans l'éditeur (Déclencheurs, Variables, panneau de propriétés) est donc
|
|
TOUJOURS le résultat du même code, qu'il ait été posé à la main ou par
|
|
l'IA.
|
|
|
|
Aucune dépendance au SDK anthropic ici (voir ai/__init__.py) — ce module
|
|
ne fait que décrire les schémas et les relier à screens.*/db.* ; la
|
|
boucle tool-use elle-même (Phase 2) l'utilisera tel quel.
|
|
|
|
Principe validé avec l'utilisateur : un NOUVEL outil reste une décision
|
|
explicite (jamais de réflexion automatique sur tout screens/), mais une
|
|
VALEUR interne à un outil existant (un type d'action, un opérateur de
|
|
condition, une opération de variable) doit suivre automatiquement dès
|
|
qu'elle est ajoutée côté moteur — d'où les schémas ci-dessous construits
|
|
à partir des constantes existantes (ACTION_TYPES, CONDITION_OPERATORS,
|
|
DATA_OPERATION_LABELS, ...) plutôt que recopiées en dur. Un test dédié
|
|
(tests/test_ai_tools.py) vérifie que cette référence n'est jamais
|
|
remplacée par une copie littérale."""
|
|
|
|
import os
|
|
|
|
import requests
|
|
from flask import url_for
|
|
|
|
import auth
|
|
import db
|
|
import screens
|
|
from db.dialogue_lines import QUESTION_REWARD_TYPES
|
|
from screens.rendering.collision_rules import ACTION_TYPES, LEAF_ACTION_TYPES, CONDITION_OPERATOR_KEYS
|
|
from screens.labels.data_operations import DATA_OPERATION_LABELS
|
|
from .scenario_client import generate_image_url
|
|
|
|
_CONDITION_OPERATORS = sorted(CONDITION_OPERATOR_KEYS)
|
|
_DATA_OPERATIONS = sorted(DATA_OPERATION_LABELS)
|
|
_PERSONNAGE_ROLES = list(screens.PERSONNAGE_ROLES)
|
|
_COLLISION_SHAPES = list(screens.COLLISION_SHAPES)
|
|
_GLOBAL_VARIABLE_TYPES = sorted(db.GLOBAL_VARIABLE_TYPES)
|
|
# Catalogue COMPLET (public + admin-only, voir core/sprite_gate.py) —
|
|
# Claude doit connaître les slugs valides pour ne jamais en deviner un
|
|
# qui retomberait silencieusement sur le personnage par défaut
|
|
# (screens.add_scene_object). L'accès aux entrées admin-only reste
|
|
# vérifié à l'exécution (voir _dispatch_add_scene_object), même garde
|
|
# que la galerie manuelle.
|
|
_FORGE_CHARACTERS = sorted(screens.SPRITE_LIBRARY)
|
|
_BACKGROUNDS = sorted(screens.BACKGROUND_LIBRARY)
|
|
|
|
_LEAF_ACTION_SCHEMA = {
|
|
"type": "object",
|
|
"description": (
|
|
"Une action FEUILLE (\"dialogue\" ou \"variable\", voir "
|
|
"screens/rendering/collision_rules.py) — peut porter un champ "
|
|
"optionnel \"then\" (une autre feuille, chaînage borné à 4)."
|
|
),
|
|
"properties": {
|
|
"type": {"type": "string", "enum": list(LEAF_ACTION_TYPES)},
|
|
"id": {"type": "string", "description": "Identifiant de cette feuille, pour l'adresser plus tard via append_action_to_trigger."},
|
|
"dialogue": {
|
|
"type": "object",
|
|
"description": (
|
|
"Pour type=dialogue : {id, lines}. Chaque élément de `lines` est SOIT une "
|
|
"RÉPLIQUE {\"type\":\"dialogue\", \"speaker\": <qui parle>, \"text\": <texte>}, "
|
|
"SOIT une QUESTION DE QUIZ {\"type\":\"question\", \"text\", \"choices\": "
|
|
"[2 à 4 réponses], \"correct_index\": <index de la bonne réponse>, "
|
|
f"\"reward_type\": {list(QUESTION_REWARD_TYPES)!r}, \"reward_amount\": <entier>}}. "
|
|
"reward_amount alimente le SCORE NATIF du jeu (visible via un objet "
|
|
"kind=\"score_widget\", nécessite aussi un objet kind=\"quiz_box\" posé pour que "
|
|
"la question s'affiche) — crédité UNIQUEMENT si la réponse est correcte, sinon "
|
|
"ignoré ; le joueur avance toujours à la ligne suivante, bonne réponse ou pas. "
|
|
"N'invente JAMAIS une variable séparée pour suivre un score de quiz : "
|
|
"reward_amount fait déjà tout, sans action \"variable\" supplémentaire."
|
|
),
|
|
"properties": {"id": {"type": "string"}, "lines": {"type": "array"}},
|
|
},
|
|
"mark_completed": {"type": "boolean"},
|
|
"target_variable": {"type": "string", "description": "Pour type=variable : nom d'une variable globale existante."},
|
|
"data_operation": {"type": "string", "enum": _DATA_OPERATIONS},
|
|
"data_value": {"type": "string", "description": "Absent pour definir_bool_vrai/definir_bool_faux/basculer_bool."},
|
|
"then": {"description": "Feuille suivante (même forme), récursif."},
|
|
},
|
|
"required": ["type"],
|
|
}
|
|
|
|
_ACTION_SCHEMA = {
|
|
"type": "object",
|
|
"description": "Une action de déclencheur — voir screens/rendering/collision_rules.py pour la forme exacte de chaque type.",
|
|
"properties": {
|
|
"type": {"type": "string", "enum": list(ACTION_TYPES)},
|
|
"id": {"type": "string"},
|
|
"dialogue": _LEAF_ACTION_SCHEMA["properties"]["dialogue"],
|
|
"mark_completed": {"type": "boolean"},
|
|
"target_variable": {"type": "string"},
|
|
"data_operation": {"type": "string", "enum": _DATA_OPERATIONS},
|
|
"data_value": {"type": "string"},
|
|
"then": {"description": "Feuille suivante (dialogue/variable), récursif."},
|
|
"sub_action": {"description": "Pour type=interagir : une action (pas \"interagir\" à nouveau)."},
|
|
"variable": {"type": "string", "description": "Pour type=condition."},
|
|
"operateur": {"type": "string", "enum": _CONDITION_OPERATORS},
|
|
"valeur": {"type": "string"},
|
|
"si_vrai": {"description": "Pour type=condition : null ou une feuille."},
|
|
"si_faux": {"description": "Pour type=condition : null ou une feuille."},
|
|
},
|
|
"required": ["type"],
|
|
}
|
|
|
|
TOOLS = [
|
|
{
|
|
"name": "add_scene_object",
|
|
"description": (
|
|
"Pose un nouvel objet sur l'écran en cours (personnage, décor, fond, "
|
|
"ou widget d'interface). Pour une image générée/de \"Mes assets\", "
|
|
"utiliser image_url (jamais générer un personnage/sprite par ce biais)."
|
|
),
|
|
"input_schema": {
|
|
"type": "object",
|
|
"properties": {
|
|
"kind": {"type": "string", "enum": ["personnage", "decor", "fond", "dialogue_box", "quiz_box", "score_widget"]},
|
|
"forge_character": {"type": "string", "enum": _FORGE_CHARACTERS, "description": "Pour kind=personnage : slug d'un personnage de la bibliothèque Forge existante (jamais un sprite généré)."},
|
|
"background_slug": {"type": "string", "enum": _BACKGROUNDS, "description": "Pour kind=fond : slug d'une image de fond déjà existante dans la bibliothèque Forge."},
|
|
"image_url": {"type": "string", "description": "URL d'une image déjà uploadée/générée (voir \"Mes assets\") — kind decor/fond uniquement, prioritaire sur background_slug."},
|
|
},
|
|
"required": ["kind"],
|
|
},
|
|
},
|
|
{
|
|
"name": "set_object_geometry",
|
|
"description": "Positionne/redimensionne un objet déjà posé sur l'écran, en pixels.",
|
|
"input_schema": {
|
|
"type": "object",
|
|
"properties": {
|
|
"object_id": {"type": "integer"},
|
|
"x": {"type": "number"}, "y": {"type": "number"},
|
|
"width": {"type": "number"}, "height": {"type": "number"},
|
|
},
|
|
"required": ["object_id", "x", "y", "width", "height"],
|
|
},
|
|
},
|
|
{
|
|
"name": "set_object_name",
|
|
"description": (
|
|
"Donne un nom à un objet de scène (ex. \"Stan\", \"Aka\") — affiché comme "
|
|
"\"qui parle\" dans les dialogues qui lui sont attachés."
|
|
),
|
|
"input_schema": {
|
|
"type": "object",
|
|
"properties": {
|
|
"object_id": {"type": "integer"},
|
|
"name": {"type": "string"},
|
|
},
|
|
"required": ["object_id", "name"],
|
|
},
|
|
},
|
|
{
|
|
"name": "set_object_role",
|
|
"description": "Change le rôle d'un objet personnage (joueur / ennemie / pnj).",
|
|
"input_schema": {
|
|
"type": "object",
|
|
"properties": {
|
|
"object_id": {"type": "integer"},
|
|
"role": {"type": "string", "enum": _PERSONNAGE_ROLES},
|
|
},
|
|
"required": ["object_id", "role"],
|
|
},
|
|
},
|
|
{
|
|
"name": "set_object_collision",
|
|
"description": "Règle la boîte de collision d'un objet (forme, taille, décalage, activée ou non).",
|
|
"input_schema": {
|
|
"type": "object",
|
|
"properties": {
|
|
"object_id": {"type": "integer"},
|
|
"enabled": {"type": "boolean"},
|
|
"shape": {"type": "string", "enum": _COLLISION_SHAPES},
|
|
"width": {"type": "number"}, "height": {"type": "number"},
|
|
"offset_x": {"type": "number"}, "offset_y": {"type": "number"},
|
|
},
|
|
"required": ["object_id"],
|
|
},
|
|
},
|
|
{
|
|
"name": "create_global_variable",
|
|
"description": "Crée une variable globale (idempotent par nom) — utilisable ensuite dans les déclencheurs/conditions.",
|
|
"input_schema": {
|
|
"type": "object",
|
|
"properties": {
|
|
"name": {"type": "string"},
|
|
"var_type": {"type": "string", "enum": _GLOBAL_VARIABLE_TYPES},
|
|
"default_value": {"type": "string"},
|
|
"per_player": {"type": "boolean"},
|
|
},
|
|
"required": ["name"],
|
|
},
|
|
},
|
|
{
|
|
"name": "set_collision_rules",
|
|
"description": (
|
|
"Remplace TOUTES les règles \"déclencheur -> action\" d'un objet. "
|
|
"Sanitizé côté serveur (screens.sanitize_collision_rules) : toute "
|
|
"valeur invalide est silencieusement retirée plutôt que rejetée."
|
|
),
|
|
"input_schema": {
|
|
"type": "object",
|
|
"properties": {
|
|
"object_id": {"type": "integer"},
|
|
"rules": {
|
|
"type": "array",
|
|
"items": {
|
|
"type": "object",
|
|
"properties": {
|
|
"trigger": {"type": "string"},
|
|
"action": _ACTION_SCHEMA,
|
|
},
|
|
"required": ["trigger", "action"],
|
|
},
|
|
},
|
|
},
|
|
"required": ["object_id", "rules"],
|
|
},
|
|
},
|
|
{
|
|
"name": "add_generated_image",
|
|
"description": (
|
|
"Génère une image (fond ou objet 2D UNIQUEMENT, JAMAIS un "
|
|
"personnage/sprite — la génération d'image n'est pas fiable pour "
|
|
"ça) via Scenario, l'ajoute à \"Mes assets\" et la pose "
|
|
"automatiquement sur l'écran en cours."
|
|
),
|
|
"input_schema": {
|
|
"type": "object",
|
|
"properties": {
|
|
"kind": {"type": "string", "enum": ["decor", "fond"]},
|
|
"prompt": {"type": "string", "description": "Description de l'image en langage naturel."},
|
|
},
|
|
"required": ["kind", "prompt"],
|
|
},
|
|
},
|
|
{
|
|
"name": "append_action_to_trigger",
|
|
"description": (
|
|
"Ajoute une action à la SUITE d'une chaîne déjà posée sur un "
|
|
"déclencheur existant, sans reconstruire toute la règle — "
|
|
"after_id désigne la dernière feuille de la chaîne."
|
|
),
|
|
"input_schema": {
|
|
"type": "object",
|
|
"properties": {
|
|
"object_id": {"type": "integer"},
|
|
"after_id": {"type": "string"},
|
|
"action": _LEAF_ACTION_SCHEMA,
|
|
},
|
|
"required": ["object_id", "after_id", "action"],
|
|
},
|
|
},
|
|
]
|
|
|
|
|
|
def _clamp_to_camera(slug, screen_id, kind, x, y, width, height):
|
|
"""Le placement précis par coordonnées s'est montré peu fiable pour
|
|
l'IA malgré des instructions explicites ET un avertissement dans le
|
|
résultat de l'outil (bug observé à répétition — l'IA n'arrivait
|
|
toujours pas à replacer l'objet correctement, épuisant même parfois
|
|
tout le budget d'itérations sans jamais y arriver). Plutôt que de
|
|
compter sur elle pour se corriger, le moteur RAMÈNE automatiquement
|
|
tout objet (hors "fond", volontairement plus grand que la caméra —
|
|
voir screens.resolve_scene_world_size) à l'intérieur du cadre visible
|
|
dès cet appel : garantit un résultat correct du premier coup, sans
|
|
aller-retour. Renvoie (x, y, note) — note est None si aucun
|
|
ajustement n'était nécessaire."""
|
|
if kind == "fond":
|
|
return x, y, None
|
|
screen = screens.get_screen(slug, screen_id)
|
|
if not screen:
|
|
return x, y, None
|
|
scene_width, scene_height = screen["scene_width"], screen["scene_height"]
|
|
clamped_x = min(max(x, 0), max(0, scene_width - width))
|
|
clamped_y = min(max(y, 0), max(0, scene_height - height))
|
|
if (clamped_x, clamped_y) == (x, y):
|
|
return x, y, None
|
|
note = (
|
|
f"Position ajustée automatiquement de ({int(x)},{int(y)}) à "
|
|
f"({int(clamped_x)},{int(clamped_y)}) pour rester dans le cadre visible par la "
|
|
f"caméra (0,0)-({scene_width},{scene_height})."
|
|
)
|
|
return clamped_x, clamped_y, note
|
|
|
|
|
|
def _dispatch_add_scene_object(slug, screen_id, user_id, kind, forge_character=None, background_slug=None, image_url=None):
|
|
# Même garde que la galerie manuelle (core/sprite_gate.py) — un
|
|
# compte non-admin ne doit pas pouvoir poser un sprite/fond
|
|
# admin-only via l'IA alors que la galerie de l'éditeur ne les lui
|
|
# propose déjà pas.
|
|
user = auth.get_user_by_id(user_id)
|
|
is_admin = bool(user and user["role"] == "admin")
|
|
if forge_character in screens.ADMIN_ONLY_CHARACTER_SLUGS and not is_admin:
|
|
forge_character = None
|
|
if background_slug in screens.ADMIN_ONLY_BACKGROUND_SLUGS and not is_admin:
|
|
background_slug = None
|
|
# screens.add_scene_object ignore SILENCIEUSEMENT un slug invalide
|
|
# (comportement voulu pour l'éditeur manuel, où un select HTML ne
|
|
# peut de toute façon proposer qu'un slug valide) — mais Claude, lui,
|
|
# peut inventer/mal orthographier une valeur malgré l'enum du schéma
|
|
# (pas de strict:true ici, voir ai/chat.py). Sans ce contrôle,
|
|
# l'outil "réussissait" en silence et Ruby annonçait un fond/
|
|
# personnage posé qui n'apparaissait jamais (bug corrigé). Lever une
|
|
# erreur ici la remonte comme résultat d'outil (voir ai/chat.py) :
|
|
# Claude la VOIT et peut se corriger dans le même tour.
|
|
if forge_character and forge_character not in screens.SPRITE_LIBRARY:
|
|
raise ValueError(f"forge_character inconnu : {forge_character!r} (voir la liste enum du tool)")
|
|
if background_slug and background_slug not in screens.BACKGROUND_LIBRARY:
|
|
raise ValueError(f"background_slug inconnu : {background_slug!r} (voir la liste enum du tool)")
|
|
object_id = screens.add_scene_object(
|
|
slug, screen_id, kind=kind, forge_character=forge_character,
|
|
background_slug=background_slug, image_url=image_url,
|
|
)
|
|
obj = screens.get_scene_object(slug, object_id)
|
|
# Position par défaut (100,100, voir ensure_scene_schema.py) déjà
|
|
# posée par screens.add_scene_object — reste passée par le même
|
|
# garde-fou pour rester correcte même si ce défaut changeait un jour.
|
|
clamped_x, clamped_y, note = _clamp_to_camera(slug, screen_id, kind, obj["x"], obj["y"], obj["width"], obj["height"])
|
|
if note:
|
|
screens.update_scene_object_geometry(slug, object_id, clamped_x, clamped_y, obj["width"], obj["height"])
|
|
result = {"object_id": object_id}
|
|
if note:
|
|
result["note"] = note
|
|
return result
|
|
|
|
|
|
def _dispatch_set_object_geometry(slug, screen_id, user_id, object_id, x, y, width, height):
|
|
obj = screens.get_scene_object(slug, object_id)
|
|
x, y, note = _clamp_to_camera(slug, screen_id, obj["kind"] if obj else None, x, y, width, height)
|
|
screens.update_scene_object_geometry(slug, object_id, x, y, width, height)
|
|
result = {"ok": True}
|
|
if note:
|
|
result["note"] = note
|
|
return result
|
|
|
|
|
|
def _dispatch_set_object_name(slug, screen_id, user_id, object_id, name):
|
|
screens.set_scene_object_name(slug, object_id, name)
|
|
return {"ok": True}
|
|
|
|
|
|
def _dispatch_set_object_role(slug, screen_id, user_id, object_id, role):
|
|
screens.set_scene_object_role(slug, object_id, role)
|
|
return {"ok": True}
|
|
|
|
|
|
def _dispatch_set_object_collision(slug, screen_id, user_id, object_id, enabled=True, shape="rectangle",
|
|
width=None, height=None, offset_x=0, offset_y=0):
|
|
# Même forme que routes/scenes/scene_object_collision.py (remplacement
|
|
# complet des réglages, jamais un merge partiel).
|
|
settings = {
|
|
"enabled": bool(enabled),
|
|
"shape": shape if shape in _COLLISION_SHAPES else "rectangle",
|
|
"width": width, "height": height,
|
|
"offset_x": offset_x or 0, "offset_y": offset_y or 0,
|
|
}
|
|
screens.set_scene_object_collision(slug, object_id, settings)
|
|
return {"ok": True}
|
|
|
|
|
|
def _dispatch_create_global_variable(slug, screen_id, user_id, name, var_type="texte", default_value="", per_player=True):
|
|
variable_id = db.create_global_variable(slug, name, var_type=var_type, default_value=default_value, per_player=per_player)
|
|
return {"variable_id": variable_id}
|
|
|
|
|
|
def _dispatch_set_collision_rules(slug, screen_id, user_id, object_id, rules):
|
|
sanitized = screens.sanitize_collision_rules(rules)
|
|
screens.set_scene_object_collision_rules(slug, object_id, sanitized)
|
|
return {"ok": True, "rules": sanitized}
|
|
|
|
|
|
def _dispatch_append_action_to_trigger(slug, screen_id, user_id, object_id, after_id, action):
|
|
ok = screens.append_action_to_trigger(slug, object_id, after_id, action)
|
|
return {"ok": ok}
|
|
|
|
|
|
def _dispatch_add_generated_image(slug, screen_id, user_id, kind, prompt):
|
|
"""Scenario -> "Mes assets" (auth.save_user_asset, source="ia") ->
|
|
posée sur l'écran via LE MÊME chemin que Phase 1
|
|
(screens.add_scene_object(image_url=...)) — jamais un chemin
|
|
parallèle. Toute erreur (Scenario mal configuré, timeout, échec)
|
|
remonte comme un résultat d'outil normal, voir ai/chat.py."""
|
|
image_url = generate_image_url(prompt)
|
|
downloaded = requests.get(image_url, timeout=60)
|
|
downloaded.raise_for_status()
|
|
ext = os.path.splitext(image_url.split("?")[0])[1] or ".png"
|
|
asset_id, filename = auth.save_user_asset(user_id, downloaded.content, ext, original_name=prompt[:80], source="ia")
|
|
served_url = url_for("serve_user_asset", user_id=user_id, filename=filename)
|
|
object_id = screens.add_scene_object(slug, screen_id, kind=kind, image_url=served_url)
|
|
return {"object_id": object_id, "asset_id": asset_id}
|
|
|
|
|
|
_DISPATCH = {
|
|
"add_scene_object": _dispatch_add_scene_object,
|
|
"set_object_geometry": _dispatch_set_object_geometry,
|
|
"set_object_name": _dispatch_set_object_name,
|
|
"set_object_role": _dispatch_set_object_role,
|
|
"set_object_collision": _dispatch_set_object_collision,
|
|
"create_global_variable": _dispatch_create_global_variable,
|
|
"set_collision_rules": _dispatch_set_collision_rules,
|
|
"append_action_to_trigger": _dispatch_append_action_to_trigger,
|
|
"add_generated_image": _dispatch_add_generated_image,
|
|
}
|
|
|
|
|
|
def dispatch_tool(slug, screen_id, user_id, tool_name, tool_input):
|
|
"""Point d'entrée UNIQUE utilisé par la boucle tool-use (Phase 2) —
|
|
lève KeyError pour un nom d'outil inconnu (jamais silencieux : un tool
|
|
annoncé par TOOLS mais absent d'ici serait un bug de ce module, pas
|
|
une entrée utilisateur à tolérer). `user_id` : nécessaire pour "Mes
|
|
assets" (scopé par compte, voir add_generated_image ci-dessus) —
|
|
ignoré par les autres outils, qui n'agissent que sur l'écran/l'objet."""
|
|
handler = _DISPATCH[tool_name]
|
|
return handler(slug, screen_id, user_id, **tool_input)
|