Tolerate missing terminal punctuation in dictée answers

Manual test: the Piper voice (previous commit) is now intelligible,
but typing exactly what's heard ("Les petits chats dorment", no
period) was rejected. The provisional audio never vocalizes
punctuation, so nothing tells the child to add it — penalizing its
absence punishes information they were never given.

DictationAnalyzer now strips terminal punctuation (. ! ? …) from both
sides before comparing, in the exact-match/variant check and before
word alignment, without touching punctuation elsewhere in the
sentence. A real error on the last word is still caught (added test).

A scene-level test reproducing the exact reported interaction
(tests/exercises/test_exercise_host_dictation.gd) failed before the
fix and passes after.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Denis Defolie
2026-10-11 19:17:40 +02:00
parent b0dcedb402
commit 02755ced6f
6 changed files with 123 additions and 6 deletions
+12
View File
@@ -184,3 +184,15 @@ Ajouts à l'arborescence proposée :
**Effet de bord** : `pyright` ne résolvait pas l'import `piper` en ciblant l'environnement Python par défaut plutôt que `.venv/`. Ajout de `pyrightconfig.json` (`venvPath`/`venv`) à la racine pour que `make lint` continue de fonctionner sans option supplémentaire.
**Régénéré** : `ce1_accord_adjectif_dictee_01.ogg` avec la nouvelle voix. Non encore réécouté par un humain au moment d'écrire ceci — à confirmer en test manuel que l'intelligibilité est bien résolue, pas seulement supposée meilleure.
**Confirmation manuelle** : voix jugée parfaitement intelligible par un test humain. Voir le point 21 pour le bug distinct révélé par ce même test.
## 21. Bug signalé manuellement : dictée correcte refusée (ponctuation finale)
**Symptôme** : en écoutant la dictée (point 20, voix maintenant intelligible) et en tapant exactement « Les petits chats dorment » tel qu'entendu, la réponse n'était pas validée comme correcte.
**Cause** : `data/dictations/ce1_accord_adjectif_dictee_01.json` attend le texte avec un point final (`"target_text": "Les petits chats dorment."`), mais aucune voix (provisoire ou future) ne vocalise la ponctuation terminale — rien dans ce que l'enfant entend ne lui indique qu'il faut écrire ce point. `DictationAnalyzer` comparait le texte tel quel : l'absence du point faisait échouer la comparaison exacte, puis l'alignement mot à mot traitait « dorment » et « dorment. » comme deux mots différents (une substitution), signalée comme une erreur non classée. Un test reproduisant exactement l'interaction rapportée (`tests/exercises/test_exercise_host_dictation.gd`) a confirmé le problème avant toute correction.
**Correction** : `DictationAnalyzer` ignore désormais la ponctuation terminale (`.`, `!`, `?`, `…`) à la comparaison — aussi bien dans le test d'égalité exacte (et celui des variantes acceptées) que dans l'alignement mot à mot, où elle est retirée avant la découpe en mots plutôt qu'après coup. La ponctuation interne à la phrase n'est pas concernée. Quatre tests couvrent : omission du point, point remplacé par un autre signe, et absence de faux négatif (une vraie faute sur le dernier mot reste détectée malgré la tolérance de ponctuation).
**Leçon** : une tolérance de comparaison doit correspondre à ce que le canal d'entrée (ici l'audio) peut réellement transmettre à l'enfant — exiger une information qu'il n'a pas reçue revient à le punir d'une faute qu'il n'a pas commise (CLAUDE.md, invariants pédagogiques). À revoir si l'audio final fait un jour entendre la ponctuation (pause marquée, voix qui annonce « point »).
+2 -2
View File
@@ -14,7 +14,7 @@ Tout ce qui suit ne peut pas être vérifié sans écran (rendu, mise en page, a
8. Mission « Le pré des petits mots » (choix multiple) : les réponses sont cliquables, une réponse juste affiche « Bravo ! », une réponse fausse affiche « Pas tout à fait » avec un indice.
9. Refaire une erreur sur le même exercice : after la 2e erreur, l'explication remplace l'indice ; après la 3e, la bonne réponse est montrée et l'exercice passe au suivant sans blocage.
10. Mission « Le pont des phrases » (correction de phrase) : toucher un mot le fait apparaître comme sélectionné (état visuel distinct, pas seulement une couleur) ; un champ de saisie apparaît sous le mot sélectionné ; toucher à nouveau le mot fait disparaître le champ.
11. Mission « La clairière de l'écho » (dictée) : à l'arrivée sur l'exercice, la voix de synthèse doit se faire entendre automatiquement ; le bouton « Réécouter » la rejoue sans planter ; le badge « Voix provisoire » est visible ; la zone de saisie accepte le texte et les accents. La voix (Piper, point 20 de `decisions.md`) doit être clairement intelligible — une première version avec eSpeak NG a été jugée inintelligible et remplacée ; à reconfirmer à l'oreille que ce n'est plus le cas.
11. Mission « La clairière de l'écho » (dictée) : à l'arrivée sur l'exercice, la voix de synthèse doit se faire entendre automatiquement ; le bouton « Réécouter » la rejoue sans planter ; le badge « Voix provisoire » est visible ; la zone de saisie accepte le texte et les accents. Taper le texte sans point final (rien dans l'audio ne l'indique) doit être accepté comme correct (point 21 de `decisions.md`).
12. Après 3 exercices, écran de récompense avec le nombre d'étoiles, puis retour à la carte.
13. Quitter le jeu puis relancer `make run` : le profil et sa progression doivent être conservés (sélectionner le même profil, vérifier que l'état affiché sur la carte a changé par rapport à l'état initial).
14. Depuis la sélection de profil, supprimer un profil : une confirmation explicite doit apparaître avant la suppression effective ; annuler doit ne rien supprimer.
@@ -28,7 +28,7 @@ Tout ce qui suit ne peut pas être vérifié sans écran (rendu, mise en page, a
## Audio
- `ce1_accord_adjectif_dictee_01` a un fichier audio provisoire généré par `make generate-dictation-audio` (voix neuronale Piper, voir `assets/CREDITS.md`). À vérifier à l'oreille : la voix se déclenche à l'arrivée sur l'exercice et au clic sur « Réécouter », le volume est audible, la phrase est clairement comprise (pas seulement devinée).
- `ce1_accord_adjectif_dictee_01` a un fichier audio provisoire généré par `make generate-dictation-audio` (voix neuronale Piper, voir `assets/CREDITS.md`). Testé manuellement : voix claire et bien comprise.
- Toute dictée ajoutée sans régénérer son audio se dégrade silencieusement (avertissement en console, pas de plantage) : vérifier qu'aucune dictée manquante ne passe inaperçue avant diffusion (`make generate-dictation-audio` ne régénère pas l'existant, `--force` pour forcer).
## Exports
+26 -4
View File
@@ -8,6 +8,13 @@ extends RefCounted
## ni hasard. L'explication complete (texte de la regle) est composee par la
## couche d'affichage a partir des rule_id retournes, pas ici.
## Ponctuation de fin de phrase ignoree a la comparaison : l'audio provisoire
## (point 19/20 de docs/specs/decisions.md) ne la fait pas entendre, donc rien
## dans ce que l'enfant ecoute ne lui indique qu'il faut l'ecrire. L'exiger
## serait sanctionner une information qu'il n'a pas recue (CLAUDE.md : une
## erreur declenche une explication, jamais une punition).
const TERMINAL_PUNCTUATION := ".!?…"
static func analyze(dictation: Dictionary, submitted_text: String) -> CorrectionResult:
var normalized_submitted := TextNormalizer.normalize(submitted_text)
@@ -16,8 +23,10 @@ static func analyze(dictation: Dictionary, submitted_text: String) -> Correction
if _matches_target_or_variant(dictation, normalized_submitted, normalized_target):
return CorrectionResult.correct(_build_explanation(0))
var target_words := TextNormalizer.split_words(normalized_target)
var submitted_words := TextNormalizer.split_words(normalized_submitted)
var target_words := TextNormalizer.split_words(_strip_terminal_punctuation(normalized_target))
var submitted_words := TextNormalizer.split_words(
_strip_terminal_punctuation(normalized_submitted)
)
var words_meta: Array = dictation["words"]
# L'alignement se fait insensible a la casse : une majuscule manquante est
@@ -74,14 +83,27 @@ static func analyze(dictation: Dictionary, submitted_text: String) -> Correction
static func _matches_target_or_variant(
dictation: Dictionary, normalized_submitted: String, normalized_target: String
) -> bool:
if normalized_submitted == normalized_target:
var stripped_submitted := _strip_terminal_punctuation(normalized_submitted)
if stripped_submitted == _strip_terminal_punctuation(normalized_target):
return true
for variant: String in dictation.get("accepted_variants", []):
if normalized_submitted == TextNormalizer.normalize(variant):
if stripped_submitted == _strip_terminal_punctuation(TextNormalizer.normalize(variant)):
return true
return false
## Retire les signes de ponctuation terminale (un ou plusieurs, ex. "?!") en
## fin de texte. N'affecte pas la ponctuation interne a la phrase.
static func _strip_terminal_punctuation(text: String) -> String:
var result := text
while (
not result.is_empty()
and TERMINAL_PUNCTUATION.find(result.substr(result.length() - 1)) != -1
):
result = result.substr(0, result.length() - 1)
return result.strip_edges()
static func _classify_error(words_meta: Array, target_index: int, found: String) -> Dictionary:
var meta: Dictionary = words_meta[target_index] if target_index < words_meta.size() else {}
var expected: String = meta.get("text", "")
@@ -65,3 +65,23 @@ func test_accepted_variant_is_treated_as_correct() -> void:
func test_normalizes_before_comparing() -> void:
var result := DictationAnalyzer.analyze(dictation, "Les petits chats dorment.")
assert_true(result.is_correct)
## L'audio provisoire ne fait entendre aucune ponctuation (voir
## docs/specs/decisions.md, point 19/20) : l'omettre n'est pas une erreur.
func test_missing_terminal_punctuation_is_still_correct() -> void:
var result := DictationAnalyzer.analyze(dictation, "Les petits chats dorment")
assert_true(result.is_correct)
func test_different_terminal_punctuation_is_still_correct() -> void:
var result := DictationAnalyzer.analyze(dictation, "Les petits chats dorment!")
assert_true(result.is_correct)
func test_terminal_punctuation_tolerance_does_not_hide_a_real_error_on_the_last_word() -> void:
var result := DictationAnalyzer.analyze(dictation, "Les petits chats dorme")
assert_false(result.is_correct)
assert_eq(result.errors.size(), 1)
assert_eq(result.errors[0]["found"], "dorme")
assert_eq(result.errors[0]["expected"], "dorment.")
@@ -0,0 +1,62 @@
extends GutTest
## Reproduction d'un bug signale manuellement via exercise_host : la dictee
## "Les petits chats dorment." tapee exactement comme entendue n'etait pas
## validee comme correcte.
var _profile_id: String = ""
func before_each() -> void:
_profile_id = ProfileManager.create_profile("Test", "CE1")
ProfileManager.select_profile(_profile_id)
GameManager.pending_mission_id = "mission_03_clairiere_echo"
func after_each() -> void:
ProfileManager.delete_profile(_profile_id)
GameManager.pending_mission_id = ""
func test_exact_target_text_is_validated_as_correct() -> void:
var host: Control = await _ready_host()
var exercise_scene: Control = host.get_node("%ExerciseContainer").get_child(0)
var exercise: Dictionary = host.get_current_exercise()
assert_eq(exercise["target_text"], "Les petits chats dorment.")
(exercise_scene.get_node("%TextEdit") as LineEdit).text = exercise["target_text"]
exercise_scene.get_node("%ValidateButton").pressed.emit()
await wait_process_frames(1)
assert_eq(_feedback_title(host), UIText.get_text("feedback_panel.correct_title"))
## "J'entends parfaitement mais ne valide pas" : l'audio ne fait entendre
## aucune ponctuation, donc l'enfant qui ecrit exactement ce qu'il entend
## (sans le point final) doit tout de meme etre valide.
func test_missing_final_period_is_still_validated_as_correct() -> void:
var host: Control = await _ready_host()
var exercise_scene: Control = host.get_node("%ExerciseContainer").get_child(0)
(exercise_scene.get_node("%TextEdit") as LineEdit).text = "Les petits chats dorment"
exercise_scene.get_node("%ValidateButton").pressed.emit()
await wait_process_frames(1)
assert_eq(_feedback_title(host), UIText.get_text("feedback_panel.correct_title"))
func _ready_host() -> Control:
var host: Control = load("res://scenes/exercises/exercise_host.tscn").instantiate()
add_child_autofree(host)
await wait_process_frames(1)
var overlay: Control = host.get_node("%OverlayContainer")
overlay.get_child(0).continued.emit()
await wait_process_frames(1)
return host
func _feedback_title(host: Control) -> String:
var overlay: Control = host.get_node("%OverlayContainer")
var feedback: Control = overlay.get_child(overlay.get_child_count() - 1)
var title_label: Label = feedback.get_node("%TitleLabel")
return title_label.text
@@ -0,0 +1 @@
uid://dcas0r0cf6k7u