Phase 3 : hardening qualite de code - typage strict, securite, dead code, a11y

Config strictement stricte partout (ruff, mypy --strict, bandit, vulture,
import-linter, eslint, stylelint), aucune regle desactivee "pour ne pas
casser le build" - l'existant a ete corrige pour la satisfaire plutot que
l'inverse. Hooks pre-commit locaux (language: system) bloquants.

- Typage mypy --strict propage a tout le moteur (db, screens, auth, core,
  ai, routes, puis publish/scripts/tests/app.py/build_css.py).
- Securite : fuite de handle fichier Windows corrigee dans l'export SCORM
  (routes/publish/export_scorm.py), CSRF/RNG non-crypto/xAPI documentes
  (# nosec, # NOSONAR justifies), nouveau db.json_for_script() (echappe
  "</script>" dans le JSON embarque en <script>, 25 sites).
- Architecture : imports circulaires/F811 nettoyes, contrats
  import-linter respectes, code mort retire (vulture).
- Accessibilite : 69 champs de formulaire sans label correctement
  associe corriges (for/id ou aria-label) sur 11 templates.
- ESLint/Stylelint : lot mecanique JS/CSS, regles ajustees puis
  appliquees (aucune desactivee sans verification individuelle).
- Tests : isolation du compte admin partage (nettoyage ponctuel +
  fixture de teardown automatique en filet de securite), suite complete
  verte (591 tests Python, 241 tests JS).
- SonarQube Community Build self-heberge (Docker + PostgreSQL) : rapport
  complet analyse point par point, faux positifs documentes.
- .gitattributes ajoute (LF force) : core.autocrlf=true sur cette machine
  faisait echouer ESLint (linebreak-style) via un bug connu de git
  (checkout "en place" qui ignore l'eol force sur un fichier deja
  present sur disque - contourne en supprimant puis recreant chaque
  fichier suivi).

djLint (H021, styles inline) volontairement saute pour ce commit -
backlog assume, deja documente, traite dans un lot separe.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
william
2026-09-15 16:06:15 +02:00
co-authored by Claude Sonnet 5
parent 7db4803b93
commit c57420c8c9
454 changed files with 16448 additions and 6967 deletions
+11 -5
View File
@@ -3,13 +3,19 @@ screens/ (rendu/données de jeu) : ai.tools définit les outils exposés à
l'agent (Phase 2) sans dépendre du SDK anthropic lui-même, pour rester
testable sans clé API."""
from .tools import TOOLS, dispatch_tool
from .client import get_client, AnthropicNotConfiguredError, MODEL
from .scenario_client import generate_image_url, ScenarioNotConfiguredError, ScenarioGenerationError
from .chat import run_chat_turn
from .client import MODEL, AnthropicNotConfiguredError, get_client
from .scenario_client import ScenarioGenerationError, ScenarioNotConfiguredError, generate_image_url
from .tools import TOOLS, dispatch_tool
__all__ = [
"TOOLS", "dispatch_tool", "get_client", "AnthropicNotConfiguredError", "MODEL",
"generate_image_url", "ScenarioNotConfiguredError", "ScenarioGenerationError",
"TOOLS",
"dispatch_tool",
"get_client",
"AnthropicNotConfiguredError",
"MODEL",
"generate_image_url",
"ScenarioNotConfiguredError",
"ScenarioGenerationError",
"run_chat_turn",
]
+97 -65
View File
@@ -2,12 +2,14 @@
UNIQUEMENT les tools de ai/tools.py, jamais d'écriture directe en base :
tout ce qu'un tour de chat produit est donc TOUJOURS relisable/
modifiable dans l'éditeur normal (même garantie que la Phase 1)."""
import json
from typing import Any
import db
import screens
from .client import get_client, MODEL
from .client import MODEL, get_client
from .tools import TOOLS, dispatch_tool
# Borne dure : jamais une boucle sans fin qui dépenserait sans fin si
@@ -25,10 +27,10 @@ _SYSTEM_PROMPT = (
"Réutilise une variable globale déjà existante UNIQUEMENT si elle "
"représente EXACTEMENT la même information (ex. un score total "
"partagé par tout le jeu) — les variables sont globales à TOUT le "
"jeu, visibles depuis n'importe quel écran, donc un drapeau \"terminé\" "
'jeu, visibles depuis n\'importe quel écran, donc un drapeau "terminé" '
"propre à UN dialogue/quiz précis ne doit JAMAIS être partagé avec un "
"autre dialogue/quiz, même similaire, même sur un autre écran (bug "
"corrigé : un quiz réutilisait par erreur le drapeau \"terminé\" d'un "
'corrigé : un quiz réutilisait par erreur le drapeau "terminé" d\'un '
"AUTRE quiz sans lien, les rendant mutuellement incohérents). Donne à "
"chaque nouveau drapeau un nom qui identifie clairement CE qu'il "
"suit (ex. préfixé par le nom du personnage/objet concerné). "
@@ -40,10 +42,10 @@ _SYSTEM_PROMPT = (
"CE QUE TU NE PEUX PAS FAIRE : tes outils pilotent la logique "
"\"déclencheur -> action\" d'un objet de scène OU de l'écran entier "
"(voir DÉCLENCHEURS/ACTIONS ci-dessous). Forge Engine a un AUTRE "
"système, séparé, de \"flow\" (nœuds/liaisons) pour changer d'écran, "
'système, séparé, de "flow" (nœuds/liaisons) pour changer d\'écran, '
"les minuteurs récurrents, une animation de sprite — tu n'as AUCUN "
"outil pour ça. Si une demande a besoin de cette partie-là (\"passer "
"à l'écran suivant\", \"après 5 secondes\"...), fais quand même tout "
'outil pour ça. Si une demande a besoin de cette partie-là ("passer '
'à l\'écran suivant", "après 5 secondes"...), fais quand même tout '
"ce que tes outils permettent, puis DIS CLAIREMENT dans ta réponse "
"texte ce que tu n'as pas pu faire et pourquoi (cette partie doit "
"être ajoutée à la main dans l'onglet \"Flow\") — ne t'acharne JAMAIS "
@@ -51,29 +53,29 @@ _SYSTEM_PROMPT = (
"permet, ça n'aboutira jamais."
"\n\n"
"DÉCLENCHEURS DISPONIBLES (set_collision_rules, sur un objet) : "
"\"collision\" (contact avec le personnage \"joueur\" — a besoin d'un "
"joueur, voir JOUEUR ET PNJ), \"clic\" (l'objet est cliqué/touché, "
'"collision" (contact avec le personnage "joueur" — a besoin d\'un '
'joueur, voir JOUEUR ET PNJ), "clic" (l\'objet est cliqué/touché, '
"aucun joueur requis — typique d'un panneau, un bouton, un objet "
"d'interface statique), \"survol\" (le pointeur survole l'objet, "
"aucun joueur requis — typique d'une info contextuelle affichée sans "
"action du joueur). DÉCLENCHEUR D'ÉCRAN (set_screen_triggers, SANS "
"objet requis) : \"affichage\" — se déclenche dès que l'écran "
'objet requis) : "affichage" — se déclenche dès que l\'écran '
"apparaît, pour une narration/cinématique d'ouverture. "
"RÈGLE IMPORTANTE : si le créateur ne précise PAS explicitement quel "
"déclencheur utiliser pour un élément donné (ex. \"ajoute un panneau "
'déclencheur utiliser pour un élément donné (ex. "ajoute un panneau '
"d'information\" sans dire si c'est au clic, au survol, ou dès "
"l'affichage), NE CHOISIS PAS toute seule — pose la question dans ta "
"réponse texte avant d'agir. "
"NOUVELLES ACTIONS (utilisables partout où une feuille est attendue, "
"chaînables via \"then\" comme dialogue/variable) : \"surbrillance\" "
'chaînables via "then" comme dialogue/variable) : "surbrillance" '
"(met un objet en valeur, ex. pour guider l'attention du joueur vers "
"la suite), \"visibilite\" (affiche/masque un objet, ex. débloquer un "
"élément), \"son\" (effet sonore ponctuel), \"video\" (joue une vidéo "
"de \"Mes assets\", en plein écran ou en incrustation — bloque la "
"suite de la chaîne jusqu'à la fin, comme un dialogue), \"indication\" "
'la suite), "visibilite" (affiche/masque un objet, ex. débloquer un '
'élément), "son" (effet sonore ponctuel), "video" (joue une vidéo '
'de "Mes assets", en plein écran ou en incrustation — bloque la '
'suite de la chaîne jusqu\'à la fin, comme un dialogue), "indication" '
"(bulle de texte courte près d'un objet, pour un conseil ponctuel — "
"PAS pour une réplique de personnage, utilise \"dialogue\" pour ça), "
"\"attendre\" (suspend la chaîne \"then\" pendant data_value SECONDES "
'PAS pour une réplique de personnage, utilise "dialogue" pour ça), '
'"attendre" (suspend la chaîne "then" pendant data_value SECONDES '
"avant de continuer — utile pour laisser un temps de lecture après "
"une narration à l'affichage de l'écran, avant d'enchaîner sur un "
"dialogue/une autre action)."
@@ -97,13 +99,13 @@ _SYSTEM_PROMPT = (
"VISIBLE (coin haut-gauche à (0,0)) — place tout objet important "
"(personnages, widgets d'interface) DANS cette zone par défaut. Le "
"moteur RAMÈNE automatiquement dans le cadre toute position qui en "
"sortirait (voir un éventuel champ \"note\" dans le résultat de "
'sortirait (voir un éventuel champ "note" dans le résultat de '
"set_object_geometry/add_scene_object — c'est déjà corrigé, rien à "
"refaire), donc vise une position raisonnable sans stresser sur le "
"pixel exact. Ne superpose jamais deux objets aux mêmes coordonnées "
"— espace-les clairement. Un personnage fraîchement posé fait "
"128x128 px par défaut — garde des tailles cohérentes entre "
"personnages sauf besoin explicite. RÈGLE FIXE pour un \"fond\" : "
'personnages sauf besoin explicite. RÈGLE FIXE pour un "fond" : '
"redimensionne-le TOUJOURS automatiquement en 2000x1000 px "
"(set_object_geometry, position (0,0)) juste après l'avoir posé, "
"SANS que le créateur ait besoin de le demander à chaque fois — "
@@ -112,37 +114,37 @@ _SYSTEM_PROMPT = (
"grand."
"\n\n"
"JOUEUR ET PNJ : un personnage fraîchement posé a TOUJOURS le rôle "
"\"pnj\" par défaut, JAMAIS \"joueur\". Un déclencheur de type "
"\"collision\" ne se déclenche QUE par le contact du personnage au "
"rôle \"joueur\" — sans lui, la collision ne se déclenche jamais ET "
'"pnj" par défaut, JAMAIS "joueur". Un déclencheur de type '
'"collision" ne se déclenche QUE par le contact du personnage au '
'rôle "joueur" — sans lui, la collision ne se déclenche jamais ET '
"la caméra n'a personne à suivre. Dès qu'un écran a besoin d'un "
"déclencheur de collision (quiz, dialogue déclenché en marchant "
"vers un PNJ, etc.), assure-toi qu'IL EXISTE EXACTEMENT UN "
"personnage avec role=\"joueur\" (set_object_role) — jamais deux, "
'personnage avec role="joueur" (set_object_role) — jamais deux, '
"et ne pose jamais deux PNJ identiques sans que le créateur l'ait "
"demandé. "
"RÈGLE IMPORTANTE : si le créateur ne précise pas comment un "
"déclencheur \"collision\" doit se comporter, demande-lui si "
'déclencheur "collision" doit se comporter, demande-lui si '
"l'action doit se déclencher IMMÉDIATEMENT au contact, ou si le "
"joueur doit d'abord APPUYER SUR UNE TOUCHE (action \"interagir\", "
"qui affiche \"Appuie sur [touche]\" tant que le contact dure) avant "
'joueur doit d\'abord APPUYER SUR UNE TOUCHE (action "interagir", '
'qui affiche "Appuie sur [touche]" tant que le contact dure) avant '
"que l'action ne se déclenche — ne suppose jamais l'un ou l'autre "
"toi-même sur une scène avec joueur+collision."
"\n\n"
"QUAND UTILISER QUOI : une VARIABLE globale sert à mémoriser une "
"donnée consultée plus tard (progression, un choix du joueur, un "
"drapeau \"terminé\") — JAMAIS pour un score de quiz (voir "
'drapeau "terminé") — JAMAIS pour un score de quiz (voir '
"reward_amount plus haut, déjà automatique). Modifie une variable "
"(action \"variable\") quand un événement doit changer durablement "
"cet état (ex. marquer un drapeau \"xxx_termine\" à vrai une fois un "
'(action "variable") quand un événement doit changer durablement '
'cet état (ex. marquer un drapeau "xxx_termine" à vrai une fois un '
"quiz fini). Utilise une CONDITION quand le comportement doit "
"VRAIMENT différer selon l'état actuel d'une variable — un simple "
"enchaînement linéaire n'a besoin que d'un chaînage \"then\", jamais "
"d'une condition. CAS CANONIQUE à connaître : un dialogue/quiz à "
"USAGE UNIQUE (ex. un PNJ qui pose un quiz une seule fois) doit être "
"protégé par une CONDITION qui vérifie D'ABORD le drapeau "
"\"xxx_termine\" — si faux (pas encore fait), lance le dialogue/quiz "
"normal (branche si_faux) PUIS termine par une action \"variable\" "
'"xxx_termine" — si faux (pas encore fait), lance le dialogue/quiz '
'normal (branche si_faux) PUIS termine par une action "variable" '
"qui passe ce drapeau à vrai ; si vrai (déjà fait), réponds par une "
"réplique courte différente (branche si_vrai) au lieu de rejouer "
"tout le quiz à chaque collision. Utilise un DIALOGUE pour toute "
@@ -152,31 +154,31 @@ _SYSTEM_PROMPT = (
"JEU : il faut TOUJOURS poser en plus le widget d'interface "
"correspondant sur l'écran via add_scene_object, sinon rien n'apparaît "
"à l'écran même si le déclencheur est correctement configuré. Une "
"action \"dialogue\" dont les lignes sont de type \"replique\" a besoin "
"d'un objet kind=\"dialogue_box\" ; une action \"dialogue\" dont les "
"lignes sont de type \"question\" (quiz) a besoin d'un objet "
"kind=\"quiz_box\" (pas dialogue_box) ; si un score/des points sont "
"utilisés, ajoute aussi un objet kind=\"score_widget\" pour qu'il soit "
'action "dialogue" dont les lignes sont de type "replique" a besoin '
'd\'un objet kind="dialogue_box" ; une action "dialogue" dont les '
'lignes sont de type "question" (quiz) a besoin d\'un objet '
'kind="quiz_box" (pas dialogue_box) ; si un score/des points sont '
'utilisés, ajoute aussi un objet kind="score_widget" pour qu\'il soit '
"visible en continu. Positionne ces widgets à un endroit raisonnable "
"de l'écran (ex. centré, ou en haut) via set_object_geometry après "
"les avoir posés."
"\n\n"
"QUIZ AUTONOME (RH/formation, pas narratif) : quand le créateur décrit "
"un besoin de quiz \"tout seul\" (pas un personnage/dialogue de jeu), "
"utilise set_quiz_box_config sur l'objet kind=\"quiz_box\" pour régler "
'un besoin de quiz "tout seul" (pas un personnage/dialogue de jeu), '
'utilise set_quiz_box_config sur l\'objet kind="quiz_box" pour régler '
"le PLEIN ÉCRAN (fullscreen) et un MINUTEUR pour répondre (timer_mode : "
"jamais imposé par défaut — demande TOUJOURS si le créateur en veut un "
"avant d'en activer un, \"question\" redémarre à chaque question, "
"\"quiz\" est un seul compte à rebours pour tout le quiz, timer_seconds "
'avant d\'en activer un, "question" redémarre à chaque question, '
'"quiz" est un seul compte à rebours pour tout le quiz, timer_seconds '
"sa durée)."
"\n\n"
"MODÈLES VISUELS — DEUX catégories bien distinctes, jamais confondues : "
"dialog_template (modèles \"boîte de dialogue\" : \"defaut\" — Classique — "
"ou \"manga_dialogue\", le pendant en petite carte du thème manga) ne "
'dialog_template (modèles "boîte de dialogue" : "defaut" — Classique — '
'ou "manga_dialogue", le pendant en petite carte du thème manga) ne '
"s'affiche QUE si fullscreen=false. "
"page_template (modèles \"page de quiz\" : \"classique\" — sobre/"
"professionnel, couleurs reprises du modèle \"boîte de dialogue\" de "
"base — ou \"manga\", un thème dessiné/typographié entièrement à part) "
'page_template (modèles "page de quiz" : "classique" — sobre/'
'professionnel, couleurs reprises du modèle "boîte de dialogue" de '
'base — ou "manga", un thème dessiné/typographié entièrement à part) '
"ne s'affiche QUE si fullscreen=true — chacun un thème complet et "
"autonome (police, formes, couleurs ET structure entièrement propres à "
"ce modèle), pensé pour un quiz qui occupe tout l'écran. Les deux "
@@ -194,13 +196,26 @@ _SYSTEM_PROMPT = (
)
def _describe_scene_state(slug, screen_id):
class ScreenDeletedError(Exception):
"""Garde-fou défensif pour screen_id introuvable : en usage normal,
supprimer un écran (screens/screens_repo/delete_screen.py) supprime
déjà EN CASCADE ses conversations IA (ON DELETE CASCADE, voir
screens/ia/ensure_ia_chat_schema.py) — cette voie n'est donc pas
censée être atteignable via l'appli. get_screen() renvoyant
dict | None, ce garde évite quand même un crash cru (attribut sur
None) si jamais screen_id était invalide pour une autre raison,
plutôt qu'un message lisible par un humain (voir run_chat_turn)."""
def _describe_scene_state(slug: str, screen_id: int) -> str:
"""Contexte dynamique (jamais mémorisé côté Claude entre les tours,
voir run_chat_turn — seul le texte final est persisté) : sans ça,
Ruby ne "voit" jamais ce qui existe déjà sur l'écran et duplique des
objets au lieu de les corriger (bug observé : 2 PNJ identiques créés
à la place d'un seul joueur + un pnj)."""
screen = screens.get_screen(slug, screen_id)
if screen is None:
raise ScreenDeletedError("L'écran de cette conversation a été supprimé.")
width, height = screen["scene_width"], screen["scene_height"]
objects = screens.list_scene_objects(slug, screen_id)
# Répété ICI (pas seulement dans les instructions générales) avec les
@@ -251,11 +266,11 @@ def _describe_scene_state(slug, screen_id):
return "\n".join(lines)
def _history_to_messages(history):
def _history_to_messages(history: list[dict[str, Any]]) -> list[Any]:
return [{"role": m["role"], "content": m["content"]} for m in history]
def run_chat_turn(slug, screen_id, conversation_id, user_id, user_message):
def run_chat_turn(slug: str, screen_id: int, conversation_id: int, user_id: int, user_message: str) -> str:
"""Un tour complet : reprend l'historique persisté de CETTE
conversation, ajoute le message du créateur, boucle tant que Claude
appelle des outils, et renvoie le texte final. `screen_id` reste
@@ -264,40 +279,57 @@ def run_chat_turn(slug, screen_id, conversation_id, user_id, user_message):
d'écran). Ne persiste RIEN elle-même — voir routes/ia/ia_chat.py,
seul appelant, qui décide de ce qui est sauvegardé (même séparation
que le reste du moteur : cette fonction ne fait que la logique IA)."""
try:
# État réel de la scène RE-LU à chaque tour (jamais mémorisé par
# Claude lui-même) — voir _describe_scene_state. Vérifié AVANT de
# construire le client Claude : pas la peine d'appeler l'API si
# l'écran de cette conversation n'existe plus.
scene_state = _describe_scene_state(slug, screen_id)
except ScreenDeletedError:
return "Cet écran a été supprimé — cette conversation n'est plus utilisable."
client = get_client() # AnthropicNotConfiguredError si pas de clé
messages = _history_to_messages(screens.list_ia_chat_messages(slug, conversation_id))
messages: list[Any] = _history_to_messages(screens.list_ia_chat_messages(slug, conversation_id))
messages.append({"role": "user", "content": user_message})
# État réel de la scène RE-LU à chaque tour (jamais mémorisé par
# Claude lui-même) — voir _describe_scene_state.
system_prompt = _SYSTEM_PROMPT + "\n\n" + _describe_scene_state(slug, screen_id)
system_prompt = _SYSTEM_PROMPT + "\n\n" + scene_state
response = None
for _ in range(_MAX_TOOL_ITERATIONS):
response = client.messages.create(
# 4096 était trop bas (bug corrigé) : la réflexion adaptative
# partage le même budget que la réponse — sur une demande
# riche (plusieurs objets + logique + texte), Claude pouvait
# être coupé EN PLEINE RÉFLEXION, avant le moindre appel
# d'outil (symptôme observé : aucune progression du tout,
# "(pas de réponse textuelle)" dès le premier tour).
model=MODEL, max_tokens=16000, system=system_prompt,
tools=TOOLS, thinking={"type": "adaptive"}, messages=messages,
# 4096 était trop bas (bug corrigé) : la réflexion adaptative
# partage le même budget que la réponse — sur une demande riche
# (plusieurs objets + logique + texte), Claude pouvait être coupé
# EN PLEINE RÉFLEXION, avant le moindre appel d'outil (symptôme
# observé : aucune progression du tout, "(pas de réponse
# textuelle)" dès le premier tour).
response = client.messages.create( # type: ignore[call-overload] # TOOLS/messages sont des dict Python bruts, pas les TypedDict exacts du SDK anthropic
model=MODEL,
max_tokens=16000,
system=system_prompt,
tools=TOOLS,
thinking={"type": "adaptive"},
messages=messages,
)
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason != "tool_use":
break
tool_results = []
tool_results: list[Any] = []
for block in response.content:
if block.type == "tool_use":
try:
result = dispatch_tool(slug, screen_id, user_id, block.name, block.input)
except Exception as e:
result = {"error": str(e)}
tool_results.append({
"type": "tool_result", "tool_use_id": block.id, "content": json.dumps(result),
})
tool_results.append(
{
"type": "tool_result",
"tool_use_id": block.id,
"content": json.dumps(result),
}
)
messages.append({"role": "user", "content": tool_results})
assert response is not None, ( # nosec B101 # noqa: S101 - narrowing de type, sur par construction
"_MAX_TOOL_ITERATIONS > 0, la boucle for s'execute donc au moins une fois"
)
text = next((b.text for b in response.content if b.type == "text"), "")
if text:
return text
+3 -4
View File
@@ -1,6 +1,7 @@
"""Client Claude (voir plan Phase 2, §2) — même politique que
auth/send_email.py::EmailNotConfiguredError : une clé absente est une
configuration incomplète, jamais un crash brut ni une clé en dur."""
import os
import anthropic
@@ -14,10 +15,8 @@ class AnthropicNotConfiguredError(Exception):
il faut un compte Anthropic Console (console.anthropic.com) séparé."""
def get_client():
def get_client() -> anthropic.Anthropic:
api_key = os.environ.get("ANTHROPIC_API_KEY")
if not api_key:
raise AnthropicNotConfiguredError(
"ANTHROPIC_API_KEY absente"
)
raise AnthropicNotConfiguredError("ANTHROPIC_API_KEY absente")
return anthropic.Anthropic(api_key=api_key)
+9 -4
View File
@@ -9,6 +9,7 @@ Ce module reste un simple client HTTP : il renvoie l'URL de l'image,
jamais ne la télécharge/l'enregistre lui-même — ça reste le travail de
ai/tools.py::_dispatch_add_generated_image (voir "Mes assets",
auth/user_assets_dir.py), pour que ce module soit testable seul."""
import os
import time
@@ -28,7 +29,7 @@ class ScenarioGenerationError(Exception):
"""Le job Scenario a échoué ou n'a pas répondu à temps."""
def _credentials():
def _credentials() -> tuple[tuple[str, str], str]:
api_key = os.environ.get("SCENARIO_API_KEY")
api_secret = os.environ.get("SCENARIO_API_SECRET")
model_id = os.environ.get("SCENARIO_MODEL_ID")
@@ -39,14 +40,18 @@ def _credentials():
return (api_key, api_secret), model_id
def generate_image_url(prompt, width=768, height=768, timeout=120, poll_interval=2):
def generate_image_url(
prompt: str, width: int = 768, height: int = 768, timeout: int = 120, poll_interval: int = 2
) -> str:
"""Lance une génération txt2img et attend le résultat — renvoie
l'URL finale de l'image (hébergée par Scenario, à télécharger par
l'appelant). Bloquant (poll_interval secondes entre chaque
vérification), borné par `timeout` secondes au total."""
auth, model_id = _credentials()
response = requests.post(
f"{_BASE_URL}/generate/txt2img", auth=auth, timeout=30,
f"{_BASE_URL}/generate/txt2img",
auth=auth,
timeout=30,
json={"prompt": prompt, "modelId": model_id, "width": width, "height": height, "numSamples": 1},
)
response.raise_for_status()
@@ -61,7 +66,7 @@ def generate_image_url(prompt, width=768, height=768, timeout=120, poll_interval
asset_id = job["metadata"]["assetIds"][0]
asset_response = requests.get(f"{_BASE_URL}/assets/{asset_id}", auth=auth, timeout=30)
asset_response.raise_for_status()
return asset_response.json()["asset"]["url"]
return str(asset_response.json()["asset"]["url"])
if job["status"] == "failure":
raise ScenarioGenerationError("Scenario a échoué à générer l'image.")
time.sleep(poll_interval)
+204 -80
View File
@@ -20,6 +20,7 @@ DATA_OPERATION_LABELS, ...) plutôt que recopiées en dur. Un test dédié
remplacée par une copie littérale."""
import os
from typing import Any, Callable
import requests
from flask import url_for
@@ -28,12 +29,13 @@ 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 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)
@@ -53,37 +55,49 @@ _GLOBAL_VARIABLE_TYPES = sorted(db.GLOBAL_VARIABLE_TYPES)
_FORGE_CHARACTERS = sorted(screens.SPRITE_LIBRARY)
_BACKGROUNDS = sorted(screens.BACKGROUND_LIBRARY)
_LEAF_ACTION_SCHEMA = {
_LEAF_ACTION_SCHEMA: dict[str, Any] = {
"type": "object",
"description": (
"Une action FEUILLE (\"dialogue\" ou \"variable\", voir "
'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)."
'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."},
"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>}}. "
'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 "
'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."
'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."},
"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\")."},
"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": (
@@ -102,7 +116,7 @@ _LEAF_ACTION_SCHEMA = {
},
"asset_url": {
"type": "string",
"description": "Pour type=son/video : URL d'un fichier déjà présent dans \"Mes assets\".",
"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."},
@@ -112,9 +126,11 @@ _LEAF_ACTION_SCHEMA = {
"required": ["type"],
}
_ACTION_SCHEMA = {
_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.",
"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"},
@@ -129,8 +145,12 @@ _ACTION_SCHEMA = {
"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)."},
"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."},
@@ -139,21 +159,43 @@ _ACTION_SCHEMA = {
"required": ["type"],
}
TOOLS = [
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\", "
'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."},
"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"],
},
@@ -165,8 +207,10 @@ TOOLS = [
"type": "object",
"properties": {
"object_id": {"type": "integer"},
"x": {"type": "number"}, "y": {"type": "number"},
"width": {"type": "number"}, "height": {"type": "number"},
"x": {"type": "number"},
"y": {"type": "number"},
"width": {"type": "number"},
"height": {"type": "number"},
},
"required": ["object_id", "x", "y", "width", "height"],
},
@@ -174,8 +218,8 @@ TOOLS = [
{
"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."
'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",
@@ -207,8 +251,10 @@ TOOLS = [
"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"},
"width": {"type": "number"},
"height": {"type": "number"},
"offset_x": {"type": "number"},
"offset_y": {"type": "number"},
},
"required": ["object_id"],
},
@@ -216,13 +262,13 @@ TOOLS = [
{
"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 "
'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 "
'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."
),
@@ -232,16 +278,29 @@ TOOLS = [
"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."},
"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.",
"description": (
"Crée une variable globale (idempotent par nom) — utilisable ensuite dans les déclencheurs/conditions."
),
"input_schema": {
"type": "object",
"properties": {
@@ -256,10 +315,10 @@ TOOLS = [
{
"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 "
'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 "
@@ -316,7 +375,7 @@ TOOLS = [
"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 "
'ça) via Scenario, l\'ajoute à "Mes assets" et la pose '
"automatiquement sur l'écran en cours."
),
"input_schema": {
@@ -370,9 +429,9 @@ TOOLS = [
"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 "
'"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": {
@@ -397,7 +456,7 @@ TOOLS = [
"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 "
'("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."
),
@@ -426,7 +485,9 @@ TOOLS = [
]
def _clamp_to_camera(slug, screen_id, kind, x, y, width, height):
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
@@ -456,7 +517,15 @@ def _clamp_to_camera(slug, screen_id, kind, x, y, width, 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):
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
@@ -481,59 +550,90 @@ def _dispatch_add_scene_object(slug, screen_id, user_id, kind, forge_character=N
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,
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"])
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}
result: dict[str, Any] = {"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):
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 = {"ok": True}
result: dict[str, Any] = {"ok": True}
if note:
result["note"] = note
return result
def _dispatch_set_object_name(slug, screen_id, user_id, object_id, name):
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, screen_id, user_id, object_id, role):
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, screen_id, user_id, object_id, enabled=True, shape="rectangle",
width=None, height=None, offset_x=0, offset_y=0):
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 = {
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,
"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, screen_id, user_id, object_id, fullscreen=False,
timer_mode="aucun", timer_seconds=20,
dialog_template="defaut", page_template="classique"):
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
@@ -541,7 +641,7 @@ def _dispatch_set_quiz_box_config(slug, screen_id, user_id, object_id, fullscree
# 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 = {
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,
@@ -552,54 +652,76 @@ def _dispatch_set_quiz_box_config(slug, screen_id, user_id, object_id, fullscree
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)
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, screen_id, user_id, object_id, rules):
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, screen_id, user_id, object_id, after_id, action):
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, screen_id, user_id, rules):
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, screen_id, user_id, after_id, action):
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, screen_id, user_id, object_id, leaf_id):
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, screen_id, user_id, leaf_id):
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, screen_id, user_id, object_id, leaf_id, direction):
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, screen_id, user_id, leaf_id, direction):
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, screen_id, user_id, kind, prompt):
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
@@ -615,7 +737,7 @@ def _dispatch_add_generated_image(slug, screen_id, user_id, kind, prompt):
return {"object_id": object_id, "asset_id": asset_id}
_DISPATCH = {
_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,
@@ -635,7 +757,9 @@ _DISPATCH = {
}
def dispatch_tool(slug, screen_id, user_id, tool_name, tool_input):
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