Add JSON schemas, data validator, and first CE1 content

Introduces a generic JSON-Schema-subset validator in GDScript
(scripts/data/schema_validator.gd) used both by `make validate-data`
and, later, by ExerciseEngine at load time, plus semantic_checks.gd
for cross-field constraints a schema can't express (answer index
bounds, at least one faulty token in sentence correction, word count
matching between a dictation's target_text and its annotations).

Schemas cover rule, exercise (dispatched per type: multiple_choice,
sentence_correction), dictation, curriculum, and save (save.schema.json
is not yet wired into validate-data; saves live in user://, handled
at the Profiles/Saves step).

First draft content for the CE1 "accord_adjectif" rule (one rule, two
multiple-choice exercises, one sentence correction, one dictation,
one curriculum entry) exercises the full pipeline end to end and
seeds docs/review/content-review.md, regenerated by the new
tools/generate_content_review.py (`make content-review`).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Denis Defolie
2026-10-11 16:47:28 +02:00
parent a1f749de40
commit d057c8ee77
31 changed files with 938 additions and 35 deletions
+1
View File
@@ -16,6 +16,7 @@ Le `Makefile` est le seul point d'entrée pour les opérations système. Tenir c
- `make validate-data` : valide tous les JSON de `data/` contre leurs schémas
- `make test` : tests en mode headless
- `make check` : lint + validate-data + test (à passer avant chaque commit)
- `make content-review` : régénère `docs/review/content-review.md` à partir de `data/`
- `make export-linux` / `make export-windows` : exports dans `build/`
## Conventions de code
+8 -2
View File
@@ -2,9 +2,11 @@ GODOT_BIN := .godot-bin/godot
VENV := .venv
GDLINT := $(VENV)/bin/gdlint
GDFORMAT := $(VENV)/bin/gdformat
PYRIGHT := $(VENV)/bin/pyright
PYTHON := $(VENV)/bin/python3
GD_SOURCES := autoload scripts tests
.PHONY: setup run lint format validate-data test check export-linux export-windows
.PHONY: setup run lint format validate-data test check content-review export-linux export-windows
setup:
tools/setup.sh
@@ -14,6 +16,7 @@ run:
lint:
$(GDLINT) $(GD_SOURCES)
$(PYRIGHT) tools/
format:
$(GDFORMAT) $(GD_SOURCES)
@@ -22,10 +25,13 @@ validate-data:
$(GODOT_BIN) --headless --path . -s res://scripts/data/validate_data_cli.gd
test:
$(GODOT_BIN) --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -gexit
$(GODOT_BIN) --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit
check: lint validate-data test
content-review:
$(PYTHON) tools/generate_content_review.py
export-linux:
@echo "Export Linux : pas encore configure (voir Ordre de construction, etape 6)."
@exit 1
View File
@@ -0,0 +1,8 @@
{
"level": "CE1",
"region_id": "foret_des_accords",
"chapter_id": "accords_groupe_nominal",
"title": "Les accords dans le groupe nominal",
"rule_ids": ["regle_accord_adjectif"],
"review_status": "draft"
}
View File
@@ -0,0 +1,18 @@
{
"id": "ce1_accord_adjectif_dictee_01",
"level": "CE1",
"domain": "grammaire",
"rule_ids": ["regle_accord_adjectif"],
"difficulty": 1,
"target_text": "Les petits chats dorment.",
"words": [
{ "text": "Les", "expects_capital": true },
{ "text": "petits", "rule_id": "regle_accord_adjectif" },
{ "text": "chats" },
{ "text": "dorment." }
],
"audio_id": "ce1_accord_adjectif_dictee_01",
"audio_provisional": true,
"accepted_variants": [],
"review_status": "draft"
}
View File
@@ -0,0 +1,25 @@
{
"id": "ce1_accord_adjectif_correction_01",
"level": "CE1",
"domain": "grammaire",
"skill": "accord_groupe_nominal",
"rule_id": "regle_accord_adjectif",
"difficulty": 2,
"type": "sentence_correction",
"instruction": "Touche le mot qui contient une erreur, puis corrige-le.",
"tokens": [
{ "text": "Les", "correct": true },
{
"text": "petit",
"correct": false,
"expected": "petits",
"rule_id": "regle_accord_adjectif"
},
{ "text": "chiens", "correct": true },
{ "text": "courent.", "correct": true }
],
"hint": "Le determinant « les » indique un pluriel.",
"explanation": "L'adjectif « petit » s'accorde avec le nom « chiens », qui est au pluriel : il faut ecrire « petits ».",
"review_tags": ["pluriel", "accord_adjectif"],
"review_status": "draft"
}
@@ -0,0 +1,17 @@
{
"id": "ce1_accord_adjectif_qcm_01",
"level": "CE1",
"domain": "grammaire",
"skill": "accord_groupe_nominal",
"rule_id": "regle_accord_adjectif",
"difficulty": 1,
"type": "multiple_choice",
"instruction": "Choisis le bon groupe de mots.",
"question": "Les ... chiens jouent.",
"answers": ["petit", "petits", "petite"],
"correct_answer": 1,
"hint": "Observe le determinant « les » et le nom « chiens ».",
"explanation": "Le groupe nominal est au pluriel. L'adjectif prend ici la marque du pluriel.",
"review_tags": ["pluriel", "accord_adjectif"],
"review_status": "draft"
}
@@ -0,0 +1,17 @@
{
"id": "ce1_accord_adjectif_qcm_02",
"level": "CE1",
"domain": "grammaire",
"skill": "accord_groupe_nominal",
"rule_id": "regle_accord_adjectif",
"difficulty": 1,
"type": "multiple_choice",
"instruction": "Choisis le bon groupe de mots.",
"question": "La ... maison est jolie.",
"answers": ["petit", "petite", "petites"],
"correct_answer": 1,
"hint": "Observe le determinant « la » et le nom « maison ».",
"explanation": "Le nom « maison » est feminin singulier. L'adjectif prend donc la marque du feminin.",
"review_tags": ["feminin", "accord_adjectif"],
"review_status": "draft"
}
View File
+9
View File
@@ -0,0 +1,9 @@
{
"rule_id": "regle_accord_adjectif",
"level": "CE1",
"domain": "grammaire",
"skill": "accord_groupe_nominal",
"statement": "Quand un adjectif accompagne un nom, il s'accorde avec ce nom en genre et en nombre.",
"example": "Un petit chien. Une petite chienne. Des petits chiens. Des petites chiennes.",
"review_status": "draft"
}
+24
View File
@@ -0,0 +1,24 @@
{
"type": "object",
"required": [
"level",
"region_id",
"chapter_id",
"title",
"rule_ids",
"review_status"
],
"additionalProperties": false,
"properties": {
"level": { "type": "string", "enum": ["CE1", "CE2"] },
"region_id": { "type": "string", "pattern": "^[a-z][a-z0-9_]*$" },
"chapter_id": { "type": "string", "pattern": "^[a-z][a-z0-9_]*$" },
"title": { "type": "string", "minLength": 1 },
"rule_ids": {
"type": "array",
"minItems": 1,
"items": { "type": "string", "pattern": "^[a-z][a-z0-9_]*$" }
},
"review_status": { "type": "string", "enum": ["draft", "validated"] }
}
}
+47
View File
@@ -0,0 +1,47 @@
{
"type": "object",
"required": [
"id",
"level",
"domain",
"rule_ids",
"difficulty",
"target_text",
"words",
"audio_id",
"audio_provisional",
"accepted_variants",
"review_status"
],
"additionalProperties": false,
"properties": {
"id": { "type": "string", "pattern": "^[a-z][a-z0-9_]*$" },
"level": { "type": "string", "enum": ["CE1", "CE2"] },
"domain": { "type": "string", "enum": ["grammaire", "orthographe"] },
"rule_ids": {
"type": "array",
"minItems": 1,
"items": { "type": "string", "pattern": "^[a-z][a-z0-9_]*$" }
},
"difficulty": { "type": "integer", "minimum": 1, "maximum": 3 },
"target_text": { "type": "string", "minLength": 1 },
"words": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"required": ["text"],
"additionalProperties": false,
"properties": {
"text": { "type": "string", "minLength": 1 },
"rule_id": { "type": "string", "pattern": "^[a-z][a-z0-9_]*$" },
"expects_capital": { "type": "boolean" }
}
}
},
"audio_id": { "type": "string", "pattern": "^[a-z][a-z0-9_]*$" },
"audio_provisional": { "type": "boolean" },
"accepted_variants": { "type": "array", "items": { "type": "string" } },
"review_status": { "type": "string", "enum": ["draft", "validated"] }
}
}
@@ -0,0 +1,43 @@
{
"type": "object",
"required": [
"id",
"level",
"domain",
"skill",
"rule_id",
"difficulty",
"type",
"instruction",
"question",
"answers",
"correct_answer",
"hint",
"explanation",
"review_tags",
"review_status"
],
"additionalProperties": false,
"properties": {
"id": { "type": "string", "pattern": "^[a-z][a-z0-9_]*$" },
"level": { "type": "string", "enum": ["CE1", "CE2"] },
"domain": { "type": "string", "enum": ["grammaire", "orthographe"] },
"skill": { "type": "string", "pattern": "^[a-z][a-z0-9_]*$" },
"rule_id": { "type": "string", "pattern": "^[a-z][a-z0-9_]*$" },
"difficulty": { "type": "integer", "minimum": 1, "maximum": 3 },
"type": { "type": "string", "const": "multiple_choice" },
"instruction": { "type": "string", "minLength": 1 },
"question": { "type": "string", "minLength": 1 },
"answers": {
"type": "array",
"minItems": 2,
"maxItems": 6,
"items": { "type": "string", "minLength": 1 }
},
"correct_answer": { "type": "integer", "minimum": 0 },
"hint": { "type": "string", "minLength": 1 },
"explanation": { "type": "string", "minLength": 1 },
"review_tags": { "type": "array", "items": { "type": "string" } },
"review_status": { "type": "string", "enum": ["draft", "validated"] }
}
}
@@ -0,0 +1,48 @@
{
"type": "object",
"required": [
"id",
"level",
"domain",
"skill",
"rule_id",
"difficulty",
"type",
"instruction",
"tokens",
"hint",
"explanation",
"review_tags",
"review_status"
],
"additionalProperties": false,
"properties": {
"id": { "type": "string", "pattern": "^[a-z][a-z0-9_]*$" },
"level": { "type": "string", "enum": ["CE1", "CE2"] },
"domain": { "type": "string", "enum": ["grammaire", "orthographe"] },
"skill": { "type": "string", "pattern": "^[a-z][a-z0-9_]*$" },
"rule_id": { "type": "string", "pattern": "^[a-z][a-z0-9_]*$" },
"difficulty": { "type": "integer", "minimum": 1, "maximum": 3 },
"type": { "type": "string", "const": "sentence_correction" },
"instruction": { "type": "string", "minLength": 1 },
"tokens": {
"type": "array",
"minItems": 2,
"items": {
"type": "object",
"required": ["text", "correct"],
"additionalProperties": false,
"properties": {
"text": { "type": "string", "minLength": 1 },
"correct": { "type": "boolean" },
"expected": { "type": "string", "minLength": 1 },
"rule_id": { "type": "string", "pattern": "^[a-z][a-z0-9_]*$" }
}
}
},
"hint": { "type": "string", "minLength": 1 },
"explanation": { "type": "string", "minLength": 1 },
"review_tags": { "type": "array", "items": { "type": "string" } },
"review_status": { "type": "string", "enum": ["draft", "validated"] }
}
}
+22
View File
@@ -0,0 +1,22 @@
{
"type": "object",
"required": [
"rule_id",
"level",
"domain",
"skill",
"statement",
"example",
"review_status"
],
"additionalProperties": false,
"properties": {
"rule_id": { "type": "string", "pattern": "^[a-z][a-z0-9_]*$" },
"level": { "type": "string", "enum": ["CE1", "CE2"] },
"domain": { "type": "string", "enum": ["grammaire", "orthographe"] },
"skill": { "type": "string", "pattern": "^[a-z][a-z0-9_]*$" },
"statement": { "type": "string", "minLength": 1 },
"example": { "type": "string", "minLength": 1 },
"review_status": { "type": "string", "enum": ["draft", "validated"] }
}
}
+32
View File
@@ -0,0 +1,32 @@
{
"type": "object",
"required": [
"schema_version",
"profile_id",
"display_name",
"school_level",
"session_count",
"unlocked_regions",
"skills",
"rewards"
],
"additionalProperties": false,
"properties": {
"schema_version": { "type": "integer", "minimum": 1 },
"profile_id": { "type": "string", "pattern": "^[a-z][a-z0-9_]*$" },
"display_name": { "type": "string", "minLength": 1 },
"school_level": { "type": "string", "enum": ["CE1", "CE2"] },
"session_count": { "type": "integer", "minimum": 0 },
"unlocked_regions": { "type": "array", "items": { "type": "string" } },
"skills": { "type": "object" },
"rewards": {
"type": "object",
"required": ["stars", "items"],
"additionalProperties": false,
"properties": {
"stars": { "type": "integer", "minimum": 0 },
"items": { "type": "array", "items": { "type": "string" } }
}
}
}
}
+41
View File
@@ -0,0 +1,41 @@
# Revue du contenu pedagogique
Genere automatiquement depuis `data/` par `tools/generate_content_review.py`. Ne pas editer ce fichier a la main.
## regle_accord_adjectif (CE1, grammaire)
**Enonce** : Quand un adjectif accompagne un nom, il s'accorde avec ce nom en genre et en nombre.
**Exemple** : Un petit chien. Une petite chienne. Des petits chiens. Des petites chiennes.
**Statut** : draft
### Choix multiples
- **ce1_accord_adjectif_qcm_01** (difficulte 1) : Les ... chiens jouent.
- Reponses : petit, petits, petite
- Reponse attendue : petits
- Indice : Observe le determinant « les » et le nom « chiens ».
- Explication : Le groupe nominal est au pluriel. L'adjectif prend ici la marque du pluriel.
- Statut : draft
- **ce1_accord_adjectif_qcm_02** (difficulte 1) : La ... maison est jolie.
- Reponses : petit, petite, petites
- Reponse attendue : petite
- Indice : Observe le determinant « la » et le nom « maison ».
- Explication : Le nom « maison » est feminin singulier. L'adjectif prend donc la marque du feminin.
- Statut : draft
### Corrections de phrase
- **ce1_accord_adjectif_correction_01** (difficulte 2) : Les [petit -> petits] chiens courent.
- Indice : Le determinant « les » indique un pluriel.
- Explication : L'adjectif « petit » s'accorde avec le nom « chiens », qui est au pluriel : il faut ecrire « petits ».
- Statut : draft
### Dictees
- **ce1_accord_adjectif_dictee_01** : Les petits chats dorment.
- Variantes acceptees : aucune
- Audio : provisoire
- Statut : draft
+10
View File
@@ -88,3 +88,13 @@ Ajouts à l'arborescence proposée :
- Licence et hébergement : tranchés ci-dessus (point 0).
- Validation du contenu CE1/CE2 par un enseignant avant diffusion : à organiser, pas de date fixée.
- Spécification minimale de performance pour « appareil familial » : non définie, à préciser avant les exports.
## 11. Étape « Données » : précisions prises en cours d'implémentation
- Le validateur générique (`scripts/data/schema_validator.gd`) implémente un sous-ensemble de JSON Schema (`type`, `required`, `properties`, `additionalProperties`, `items`, `enum`, `const`, bornes numériques, longueurs, `pattern`). Suffisant pour tous les schémas actuels ; pas de `patternProperties` ni de `if`/`then`.
- Le dispatch du schéma d'exercice se fait sur le champ `type` (`multiple_choice` → `exercise_multiple_choice.schema.json`, `sentence_correction` → `exercise_sentence_correction.schema.json`), dans `DataLoader.exercise_schema_name_for_type`. Un type absent de cette fonction est rejeté explicitement par `make validate-data`, conformément à l'invariant « liste fermée des types d'exercices ».
- Les contraintes croisées qu'un schéma ne peut pas exprimer (index `correct_answer` dans les bornes de `answers`, au moins un token fautif en correction de phrase avec une correction `expected`, cohérence entre `target_text` et `words` en dictée) vivent dans `scripts/data/semantic_checks.gd`, testées indépendamment du schéma.
- Schéma de dictée : `words` (annotation mot par mot : `rule_id` et `expects_capital` facultatifs) est **obligatoire**, pas facultatif — sans lui, l'erreur reste non classée (cf. « Points durs » du prompt de lancement). Le nombre de mots dans `words` doit correspondre à `target_text` découpé sur les espaces.
- Le schéma `save` reste provisoire pour le champ `skills` (`{"type": "object"}`, sans validation des clés dynamiques) : le validateur actuel ne sait pas exprimer un schéma pour des clés arbitraires. À affiner à l'étape « Profils et sauvegardes ».
- GUT est pinné en v9.6.0, pas la dernière version (9.7.1 référence le singleton `AccessibilityServer`, absent de Godot 4.6.3 en mode headless).
- `tools/setup.sh` lance `godot --headless --path . --import` en fin d'installation : nécessaire pour que les scripts `class_name` globaux (`SchemaValidator`, `DataLoader`, `SemanticChecks`) soient résolus avant la première exécution de `make test` / `make validate-data`.
+76
View File
@@ -0,0 +1,76 @@
class_name DataLoader
extends RefCounted
## Charge et valide les fichiers JSON de data/ contre leurs schemas (data/schemas/).
## Meme code utilise par `make validate-data` (headless) et par ExerciseEngine a
## l'execution : une seule source de verite pour la validation (voir
## docs/specs/decisions.md, point 2).
const SCHEMA_DIR := "res://data/schemas"
static var _schema_cache: Dictionary = {}
## Mappe un type d'exercice (champ "type" des donnees) vers le nom de son schema.
## Chaine vide si le type n'est pas reconnu : la liste des types est fermee ici,
## pas devinee ailleurs.
static func exercise_schema_name_for_type(exercise_type: String) -> String:
match exercise_type:
"multiple_choice":
return "exercise_multiple_choice"
"sentence_correction":
return "exercise_sentence_correction"
_:
return ""
static func load_schema(schema_name: String) -> Dictionary:
if _schema_cache.has(schema_name):
return _schema_cache[schema_name]
var path := "%s/%s.schema.json" % [SCHEMA_DIR, schema_name]
var file := FileAccess.open(path, FileAccess.READ)
if file == null:
push_error("Schema introuvable : %s" % path)
return {}
var parsed: Variant = JSON.parse_string(file.get_as_text())
var schema: Dictionary = parsed if typeof(parsed) == TYPE_DICTIONARY else {}
_schema_cache[schema_name] = schema
return schema
## Lit un fichier JSON et le valide contre le schema nomme. Retourne la liste des
## erreurs (vide si valide) ; ne retourne jamais les donnees analysees, pour que
## l'appelant ne puisse pas accidentellement utiliser une donnee non validee.
static func validate_file(path: String, schema_name: String) -> Array[String]:
var parsed: Variant = read_json(path)
if parsed == null:
return ["%s : lecture impossible ou JSON invalide" % path]
var schema := load_schema(schema_name)
return SchemaValidator.validate(parsed, schema, path)
static func read_json(path: String) -> Variant:
var file := FileAccess.open(path, FileAccess.READ)
if file == null:
return null
var text := file.get_as_text()
if text.strip_edges().is_empty():
return null
return JSON.parse_string(text)
static func find_json_files(root_path: String) -> Array[String]:
var result: Array[String] = []
var dir := DirAccess.open(root_path)
if dir == null:
return result
dir.list_dir_begin()
var entry := dir.get_next()
while entry != "":
if entry != "." and entry != "..":
var full_path := root_path.path_join(entry)
if dir.current_is_dir():
result.append_array(find_json_files(full_path))
elif entry.ends_with(".json"):
result.append(full_path)
entry = dir.get_next()
return result
+1
View File
@@ -0,0 +1 @@
uid://l56rn1o7hqm0
+122
View File
@@ -0,0 +1,122 @@
class_name SchemaValidator
extends RefCounted
## Validateur de donnees contre un sous-ensemble de JSON Schema : type, required,
## properties, additionalProperties, items, enum, const, minimum, maximum,
## minItems, maxItems, minLength, maxLength, pattern.
## Les schemas ne decrivent jamais de code a executer : seulement une structure
## de donnees a verifier (voir l'invariant d'architecture dans CLAUDE.md).
static func validate(value: Variant, schema: Dictionary, path: String = "$") -> Array[String]:
var errors: Array[String] = []
_validate_node(value, schema, path, errors)
return errors
static func _validate_node(
value: Variant, schema: Dictionary, path: String, errors: Array[String]
) -> void:
if schema.has("const"):
if value != schema["const"]:
errors.append("%s : doit valoir exactement %s" % [path, schema["const"]])
return
if schema.has("enum"):
var allowed: Array = schema["enum"]
if not allowed.has(value):
errors.append("%s : valeur '%s' absente de l'enum %s" % [path, value, allowed])
return
if schema.has("type"):
if not _check_type(value, schema["type"]):
errors.append(
"%s : type attendu '%s', trouve '%s'" % [path, schema["type"], typeof(value)]
)
return
match typeof(value):
TYPE_DICTIONARY:
_validate_object(value, schema, path, errors)
TYPE_ARRAY:
_validate_array(value, schema, path, errors)
TYPE_STRING:
_validate_string(value, schema, path, errors)
TYPE_INT, TYPE_FLOAT:
_validate_number(value, schema, path, errors)
static func _check_type(value: Variant, expected: String) -> bool:
match expected:
"object":
return typeof(value) == TYPE_DICTIONARY
"array":
return typeof(value) == TYPE_ARRAY
"string":
return typeof(value) == TYPE_STRING
"boolean":
return typeof(value) == TYPE_BOOL
"integer":
if typeof(value) == TYPE_INT:
return true
if typeof(value) == TYPE_FLOAT:
var number: float = value
return number == floor(number)
return false
"number":
return typeof(value) == TYPE_INT or typeof(value) == TYPE_FLOAT
_:
return true
static func _validate_object(
value: Dictionary, schema: Dictionary, path: String, errors: Array[String]
) -> void:
if schema.has("required"):
for key: String in schema["required"]:
if not value.has(key):
errors.append("%s : propriete requise manquante '%s'" % [path, key])
var properties: Dictionary = schema.get("properties", {})
var additional_allowed: Variant = schema.get("additionalProperties", true)
for key: String in value.keys():
if properties.has(key):
_validate_node(value[key], properties[key], "%s.%s" % [path, key], errors)
elif additional_allowed == false:
errors.append("%s : propriete inattendue '%s'" % [path, key])
static func _validate_array(
value: Array, schema: Dictionary, path: String, errors: Array[String]
) -> void:
if schema.has("minItems") and value.size() < int(schema["minItems"]):
errors.append("%s : au moins %d element(s) attendu(s)" % [path, schema["minItems"]])
if schema.has("maxItems") and value.size() > int(schema["maxItems"]):
errors.append("%s : au plus %d element(s) attendu(s)" % [path, schema["maxItems"]])
if schema.has("items"):
var item_schema: Dictionary = schema["items"]
for i in value.size():
_validate_node(value[i], item_schema, "%s[%d]" % [path, i], errors)
static func _validate_string(
value: String, schema: Dictionary, path: String, errors: Array[String]
) -> void:
if schema.has("minLength") and value.length() < int(schema["minLength"]):
errors.append("%s : longueur minimale %d non atteinte" % [path, schema["minLength"]])
if schema.has("maxLength") and value.length() > int(schema["maxLength"]):
errors.append("%s : longueur maximale %d depassee" % [path, schema["maxLength"]])
if schema.has("pattern"):
var regex := RegEx.new()
regex.compile(schema["pattern"])
if regex.search(value) == null:
errors.append("%s : ne correspond pas au motif '%s'" % [path, schema["pattern"]])
static func _validate_number(
value: Variant, schema: Dictionary, path: String, errors: Array[String]
) -> void:
var number: float = value
if schema.has("minimum") and number < float(schema["minimum"]):
errors.append("%s : doit etre >= %s" % [path, schema["minimum"]])
if schema.has("maximum") and number > float(schema["maximum"]):
errors.append("%s : doit etre <= %s" % [path, schema["maximum"]])
+1
View File
@@ -0,0 +1 @@
uid://dl6pewdq2dyus
+49
View File
@@ -0,0 +1,49 @@
class_name SemanticChecks
extends RefCounted
## Contraintes croisees entre champs qu'un schema JSON (voir SchemaValidator) ne
## peut pas exprimer : index valide, coherence entre deux tableaux, etc.
## Appele uniquement apres une validation de schema reussie : ces fonctions
## supposent une structure deja conforme.
static func check_multiple_choice(data: Dictionary, path: String) -> Array[String]:
var errors: Array[String] = []
var answers: Array = data.get("answers", [])
var correct_answer: int = data.get("correct_answer", -1)
if correct_answer < 0 or correct_answer >= answers.size():
errors.append(
"%s : correct_answer (%d) hors des bornes de answers (%d element(s))"
% [path, correct_answer, answers.size()]
)
return errors
static func check_sentence_correction(data: Dictionary, path: String) -> Array[String]:
var errors: Array[String] = []
var tokens: Array = data.get("tokens", [])
var has_error_token := false
for i in tokens.size():
var token: Dictionary = tokens[i]
if token.get("correct", true) == false:
has_error_token = true
if not token.has("expected"):
errors.append(
"%s.tokens[%d] : token fautif sans correction attendue ('expected')"
% [path, i]
)
if not has_error_token:
errors.append("%s : aucun token fautif, rien a corriger" % path)
return errors
static func check_dictation(data: Dictionary, path: String) -> Array[String]:
var errors: Array[String] = []
var target_text: String = data.get("target_text", "")
var words: Array = data.get("words", [])
var expected_word_count := target_text.split(" ", false).size()
if words.size() != expected_word_count:
errors.append(
"%s : %d mot(s) annote(s) pour %d mot(s) dans target_text"
% [path, words.size(), expected_word_count]
)
return errors
+1
View File
@@ -0,0 +1 @@
uid://c2ekxtn3qh5ku
+53 -31
View File
@@ -1,46 +1,68 @@
extends SceneTree
## Outil headless invoque par `make validate-data`.
## A ce stade, verifie seulement que les fichiers data/**/*.json sont du JSON bien forme.
## La validation contre les schemas (rule, exercise, dictation, curriculum, save) sera
## ajoutee a l'etape "Donnees" (voir Ordre de construction, docs/specs/decisions.md point 2).
## Valide chaque fichier JSON de data/ contre son schema (data/schemas/), puis
## applique les contraintes croisees que le schema ne peut pas exprimer.
func _init() -> void:
var json_paths: Array[String] = _find_json_files("res://data")
var failures: Array[String] = []
for path in json_paths:
var file := FileAccess.open(path, FileAccess.READ)
if file == null:
failures.append("%s : lecture impossible" % path)
continue
var text := file.get_as_text()
if text.strip_edges().is_empty():
continue
if JSON.parse_string(text) == null:
failures.append("%s : JSON invalide" % path)
var checked := 0
checked += _validate_simple_dir("res://data/rules", "rule", failures)
checked += _validate_simple_dir("res://data/curriculum", "curriculum", failures)
checked += _validate_dictations("res://data/dictations", failures)
checked += _validate_exercises("res://data/exercises", failures)
if failures.is_empty():
print("validate-data : %d fichier(s) JSON valide(s)" % json_paths.size())
print("validate-data : %d fichier(s) JSON valide(s)" % checked)
quit(0)
else:
for failure in failures:
printerr(failure)
printerr("validate-data : %d erreur(s)" % failures.size())
quit(1)
func _find_json_files(root_path: String) -> Array[String]:
var result: Array[String] = []
var dir := DirAccess.open(root_path)
if dir == null:
return result
dir.list_dir_begin()
var entry := dir.get_next()
while entry != "":
if entry != "." and entry != "..":
var full_path := root_path.path_join(entry)
if dir.current_is_dir():
result.append_array(_find_json_files(full_path))
elif entry.ends_with(".json"):
result.append(full_path)
entry = dir.get_next()
return result
func _validate_simple_dir(dir_path: String, schema_name: String, failures: Array[String]) -> int:
var count := 0
for path in DataLoader.find_json_files(dir_path):
count += 1
failures.append_array(DataLoader.validate_file(path, schema_name))
return count
func _validate_dictations(dir_path: String, failures: Array[String]) -> int:
var count := 0
for path in DataLoader.find_json_files(dir_path):
count += 1
var schema_errors := DataLoader.validate_file(path, "dictation")
failures.append_array(schema_errors)
if schema_errors.is_empty():
var data: Dictionary = DataLoader.read_json(path)
failures.append_array(SemanticChecks.check_dictation(data, path))
return count
func _validate_exercises(dir_path: String, failures: Array[String]) -> int:
var count := 0
for path in DataLoader.find_json_files(dir_path):
count += 1
var data: Variant = DataLoader.read_json(path)
if data == null or typeof(data) != TYPE_DICTIONARY or not data.has("type"):
failures.append("%s : champ 'type' manquant ou JSON invalide" % path)
continue
var exercise_type: String = data["type"]
var schema_name := DataLoader.exercise_schema_name_for_type(exercise_type)
if schema_name.is_empty():
failures.append("%s : type d'exercice inconnu '%s'" % [path, exercise_type])
continue
var schema_errors := DataLoader.validate_file(path, schema_name)
failures.append_array(schema_errors)
if not schema_errors.is_empty():
continue
match exercise_type:
"multiple_choice":
failures.append_array(SemanticChecks.check_multiple_choice(data, path))
"sentence_correction":
failures.append_array(SemanticChecks.check_sentence_correction(data, path))
return count
+69
View File
@@ -0,0 +1,69 @@
extends GutTest
## Tests du validateur de schema generique (scripts/data/schema_validator.gd),
## independants de tout contenu reel de data/.
var schema: Dictionary = {
"type": "object",
"required": ["id", "level"],
"additionalProperties": false,
"properties": {
"id": { "type": "string", "pattern": "^[a-z][a-z0-9_]*$" },
"level": { "type": "string", "enum": ["CE1", "CE2"] },
"difficulty": { "type": "integer", "minimum": 1, "maximum": 3 },
"tags": { "type": "array", "minItems": 1, "items": { "type": "string" } },
},
}
func test_valid_object_has_no_errors() -> void:
var data := { "id": "ex_01", "level": "CE1", "difficulty": 2, "tags": ["pluriel"] }
assert_eq(SchemaValidator.validate(data, schema), [])
func test_missing_required_property_is_reported() -> void:
var data := { "level": "CE1" }
var errors := SchemaValidator.validate(data, schema)
assert_eq(errors.size(), 1)
assert_string_contains(errors[0], "id")
func test_wrong_type_is_reported() -> void:
var data := { "id": "ex_01", "level": "CE1", "difficulty": "deux" }
var errors := SchemaValidator.validate(data, schema)
assert_eq(errors.size(), 1)
assert_string_contains(errors[0], "difficulty")
func test_enum_violation_is_reported() -> void:
var data := { "id": "ex_01", "level": "CM1" }
var errors := SchemaValidator.validate(data, schema)
assert_eq(errors.size(), 1)
assert_string_contains(errors[0], "level")
func test_pattern_violation_is_reported() -> void:
var data := { "id": "Ex-01", "level": "CE1" }
var errors := SchemaValidator.validate(data, schema)
assert_eq(errors.size(), 1)
assert_string_contains(errors[0], "id")
func test_unexpected_property_is_reported_when_additional_forbidden() -> void:
var data := { "id": "ex_01", "level": "CE1", "extra": true }
var errors := SchemaValidator.validate(data, schema)
assert_eq(errors.size(), 1)
assert_string_contains(errors[0], "extra")
func test_min_items_violation_is_reported() -> void:
var data := { "id": "ex_01", "level": "CE1", "tags": [] }
var errors := SchemaValidator.validate(data, schema)
assert_eq(errors.size(), 1)
assert_string_contains(errors[0], "tags")
func test_numeric_bounds_are_checked() -> void:
var data := { "id": "ex_01", "level": "CE1", "difficulty": 9 }
var errors := SchemaValidator.validate(data, schema)
assert_eq(errors.size(), 1)
assert_string_contains(errors[0], "difficulty")
+45
View File
@@ -0,0 +1,45 @@
extends GutTest
## Tests des contraintes croisees (scripts/data/semantic_checks.gd).
func test_multiple_choice_rejects_out_of_bounds_correct_answer() -> void:
var data := { "answers": ["a", "b"], "correct_answer": 5 }
var errors := SemanticChecks.check_multiple_choice(data, "test")
assert_eq(errors.size(), 1)
func test_multiple_choice_accepts_valid_index() -> void:
var data := { "answers": ["a", "b"], "correct_answer": 1 }
assert_eq(SemanticChecks.check_multiple_choice(data, "test"), [])
func test_sentence_correction_requires_at_least_one_error_token() -> void:
var data := { "tokens": [{ "text": "Les", "correct": true }] }
var errors := SemanticChecks.check_sentence_correction(data, "test")
assert_eq(errors.size(), 1)
func test_sentence_correction_requires_expected_on_error_tokens() -> void:
var data := { "tokens": [{ "text": "petit", "correct": false }] }
var errors := SemanticChecks.check_sentence_correction(data, "test")
assert_eq(errors.size(), 1)
assert_string_contains(errors[0], "expected")
func test_dictation_word_count_must_match_target_text() -> void:
var data := {
"target_text": "Les petits chats dorment.",
"words": [{ "text": "Les" }, { "text": "petits" }],
}
var errors := SemanticChecks.check_dictation(data, "test")
assert_eq(errors.size(), 1)
func test_dictation_accepts_matching_word_count() -> void:
var data := {
"target_text": "Les petits chats dorment.",
"words": [
{ "text": "Les" }, { "text": "petits" }, { "text": "chats" }, { "text": "dorment." }
],
}
assert_eq(SemanticChecks.check_dictation(data, "test"), [])
+145
View File
@@ -0,0 +1,145 @@
#!/usr/bin/env python3
"""Genere docs/review/content-review.md a partir des donnees de data/.
Le JSON se relit mal ; ce document rassemble, pour chaque regle, son enonce
puis chaque exercice qui l'illustre, avec sa reponse attendue, son indice et
son explication. C'est ce document que l'adulte valide (voir CLAUDE.md,
section Contenu pedagogique).
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
REPO_ROOT = Path(__file__).resolve().parent.parent
DATA_DIR = REPO_ROOT / "data"
OUTPUT_PATH = REPO_ROOT / "docs" / "review" / "content-review.md"
def load_json_files(directory: Path) -> list[dict[str, Any]]:
if not directory.exists():
return []
return [
json.loads(path.read_text(encoding="utf-8")) for path in sorted(directory.rglob("*.json"))
]
def format_multiple_choice(exercise: dict[str, Any]) -> str:
answers: list[str] = exercise["answers"]
correct = answers[exercise["correct_answer"]]
return "\n".join(
[
f"- **{exercise['id']}** (difficulte {exercise['difficulty']}) : {exercise['question']}",
f" - Reponses : {', '.join(answers)}",
f" - Reponse attendue : {correct}",
f" - Indice : {exercise['hint']}",
f" - Explication : {exercise['explanation']}",
f" - Statut : {exercise['review_status']}",
]
)
def format_sentence_correction(exercise: dict[str, Any]) -> str:
rendered: list[str] = []
for token in exercise["tokens"]:
if token.get("correct", True):
rendered.append(token["text"])
else:
rendered.append(f"[{token['text']} -> {token['expected']}]")
sentence = " ".join(rendered)
return "\n".join(
[
f"- **{exercise['id']}** (difficulte {exercise['difficulty']}) : {sentence}",
f" - Indice : {exercise['hint']}",
f" - Explication : {exercise['explanation']}",
f" - Statut : {exercise['review_status']}",
]
)
def format_dictation(dictation: dict[str, Any]) -> str:
variants: list[str] = dictation["accepted_variants"]
audio_state = "provisoire" if dictation["audio_provisional"] else "finalise"
return "\n".join(
[
f"- **{dictation['id']}** : {dictation['target_text']}",
f" - Variantes acceptees : {', '.join(variants) if variants else 'aucune'}",
f" - Audio : {audio_state}",
f" - Statut : {dictation['review_status']}",
]
)
def render(
rules: list[dict[str, Any]],
exercises: list[dict[str, Any]],
dictations: list[dict[str, Any]],
) -> str:
multiple_choice = [e for e in exercises if e["type"] == "multiple_choice"]
sentence_correction = [e for e in exercises if e["type"] == "sentence_correction"]
lines: list[str] = [
"# Revue du contenu pedagogique",
"",
"Genere automatiquement depuis `data/` par `tools/generate_content_review.py`. "
"Ne pas editer ce fichier a la main.",
"",
]
for rule in sorted(rules, key=lambda r: str(r["rule_id"])):
rule_id = rule["rule_id"]
lines += [
f"## {rule_id} ({rule['level']}, {rule['domain']})",
"",
f"**Enonce** : {rule['statement']}",
"",
f"**Exemple** : {rule['example']}",
"",
f"**Statut** : {rule['review_status']}",
"",
]
rule_mc = [e for e in multiple_choice if e["rule_id"] == rule_id]
if rule_mc:
lines.append("### Choix multiples")
lines.append("")
for exercise in sorted(rule_mc, key=lambda e: str(e["id"])):
lines.append(format_multiple_choice(exercise))
lines.append("")
rule_sc = [e for e in sentence_correction if e["rule_id"] == rule_id]
if rule_sc:
lines.append("### Corrections de phrase")
lines.append("")
for exercise in sorted(rule_sc, key=lambda e: str(e["id"])):
lines.append(format_sentence_correction(exercise))
lines.append("")
rule_dictations = [d for d in dictations if rule_id in d["rule_ids"]]
if rule_dictations:
lines.append("### Dictees")
lines.append("")
for dictation in sorted(rule_dictations, key=lambda d: str(d["id"])):
lines.append(format_dictation(dictation))
lines.append("")
return "\n".join(lines) + "\n"
def main() -> None:
rules = load_json_files(DATA_DIR / "rules")
exercises = load_json_files(DATA_DIR / "exercises")
dictations = load_json_files(DATA_DIR / "dictations")
OUTPUT_PATH.parent.mkdir(parents=True, exist_ok=True)
OUTPUT_PATH.write_text(render(rules, exercises, dictations), encoding="utf-8")
print(
f"content-review.md genere : {len(rules)} regle(s), "
f"{len(exercises)} exercice(s), {len(dictations)} dictee(s)."
)
if __name__ == "__main__":
main()
+6 -2
View File
@@ -11,6 +11,7 @@ GODOT_BIN_DIR=".godot-bin"
GODOT_BIN="${GODOT_BIN_DIR}/godot"
GUT_VERSION="v9.6.0" # v9.6.1+ reference AccessibilityServer, absent de Godot 4.6.3
GDTOOLKIT_VERSION="4.5.0"
PYRIGHT_VERSION="1.1.407"
mkdir -p "${GODOT_BIN_DIR}"
@@ -46,6 +47,9 @@ if [ ! -d ".venv" ]; then
python3 -m venv .venv
fi
./.venv/bin/pip install --quiet --upgrade pip
./.venv/bin/pip install --quiet "gdtoolkit==${GDTOOLKIT_VERSION}"
./.venv/bin/pip install --quiet "gdtoolkit==${GDTOOLKIT_VERSION}" "pyright==${PYRIGHT_VERSION}"
echo "Setup termine : Godot ${GODOT_VERSION}, GUT ${GUT_VERSION}, gdtoolkit ${GDTOOLKIT_VERSION}."
echo "Import du projet (enregistre les class_name globaux : SchemaValidator, DataLoader...)."
"${GODOT_BIN}" --headless --path . --import >/dev/null
echo "Setup termine : Godot ${GODOT_VERSION}, GUT ${GUT_VERSION}, gdtoolkit ${GDTOOLKIT_VERSION}, pyright ${PYRIGHT_VERSION}."