Files
Forge-Engine/ai/tools.py
T
williamandClaude Sonnet 5 559331f9cf
Build and deploy / test-python (push) Successful in 10m59s
Build and deploy / test-js (push) Successful in 1m27s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Enrichit les declencheurs/actions de scene (clic/survol/affichage, surbrillance/video/son/visibilite/indication/attendre) et fiabilise la pose d'un fond/decor importe
- Ajoute clic/survol/affichage-ecran comme declencheurs, et surbrillance,
  video, son, visibilite, indication, attendre comme actions, utilisables
  aussi bien par l'editeur manuel (menu lateral Objets/Ecran) que par
  Ruby (IA), avec blocs deplacables/supprimables dans une chaine.
- Corrige plusieurs variantes du bug "impossible de poser un objet hors
  du champ de la camera" (troncature du chainage d'actions a 4 maillons,
  fond importe pose a 128x128 au lieu de sa taille reelle, decalage du
  fond au vrai glisser-depose, redimensionnement manuel jamais propage
  au monde).
- Ajoute un vrai glisser-depose depuis la galerie vers la scene, la
  gestion complete de "Mes assets" (sous-sections Fonds/Decors/Sons/
  Videos, suppression, reclassement fond<->decor sans re-upload).
- Ajoute l'upload de son (limite 3 min) et de video (MP4 uniquement,
  limite 5 min), avec validation de la duree reelle du fichier, et une
  replique audio optionnelle dans une bulle de dialogue.
- Fixe la taille de pose d'un objet/decor importe a 200x200 avec une
  boite de collision de 150x150.
- Filtre le selecteur de fichier des actions son/video par type reel.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 00:28:46 +02:00

596 lines
28 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, TRIGGER_TYPES
from screens.rendering.screen_triggers import TRIGGER_TYPES_SCREEN
from screens.labels.data_operations import DATA_OPERATION_LABELS
from screens.labels.element_visibility import ELEMENT_VISIBILITY_LABELS
from screens.labels.surbrillance_values import SURBRILLANCE_LABELS
from screens.labels.video_modes import VIDEO_MODE_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": "Pour type=variable : absent pour definir_bool_vrai/definir_bool_faux/basculer_bool. Pour type=attendre : nombre de SECONDES à attendre avant \"then\" (ex. \"2\", \"1.5\")."},
"object_id": {
"type": "integer",
"description": (
"Pour type=surbrillance/visibilite/indication : id de l'objet CIBLÉ par cette "
"action — n'importe quel objet de l'écran, pas forcément celui qui porte le "
"déclencheur (ex. le joueur touche un interrupteur -> indication près d'une porte)."
),
},
"valeur": {
"type": "string",
"description": (
f"Pour type=surbrillance : une valeur parmi {sorted(SURBRILLANCE_LABELS)!r}. "
f"Pour type=visibilite : une valeur parmi {sorted(ELEMENT_VISIBILITY_LABELS)!r}. "
"Pour type=condition (voir _ACTION_SCHEMA) : la valeur littérale à comparer."
),
},
"asset_url": {
"type": "string",
"description": "Pour type=son/video : URL d'un fichier déjà présent dans \"Mes assets\".",
},
"mode": {"type": "string", "enum": sorted(VIDEO_MODE_LABELS), "description": "Pour type=video."},
"texte": {"type": "string", "description": "Pour type=indication : le texte affiché dans la bulle."},
"duree_ms": {"type": "integer", "description": "Pour type=indication : durée d'affichage en ms (optionnel)."},
"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"},
"object_id": _LEAF_ACTION_SCHEMA["properties"]["object_id"],
"valeur": _LEAF_ACTION_SCHEMA["properties"]["valeur"],
"asset_url": _LEAF_ACTION_SCHEMA["properties"]["asset_url"],
"mode": _LEAF_ACTION_SCHEMA["properties"]["mode"],
"texte": _LEAF_ACTION_SCHEMA["properties"]["texte"],
"duree_ms": _LEAF_ACTION_SCHEMA["properties"]["duree_ms"],
"then": {"description": "Feuille suivante (dialogue/variable/surbrillance/visibilite/son/video/indication), 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},
"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. "
"Déclencheurs disponibles : \"collision\" (contact avec le personnage "
"\"joueur\" — voir set_object_role), \"clic\" (l'objet est cliqué/touché, "
"aucun joueur requis), \"survol\" (le pointeur survole l'objet, aucun "
"joueur requis). Sanitizé côté serveur (screens.sanitize_collision_rules) : "
"toute valeur invalide est silencieusement retirée plutôt que rejetée — "
"compare le nombre de `rules` renvoyées dans le résultat à ce que tu as "
"envoyé pour détecter un retrait silencieux."
),
"input_schema": {
"type": "object",
"properties": {
"object_id": {"type": "integer"},
"rules": {
"type": "array",
"items": {
"type": "object",
"properties": {
"trigger": {"type": "string", "enum": list(TRIGGER_TYPES)},
"action": _ACTION_SCHEMA,
},
"required": ["trigger", "action"],
},
},
},
"required": ["object_id", "rules"],
},
},
{
"name": "set_screen_triggers",
"description": (
"Remplace TOUTES les règles \"déclencheur d'écran -> action\" de l'écran "
"en cours — un déclencheur d'ÉCRAN se déclenche SANS avoir besoin d'un "
"objet (narration/cinématique dès l'affichage). Seul déclencheur : "
"\"affichage\" (à l'affichage de l'écran). Même sanitisation que "
"set_collision_rules."
),
"input_schema": {
"type": "object",
"properties": {
"rules": {
"type": "array",
"items": {
"type": "object",
"properties": {
"trigger": {"type": "string", "enum": list(TRIGGER_TYPES_SCREEN)},
"action": _ACTION_SCHEMA,
},
"required": ["trigger", "action"],
},
},
},
"required": ["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": (
"Insère une action JUSTE APRÈS un bloc précis d'une chaîne déjà "
"posée sur un déclencheur existant, sans reconstruire toute la "
"règle — after_id peut désigner N'IMPORTE QUEL bloc de la "
"chaîne, pas forcément le dernier : une suite déjà présente "
"après ce bloc passe derrière la nouvelle action plutôt que "
"d'être remplacée."
),
"input_schema": {
"type": "object",
"properties": {
"object_id": {"type": "integer"},
"after_id": {"type": "string"},
"action": _LEAF_ACTION_SCHEMA,
},
"required": ["object_id", "after_id", "action"],
},
},
{
"name": "append_action_to_screen_trigger",
"description": (
"Équivalent d'append_action_to_trigger pour un déclencheur D'ÉCRAN "
"(voir set_screen_triggers), sur l'écran en cours — after_id peut "
"désigner N'IMPORTE QUEL bloc de la chaîne, pas forcément le "
"dernier."
),
"input_schema": {
"type": "object",
"properties": {
"after_id": {"type": "string"},
"action": _LEAF_ACTION_SCHEMA,
},
"required": ["after_id", "action"],
},
},
{
"name": "remove_trigger_action",
"description": (
"Retire UNE action précise (leaf_id) d'un déclencheur d'OBJET, où "
"qu'elle soit dans l'arbre (feuille directe, maillon d'une chaîne "
"\"then\", sous-action d'un \"interagir\", ou branche d'une "
"\"condition\"). Si c'était la SEULE action de la règle, la règle "
"entière disparaît ; si elle avait une suite \"then\", celle-ci "
"prend sa place."
),
"input_schema": {
"type": "object",
"properties": {
"object_id": {"type": "integer"},
"leaf_id": {"type": "string"},
},
"required": ["object_id", "leaf_id"],
},
},
{
"name": "remove_screen_trigger_action",
"description": "Équivalent de remove_trigger_action pour un déclencheur D'ÉCRAN.",
"input_schema": {
"type": "object",
"properties": {"leaf_id": {"type": "string"}},
"required": ["leaf_id"],
},
},
{
"name": "move_trigger_action",
"description": (
"Déplace une action précise (leaf_id) d'un cran vers le haut "
"(\"up\") ou le bas (\"down\") DANS SA PROPRE CHAÎNE \"then\", pour "
"un déclencheur d'OBJET — jamais au-delà de sa branche de "
"condition/sous-action interagir."
),
"input_schema": {
"type": "object",
"properties": {
"object_id": {"type": "integer"},
"leaf_id": {"type": "string"},
"direction": {"type": "string", "enum": ["up", "down"]},
},
"required": ["object_id", "leaf_id", "direction"],
},
},
{
"name": "move_screen_trigger_action",
"description": "Équivalent de move_trigger_action pour un déclencheur D'ÉCRAN.",
"input_schema": {
"type": "object",
"properties": {
"leaf_id": {"type": "string"},
"direction": {"type": "string", "enum": ["up", "down"]},
},
"required": ["leaf_id", "direction"],
},
},
]
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_set_screen_triggers(slug, screen_id, user_id, rules):
sanitized = screens.sanitize_screen_triggers(rules)
screens.set_screen_triggers(slug, screen_id, sanitized)
return {"ok": True, "rules": sanitized}
def _dispatch_append_action_to_screen_trigger(slug, screen_id, user_id, after_id, action):
ok = screens.append_action_to_screen_trigger(slug, screen_id, after_id, action)
return {"ok": ok}
def _dispatch_remove_trigger_action(slug, screen_id, user_id, object_id, leaf_id):
ok = screens.remove_action_from_trigger(slug, object_id, leaf_id)
return {"ok": ok}
def _dispatch_remove_screen_trigger_action(slug, screen_id, user_id, leaf_id):
ok = screens.remove_action_from_screen_trigger(slug, screen_id, leaf_id)
return {"ok": ok}
def _dispatch_move_trigger_action(slug, screen_id, user_id, object_id, leaf_id, direction):
ok = screens.move_action_in_trigger(slug, object_id, leaf_id, direction)
return {"ok": ok}
def _dispatch_move_screen_trigger_action(slug, screen_id, user_id, leaf_id, direction):
ok = screens.move_action_in_screen_trigger(slug, screen_id, leaf_id, direction)
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,
"set_screen_triggers": _dispatch_set_screen_triggers,
"append_action_to_screen_trigger": _dispatch_append_action_to_screen_trigger,
"remove_trigger_action": _dispatch_remove_trigger_action,
"remove_screen_trigger_action": _dispatch_remove_screen_trigger_action,
"move_trigger_action": _dispatch_move_trigger_action,
"move_screen_trigger_action": _dispatch_move_screen_trigger_action,
"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)