Files
Forge-Engine/document_engine/labels/scenario_config.py
T
williamandClaude Sonnet 5 a529857379
Build and deploy / test-python (push) Failing after 1m15s
Build and deploy / test-js (push) Successful in 54s
Build and deploy / lint-python (push) Failing after 1m12s
Build and deploy / lint-js (push) Failing after 1m8s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Build and deploy / sonarqube (push) Failing after 1m4s
Refonte du Scenario en arbre de decision (modale dediee, plus de vrai/faux
Retour utilisateur : "une consequence peut mener a d'autres choix et
ainsi de suite, il n'y a pas de notion vrai/faux, il faut une modale
avec la possibilite de construire un veritable arbre de choix
consequence". Remplace le modele plat (situation + 2-4 choix + un seul
bon choix + une consequence terminale) par un vrai graphe de nœuds :
{"title", "nodes": [{"id", "text", "choices": [{"text", "target_id"}]}]}
- nodes[0] est la situation initiale, chaque choix peut pointer vers
n'importe quel autre nœud (branchement, convergence, fins multiples),
un nœud sans choix est une fin de branche valide. Aucune notion de
bonne/mauvaise reponse.

L'arbre se construit desormais dans une modale dediee (trop de
structure pour la colonne etroite du panneau Proprietes) : liste de
nœuds, chaque choix avec un menu deroulant "mene a" listant les autres
nœuds ou "fin de branche". La modale est un composant generique
(.docModal*) independant de tout framework externe.

sanitize_scenario_config degrade silencieusement tout target_id
orphelin (nœud supprime) vers None plutot que de faire echouer le
scenario entier. Le lecteur cote client navigue le graphe nœud par
nœud, le texte du nœud visite remplace le precedent (toujours pas un
Quiz), jusqu'a une fin de branche puis passage au scenario suivant.

Verifie via simulation DOM reelle (jsdom) : navigation ramifiee
(branchement, convergence, fin via nœud vide ET via choix sans cible),
plusieurs arbres a la suite, et l'editeur modal complet (ouverture,
ajout/suppression de nœud avec reparation des references pendantes,
changement de cible, fermeture bouton/fond/Echap).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 17:29:24 +02:00

119 lines
5.1 KiB
Python

"""Modèle de données du mini-jeu Scénario (voir docs/plan/PLAN.md §3.2) —
l'apprenant lit une situation initiale, choisit une option, et découvre
la conséquence de SON choix ; cette conséquence peut elle-même mener à
de nouveaux choix, et ainsi de suite (arbre de décision, voir
document_engine/rendering/render_document_element.py::
_render_scenario_player). Aucune notion de bonne/mauvaise réponse
(retour utilisateur du 20/09/2026 : "il n'y a pas de notion vrai/faux,
l'utilisateur observe les conséquences") — l'apprenant explore
simplement les branches. Le créateur peut définir plusieurs scénarios
(plusieurs arbres indépendants), joués les uns après les autres dans
l'ordre d'écriture. Même convention resolve_X/sanitize_X que
quiz_config.py/association_config.py/memory_config.py/mots_config.py
(aucun import croisé) : sanitize_scenario_config est pure, ne lève
jamais, et renvoie toujours un dict complet.
Structure d'un scénario : {"title": str, "nodes": [...]}. `nodes[0]` est
TOUJOURS la situation initiale (racine de l'arbre, jamais supprimable
depuis le panneau Propriétés). Chaque nœud est
{"id": str, "text": str, "choices": [...]} — `id` est une référence
STABLE (générée côté client, jamais recalculée ici) vers laquelle un
choix d'un AUTRE nœud peut pointer via son `target_id` ; un nœud sans
choix est une fin de branche. Un choix est {"text": str,
"target_id": str | None} — `target_id` à None signifie "fin de branche
à ce choix" (aucun nœud suivant)."""
from typing import Any
MAX_SCENARIO_CHOICES = 4
DEFAULT_SCENARIO_CONFIG: dict[str, Any] = {
"theme_color": "#ff5f2e",
"scenarios": [],
}
def _sanitize_scenario_choice(raw: Any) -> dict[str, Any] | None:
"""None si le choix est invalide (texte vide) — filtrée par
_sanitize_scenario_node plutôt que de faire échouer tout le nœud,
même convention que quiz_config.py::_sanitize_question. `target_id`
est vérifié/nettoyé une seconde fois par _sanitize_scenario (une fois
l'ensemble des ids valides du scénario connu), jamais ici."""
if not isinstance(raw, dict):
return None
text = str(raw.get("text", "")).strip()
if not text:
return None
target_id = raw.get("target_id")
return {"text": text, "target_id": target_id if isinstance(target_id, str) and target_id else None}
def _sanitize_scenario_node(raw: Any) -> dict[str, Any] | None:
"""None si le nœud est invalide (id absent, ou texte vide) — même
convention que les autres sanitize_X de ce module. Un nœud SANS choix
est parfaitement valide (fin de branche), contrairement à l'ancien
modèle où un scénario exigeait 2 à 4 choix : ici, 0 choix est un état
normal, pas une erreur."""
if not isinstance(raw, dict):
return None
node_id = raw.get("id")
if not isinstance(node_id, str) or not node_id:
return None
text = str(raw.get("text", "")).strip()
if not text:
return None
raw_choices = raw.get("choices")
choices = []
if isinstance(raw_choices, list):
choices = [c for c in (_sanitize_scenario_choice(item) for item in raw_choices) if c is not None]
choices = choices[:MAX_SCENARIO_CHOICES]
return {"id": node_id, "text": text, "choices": choices}
def _sanitize_scenario(raw: Any) -> dict[str, Any] | None:
"""None si le scénario est invalide (titre vide, ou aucun nœud
valide restant après nettoyage — il faut au moins la situation
initiale). Un choix qui pointait vers un nœud devenu invalide/
supprimé dégrade silencieusement vers `target_id: None` ("fin de
branche") plutôt que de faire échouer tout le scénario — même
philosophie que le reste de ce module : ne jamais lever, ne jamais
faire échouer une structure entière pour une seule référence
cassée."""
if not isinstance(raw, dict):
return None
title = str(raw.get("title", "")).strip()
if not title:
return None
raw_nodes = raw.get("nodes")
if not isinstance(raw_nodes, list):
return None
nodes = []
seen_ids: set[str] = set()
for item in raw_nodes:
node = _sanitize_scenario_node(item)
if node is None or node["id"] in seen_ids:
continue
seen_ids.add(node["id"])
nodes.append(node)
if not nodes:
return None
valid_ids = {n["id"] for n in nodes}
for node in nodes:
node["choices"] = [
{**c, "target_id": c["target_id"] if c["target_id"] in valid_ids else None} for c in node["choices"]
]
return {"title": title, "nodes": nodes}
def sanitize_scenario_config(raw_config: Any) -> dict[str, Any]:
config = dict(DEFAULT_SCENARIO_CONFIG)
if not isinstance(raw_config, dict):
return config
theme_color = raw_config.get("theme_color")
if isinstance(theme_color, str) and theme_color:
config["theme_color"] = theme_color
raw_scenarios = raw_config.get("scenarios")
if isinstance(raw_scenarios, list):
config["scenarios"] = [s for s in (_sanitize_scenario(item) for item in raw_scenarios) if s is not None]
return config