From 8584249d78407de9e195fcbe6c10f33af1a7c9b1 Mon Sep 17 00:00:00 2001 From: Denis Defolie Date: Mon, 12 Oct 2026 11:41:49 +0200 Subject: [PATCH] =?UTF-8?q?Accept=20elided=20corrections=20(j'ach=C3=A8ter?= =?UTF-8?q?ais)=20and=20fix=20the=20expected=20answer=20(0.8.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- .../cm2/cm2_futur_cond_correction_01.json | 2 +- docs/specs/decisions.md | 2 ++ project.godot | 2 +- scripts/correction/answer_validator.gd | 34 +++++++++++++----- tests/correction/test_answer_validator.gd | 36 +++++++++++++++++++ tests/data/test_curriculum.gd | 27 ++++++++++++++ 6 files changed, 93 insertions(+), 10 deletions(-) diff --git a/data/exercises/cm2/cm2_futur_cond_correction_01.json b/data/exercises/cm2/cm2_futur_cond_correction_01.json index 7296d00..2010ed2 100644 --- a/data/exercises/cm2/cm2_futur_cond_correction_01.json +++ b/data/exercises/cm2/cm2_futur_cond_correction_01.json @@ -23,7 +23,7 @@ { "text": "j'achèterai", "correct": false, - "expected": "achèterais", + "expected": "j'achèterais", "rule_id": "regle_futur_conditionnel" }, { diff --git a/docs/specs/decisions.md b/docs/specs/decisions.md index 2998d15..c598846 100644 --- a/docs/specs/decisions.md +++ b/docs/specs/decisions.md @@ -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. **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é). diff --git a/project.godot b/project.godot index 63adc08..e825296 100644 --- a/project.godot +++ b/project.godot @@ -11,7 +11,7 @@ config_version=5 [application] config/name="Les Aventuriers des mots" -config/version="0.8.0" +config/version="0.8.1" run/main_scene="res://scenes/boot/boot.tscn" config/features=PackedStringArray("4.6", "Forward Plus") config/icon="res://icon.svg" diff --git a/scripts/correction/answer_validator.gd b/scripts/correction/answer_validator.gd index 7e01860..d586511 100644 --- a/scripts/correction/answer_validator.gd +++ b/scripts/correction/answer_validator.gd @@ -48,7 +48,7 @@ static func validate_sentence_correction( errors.append(_missed_error(i, token, exercise)) elif is_faulty and was_selected: 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)) elif not is_faulty and was_selected: 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 ## incidentes (majuscule/minuscule, apostrophe typographique, espaces, ## 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 -## (CLAUDE.md, invariants pedagogiques). -static func _matches_expected(submitted: String, expected: String) -> bool: - return ( - TextNormalizer.normalize(submitted).to_lower() - == TextNormalizer.normalize(expected).to_lower() - ) +## l'accord. Quand le mot touche porte une elision (« j'achèterai »), l'enfant +## peut taper le mot entier (« j'achèterais ») ou seulement le verbe +## (« achèterais ») : une reponse ambigue n'est pas declaree fausse +## automatiquement (CLAUDE.md, invariants pedagogiques). +static func _matches_expected(submitted: String, expected: String, token_text: String = "") -> bool: + 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: diff --git a/tests/correction/test_answer_validator.gd b/tests/correction/test_answer_validator.gd index aa71ae5..211abbe 100644 --- a/tests/correction/test_answer_validator.gd +++ b/tests/correction/test_answer_validator.gd @@ -113,3 +113,39 @@ func test_sentence_correction_accepts_decomposed_accented_correction() -> void: exercise_with_accent, [0], {0: "école"} ) 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) diff --git a/tests/data/test_curriculum.gd b/tests/data/test_curriculum.gd index c38053d..832e9ee 100644 --- a/tests/data/test_curriculum.gd +++ b/tests/data/test_curriculum.gd @@ -79,3 +79,30 @@ func test_correct_answer_position_is_not_always_the_same() -> void: 1, "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] + ) + )