- Nouveau db/assert_not_none.py : centralise l'unique suppression
Bandit/Ruff (# nosec B101 / # noqa: S101) de narrowing de type dans
tout le moteur. Remplace 11 sites disperses (ai/chat.py, ai/tools.py,
routes/scenes/scene_object_{add,collision,geometry,personnage_data,
quiz_config}.py, screens/payload/full_game_payload.py,
scripts/build_demo_dialogues.py) qui repetaient chacun le meme
commentaire empile - Sonar (python:S7632) ne parse pas deux
commentaires # sur une ligne, meme si Ruff et Bandit les acceptent
chacun tres bien (contrainte structurelle documentee dans
CODE_QUALITY.md : chaque outil exige son propre mot-cle immediatement
apres un #, aucun format a un seul # ne peut satisfaire les deux a la
fois). Les 6 sites # nosec B608 (SQL dynamique) restent inchanges,
nature differente, hors perimetre de ce refactor.
Verifie : mypy --strict propre (389 fichiers), ruff/bandit/import-linter
clean, suite complete verte (591 tests), scan SonarQube local relance
confirmant S7632 a 7 (1 seul site restant dans le helper lui-meme + les
6 B608), 0 bug (une regression S8371 trouvee et corrigee en route).
- 4 sites |safe repositionnes sur leur ligne exacte (scene_edit.html,
register_2fa.html, onboarding_new.html, play.html) - le marqueur
NOSONAR etait sur la ligne precedente par erreur (meme piege que celui
documente pour S8371 ci-dessus). Confirme par scan que meme corrige,
l'analyseur Web de Sonar ne supporte aucune syntaxe de suppression
inline testee pour la regle Web:S5247 - documente comme limitation
technique connue dans CODE_QUALITY.md plutot que force.
- CODE_QUALITY.md (nouveau) : reference complete du dispositif qualite -
vue d'ensemble par outil, configuration de chacun, commandes de lancement
local, procedure de justification d'une exception (avec le format exact
attendu par Bandit/Ruff/Sonar, verifie empiriquement), table des 10
exceptions documentees, backlog (djLint H021, code smells Sonar).
djLint (H021, styles inline) volontairement saute pour ce commit - meme
backlog assume que les commits precedents, aucun rapport avec ce changement.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
769 lines
33 KiB
Python
769 lines
33 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
|
|
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": <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: 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)
|
|
obj = db.assert_not_none(obj, "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)
|