Accept elided corrections (j'achèterais) and fix the expected answer (0.8.1)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Denis Defolie
2026-10-12 11:41:49 +02:00
parent 42dd170a5d
commit 8584249d78
6 changed files with 93 additions and 10 deletions
@@ -23,7 +23,7 @@
{ {
"text": "j'achèterai", "text": "j'achèterai",
"correct": false, "correct": false,
"expected": "achèterais", "expected": "j'achèterais",
"rule_id": "regle_futur_conditionnel" "rule_id": "regle_futur_conditionnel"
}, },
{ {
+2
View File
@@ -403,3 +403,5 @@ Volume : 6 tableaux, 18 règles, 144 QCM, 72 corrections, 54 dictées (voix Pipe
**Contrôles** : les gardes-fous de doublons et de quasi-doublons ont attrapé 14 collisions pendant la rédaction (dont avec CE2), toutes corrigées. Aucune dictée ne contient de point d'interrogation ou d'exclamation isolé (l'espace insécable avant « ? » rend la saisie ambiguë) ; ces phrases sont réservées aux QCM et corrections. **Contrôles** : les gardes-fous de doublons et de quasi-doublons ont attrapé 14 collisions pendant la rédaction (dont avec CE2), toutes corrigées. Aucune dictée ne contient de point d'interrogation ou d'exclamation isolé (l'espace insécable avant « ? » rend la saisie ambiguë) ; ces phrases sont réservées aux QCM et corrections.
**Reste / limites** : CM1 et CM2 n'ont pas été relus pédagogiquement (indices, niveau de difficulté, règles du passé simple et de l'accord du participe avec COD, à faire valider) ; voix des 54 dictées non écoutées ; la création de profil propose désormais 4 niveaux (CE1, CE2, CM1, CM2). **Reste / limites** : CM1 et CM2 n'ont pas été relus pédagogiquement (indices, niveau de difficulté, règles du passé simple et de l'accord du participe avec COD, à faire valider) ; voix des 54 dictées non écoutées ; la création de profil propose désormais 4 niveaux (CE1, CE2, CM1, CM2).
**Correctif (0.8.1) — mots touchés avec élision** : dans « La coulée de lave », le mot touché était « j'achèterai » mais l'attendu « achèterais » (sans « j' ») : la réponse naturelle « j'achèterais » était refusée. Données corrigées (attendu « j'achèterais ») et validateur rendu tolérant : pour un mot touché avec élision, on accepte le mot entier ou seulement le verbe, avec apostrophe droite ou typographique. Tests : validateur (formes acceptées et refusées) et garde-fou de données (un attendu ne perd pas le préfixe d'élision du mot touché).
+1 -1
View File
@@ -11,7 +11,7 @@ config_version=5
[application] [application]
config/name="Les Aventuriers des mots" config/name="Les Aventuriers des mots"
config/version="0.8.0" config/version="0.8.1"
run/main_scene="res://scenes/boot/boot.tscn" run/main_scene="res://scenes/boot/boot.tscn"
config/features=PackedStringArray("4.6", "Forward Plus") config/features=PackedStringArray("4.6", "Forward Plus")
config/icon="res://icon.svg" config/icon="res://icon.svg"
+26 -8
View File
@@ -48,7 +48,7 @@ static func validate_sentence_correction(
errors.append(_missed_error(i, token, exercise)) errors.append(_missed_error(i, token, exercise))
elif is_faulty and was_selected: elif is_faulty and was_selected:
var submitted: String = corrections.get(i, "") var submitted: String = corrections.get(i, "")
if not _matches_expected(submitted, token["expected"]): if not _matches_expected(submitted, token["expected"], token["text"]):
errors.append(_wrong_correction_error(i, submitted, token, exercise)) errors.append(_wrong_correction_error(i, submitted, token, exercise))
elif not is_faulty and was_selected: elif not is_faulty and was_selected:
errors.append(_false_positive_error(i, token, exercise)) errors.append(_false_positive_error(i, token, exercise))
@@ -68,13 +68,31 @@ static func validate_sentence_correction(
## Compare la correction saisie a celle attendue en ignorant les differences ## Compare la correction saisie a celle attendue en ignorant les differences
## incidentes (majuscule/minuscule, apostrophe typographique, espaces, ## incidentes (majuscule/minuscule, apostrophe typographique, espaces,
## accents decomposes) : ce n'est pas la casse qui est evaluee ici, c'est ## accents decomposes) : ce n'est pas la casse qui est evaluee ici, c'est
## l'accord. Une reponse ambigue n'est pas declaree fausse automatiquement ## l'accord. Quand le mot touche porte une elision (« j'achèterai »), l'enfant
## (CLAUDE.md, invariants pedagogiques). ## peut taper le mot entier (« j'achèterais ») ou seulement le verbe
static func _matches_expected(submitted: String, expected: String) -> bool: ## (« achèterais ») : une reponse ambigue n'est pas declaree fausse
return ( ## automatiquement (CLAUDE.md, invariants pedagogiques).
TextNormalizer.normalize(submitted).to_lower() static func _matches_expected(submitted: String, expected: String, token_text: String = "") -> bool:
== TextNormalizer.normalize(expected).to_lower() var typed := TextNormalizer.normalize(submitted).to_lower()
) for form in _accepted_forms(expected, token_text):
if typed == TextNormalizer.normalize(form).to_lower():
return true
return false
## Formes acceptees : l'attendu, sans son prefixe d'elision (« j' », « l' »...),
## ou avec le prefixe du mot touche quand l'attendu n'en porte pas.
static func _accepted_forms(expected: String, token_text: String) -> Array[String]:
var forms: Array[String] = [expected]
var normalized_expected := TextNormalizer.normalize(expected)
var normalized_token := TextNormalizer.normalize(token_text)
var expected_cut := normalized_expected.find("'")
if expected_cut != -1:
forms.append(normalized_expected.substr(expected_cut + 1))
var token_cut := normalized_token.find("'")
if token_cut != -1 and expected_cut == -1:
forms.append(normalized_token.substr(0, token_cut + 1) + normalized_expected)
return forms
static func _missed_error(index: int, token: Dictionary, exercise: Dictionary) -> Dictionary: static func _missed_error(index: int, token: Dictionary, exercise: Dictionary) -> Dictionary:
+36
View File
@@ -113,3 +113,39 @@ func test_sentence_correction_accepts_decomposed_accented_correction() -> void:
exercise_with_accent, [0], {0: "école"} exercise_with_accent, [0], {0: "école"}
) )
assert_true(result.is_correct) assert_true(result.is_correct)
func _elision_exercise() -> Dictionary:
return {
"rule_id": "regle_x",
"hint": "indice",
"explanation": "explication",
"tokens":
[
{"text": "Si", "correct": true},
{"text": "j'achèterai", "correct": false, "expected": "j'achèterais"},
{"text": "une", "correct": true},
],
}
func test_elided_token_accepts_the_whole_word_or_only_the_verb() -> void:
var exercise := _elision_exercise()
for typed in ["j'achèterais", "J'achèterais", "j’achèterais", "achèterais", " achèterais "]:
var result := AnswerValidator.validate_sentence_correction(exercise, [1], {1: typed})
assert_true(result.is_correct, "« %s » doit etre accepte" % typed)
func test_elided_token_still_rejects_a_wrong_correction() -> void:
var exercise := _elision_exercise()
for typed in ["j'achèterai", "achèterai", "achetterais", ""]:
var result := AnswerValidator.validate_sentence_correction(exercise, [1], {1: typed})
assert_false(result.is_correct, "« %s » ne doit pas etre accepte" % typed)
func test_expected_without_elision_accepts_the_prefix_of_the_touched_word() -> void:
var exercise := _elision_exercise()
exercise["tokens"][1]["expected"] = "achèterais"
for typed in ["achèterais", "j'achèterais"]:
var result := AnswerValidator.validate_sentence_correction(exercise, [1], {1: typed})
assert_true(result.is_correct, "« %s » doit etre accepte" % typed)
+27
View File
@@ -79,3 +79,30 @@ func test_correct_answer_position_is_not_always_the_same() -> void:
1, 1,
"bonne reponse toujours au meme rang : %s" % rule_id "bonne reponse toujours au meme rang : %s" % rule_id
) )
func test_elided_wrong_tokens_keep_the_same_prefix_unless_the_word_changes() -> void:
# Regression : « j'achèterai » avait pour attendu « achèterais » (sans « j' »),
# donc la reponse naturelle « j'achèterais » etait refusee. Un mot touche avec
# elision doit avoir un attendu qui garde le prefixe d'elision, sauf si c'est
# l'elision elle-meme qui est l'objet de l'exercice (la/l'a, qu'elle/quelle...).
for rule_id: String in ExerciseEngine._rules:
for exercise: Dictionary in ExerciseEngine._candidates_for(rule_id, "sentence_correction"):
for token: Dictionary in exercise["tokens"]:
if token.get("correct", true):
continue
var text: String = token["text"]
var expected: String = token["expected"]
if not text.contains("'") or expected.contains("'"):
continue
# Le mot change de nature (ex. « Qu'elle » -> « Quelle ») : seules
# les corrections ou l'elision est conservee sont concernees.
var prefix := text.split("'")[0]
var stem := text.substr(prefix.length() + 1)
assert_false(
expected.to_lower().begins_with(stem.substr(0, 3).to_lower()),
(
"%s : attendu « %s » sans le prefixe de « %s »"
% [exercise["id"], expected, text]
)
)