"""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 from typing import Any, Callable import requests from flask import url_for import auth import db import screens from db.dialogue_lines import QUESTION_REWARD_TYPES 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 screens.rendering.collision_rules import ACTION_TYPES, CONDITION_OPERATOR_KEYS, LEAF_ACTION_TYPES, TRIGGER_TYPES from screens.rendering.screen_triggers import TRIGGER_TYPES_SCREEN 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) _QUIZ_BOX_TIMER_MODES = list(screens.TIMER_MODES) _QUIZ_BOX_DIALOG_TEMPLATES = list(screens.QUIZ_BOX_DIALOG_TEMPLATES) _QUIZ_BOX_PAGE_TEMPLATES = list(screens.QUIZ_BOX_PAGE_TEMPLATES) _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: dict[str, Any] = { "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": , "text": }, ' 'SOIT une QUESTION DE QUIZ {"type":"question", "text", "choices": ' '[2 à 4 réponses], "correct_index": , ' f'"reward_type": {list(QUESTION_REWARD_TYPES)!r}, "reward_amount": }}. ' "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: dict[str, Any] = { "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: list[dict[str, Any]] = [ { "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": "set_quiz_box_config", "description": ( 'Règle les options d\'une "❓ Boîte à quiz" pensées pour un quiz AUTONOME ' '(RH/formation) : plein écran, minuteur (jamais imposé — "aucun" par défaut, ' 'au choix du créateur : "question" redémarre à chaque question, "quiz" est un ' "seul compte à rebours pour tout le quiz), et DEUX modèles visuels INDÉPENDANTS " "(les deux réglages sont conservés en même temps, jamais l'un n'écrase l'autre) : " "dialog_template (visible SEULEMENT hors plein écran — variantes de forme/couleur " 'sur une structure proche de "Classique") et page_template (visible SEULEMENT en ' "plein écran — thème complet et autonome, structure HTML propre à chaque modèle). " "En plein écran, le score se retrouve affiché directement dans la boîte." ), "input_schema": { "type": "object", "properties": { "object_id": {"type": "integer"}, "fullscreen": {"type": "boolean"}, "timer_mode": {"type": "string", "enum": _QUIZ_BOX_TIMER_MODES}, "timer_seconds": { "type": "integer", "description": 'Durée du minuteur (3 à 600s), ignorée si timer_mode="aucun".', }, "dialog_template": { "type": "string", "enum": _QUIZ_BOX_DIALOG_TEMPLATES, "description": 'Modèle "boîte de dialogue" — ne s\'affiche que si fullscreen=false.', }, "page_template": { "type": "string", "enum": _QUIZ_BOX_PAGE_TEMPLATES, "description": 'Modèle "page de quiz" — ne s\'affiche que si fullscreen=true.', }, }, "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: str, screen_id: int, kind: str | None, x: float, y: float, width: float, height: float ) -> tuple[float, float, str | None]: """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: str, screen_id: int, user_id: int, kind: str, forge_character: str | None = None, background_slug: str | None = None, image_url: str | None = None, ) -> dict[str, Any]: # 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) assert obj is not None, ( # nosec B101 # noqa: S101 - narrowing de type, sur par construction "object_id vient d'etre cree par screens.add_scene_object juste au-dessus" ) # 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: dict[str, Any] = {"object_id": object_id} if note: result["note"] = note return result def _dispatch_set_object_geometry( slug: str, screen_id: int, user_id: int, object_id: int, x: float, y: float, width: float, height: float ) -> dict[str, Any]: 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: dict[str, Any] = {"ok": True} if note: result["note"] = note return result def _dispatch_set_object_name(slug: str, screen_id: int, user_id: int, object_id: int, name: str) -> dict[str, Any]: screens.set_scene_object_name(slug, object_id, name) return {"ok": True} def _dispatch_set_object_role(slug: str, screen_id: int, user_id: int, object_id: int, role: str) -> dict[str, Any]: screens.set_scene_object_role(slug, object_id, role) return {"ok": True} def _dispatch_set_object_collision( slug: str, screen_id: int, user_id: int, object_id: int, enabled: bool = True, shape: str = "rectangle", width: float | None = None, height: float | None = None, offset_x: float = 0, offset_y: float = 0, ) -> dict[str, Any]: # Même forme que routes/scenes/scene_object_collision.py (remplacement # complet des réglages, jamais un merge partiel). settings: dict[str, Any] = { "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_set_quiz_box_config( slug: str, screen_id: int, user_id: int, object_id: int, fullscreen: bool = False, timer_mode: str = "aucun", timer_seconds: int = 20, dialog_template: str = "defaut", page_template: str = "classique", ) -> dict[str, Any]: # Même forme que screens.set_scene_object_quiz_config (remplacement # complet des réglages, jamais un merge partiel) — screens.py sanitize # de toute façon toute valeur hors énumération, ce garde-fou ici sert # juste à ne jamais transmettre None à sanitize_quiz_box_config. # dialog_template/page_template sont conservés INDÉPENDAMMENT (voir # screens/rendering/quiz_box_config.py) : Ruby peut en régler un seul # sans jamais écraser l'autre. config: dict[str, Any] = { "fullscreen": bool(fullscreen), "timer_mode": timer_mode if timer_mode in _QUIZ_BOX_TIMER_MODES else "aucun", "timer_seconds": timer_seconds or 20, "dialog_template": dialog_template if dialog_template in _QUIZ_BOX_DIALOG_TEMPLATES else "defaut", "page_template": page_template if page_template in _QUIZ_BOX_PAGE_TEMPLATES else "classique", } screens.set_scene_object_quiz_config(slug, object_id, config) return {"ok": True} def _dispatch_create_global_variable( slug: str, screen_id: int, user_id: int, name: str, var_type: str = "texte", default_value: str = "", per_player: bool = True, ) -> dict[str, Any]: 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: str, screen_id: int, user_id: int, object_id: int, rules: Any ) -> dict[str, Any]: 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: str, screen_id: int, user_id: int, object_id: int, after_id: str, action: dict[str, Any] ) -> dict[str, Any]: ok = screens.append_action_to_trigger(slug, object_id, after_id, action) return {"ok": ok} def _dispatch_set_screen_triggers(slug: str, screen_id: int, user_id: int, rules: Any) -> dict[str, Any]: 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: str, screen_id: int, user_id: int, after_id: str, action: dict[str, Any] ) -> dict[str, Any]: ok = screens.append_action_to_screen_trigger(slug, screen_id, after_id, action) return {"ok": ok} def _dispatch_remove_trigger_action( slug: str, screen_id: int, user_id: int, object_id: int, leaf_id: str ) -> dict[str, Any]: ok = screens.remove_action_from_trigger(slug, object_id, leaf_id) return {"ok": ok} def _dispatch_remove_screen_trigger_action(slug: str, screen_id: int, user_id: int, leaf_id: str) -> dict[str, Any]: ok = screens.remove_action_from_screen_trigger(slug, screen_id, leaf_id) return {"ok": ok} def _dispatch_move_trigger_action( slug: str, screen_id: int, user_id: int, object_id: int, leaf_id: str, direction: str ) -> dict[str, Any]: ok = screens.move_action_in_trigger(slug, object_id, leaf_id, direction) return {"ok": ok} def _dispatch_move_screen_trigger_action( slug: str, screen_id: int, user_id: int, leaf_id: str, direction: str ) -> dict[str, Any]: ok = screens.move_action_in_screen_trigger(slug, screen_id, leaf_id, direction) return {"ok": ok} def _dispatch_add_generated_image(slug: str, screen_id: int, user_id: int, kind: str, prompt: str) -> dict[str, Any]: """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: dict[str, Callable[..., dict[str, Any]]] = { "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, "set_quiz_box_config": _dispatch_set_quiz_box_config, "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: str, screen_id: int, user_id: int, tool_name: str, tool_input: dict[str, Any] ) -> dict[str, Any]: """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)