Require capitalization and final punctuation again; add a UI reminder

Follow-up to the previous commit: testing further showed a missing
initial capital also fails validation, which raised the real
question (CLAUDE.md: ask rather than decide alone on an ambiguous
pedagogical point) — is sentence-initial capitalization and final
punctuation something the audio should convey, or a general writing
convention the child applies regardless of what they hear?

Decided: a general convention, like in a real classroom dictée. So
both stay required, and the terminal-punctuation tolerance added in
the previous commit is reverted (DictationAnalyzer back to comparing
the text as-is).

Since the audio still can't vocalize either rule, the child needs to
be told some other way: added a permanent reminder label to the
dictation scene ("N'oublie pas : une majuscule au début de la phrase
et un point à la fin !"), sourced from data/localization/fr.json.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Denis Defolie
2026-10-11 19:27:59 +02:00
parent 02755ced6f
commit 8a3469e7b1
8 changed files with 48 additions and 49 deletions
+1
View File
@@ -38,6 +38,7 @@
"feedback_panel.correction_label": "La bonne réponse :",
"sentence_correction.correction_placeholder": "Corrige ce mot",
"dictation.instruction_label": "Écris la phrase que tu entends :",
"dictation.convention_hint": "N'oublie pas : une majuscule au début de la phrase et un point à la fin !",
"dictation.input_placeholder": "Écris ici...",
"dictation.replay_button": "Réécouter",
"dictation.provisional_audio_badge": "Voix provisoire",
+12
View File
@@ -196,3 +196,15 @@ Ajouts à l'arborescence proposée :
**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 »).
**Revu au point 22** : cette tolérance a été annulée après discussion — majuscule et point final sont une convention générale, pas une information dictée.
## 22. Majuscule et point final : exigés comme convention générale, pas tolérés
**Contexte** : en poursuivant le test manuel du point 21, une réponse tapée sans majuscule initiale (« les petits chats dorment ») a aussi échoué, sur une erreur de catégorie « capitalization ». Question posée (CLAUDE.md : « en cas d'ambiguïté, poser la question plutôt que trancher seul ») plutôt que d'étendre seul la tolérance du point 21 à la majuscule : la majuscule en début de phrase et le point final sont-ils une information que l'audio devrait transmettre (comme supposé au point 21), ou une convention générale que l'enfant applique de lui-même, indépendamment de ce qu'il entend — comme en classe, où l'enseignant ne dit pas toujours « majuscule » ou « point » avant chaque dictée ?
**Décision** : convention générale. Les deux sont exigés, comme n'importe quel autre mot de la phrase. La tolérance du point 21 est annulée : `DictationAnalyzer` retrouve sa comparaison d'origine (plus de `_strip_terminal_punctuation`), majuscule et point final doivent à nouveau être écrits exactement.
**Contrepartie** : exiger une convention que l'audio ne signale pas suppose que l'enfant la connaît déjà par ailleurs, pas qu'il la devine dictée par dictée. Ajout d'un rappel permanent et visible dans la scène de dictée (`dictation.tscn`, label `ConventionHintLabel`, clé `dictation.convention_hint` dans `data/localization/fr.json` : « N'oublie pas : une majuscule au début de la phrase et un point à la fin ! »). C'est la réponse concrète à « il faudra indiquer les règles quelque part » : la règle est communiquée par l'interface, pas supposée acquise silencieusement.
**Tests** : `tests/correction/test_dictation_analyzer.gd` vérifie que l'omission du point est à nouveau une erreur (la majuscule l'était déjà, test existant) ; `tests/exercises/test_exercise_host_dictation.gd` vérifie la même chose au niveau de la scène complète.
+1 -1
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. Taper le texte sans point final (rien dans l'audio ne l'indique) doit être accepté comme correct (point 21 de `decisions.md`).
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. Le rappel « N'oublie pas : une majuscule au début de la phrase et un point à la fin ! » doit être visible (point 22 de `decisions.md`) ; taper le texte sans majuscule ou sans point final doit être refusé, avec l'erreur correspondante.
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.
+6
View File
@@ -3,6 +3,10 @@ extends Control
## par signal, ne touche ni a la sauvegarde ni a la progression. L'audio est
## provisoire (voir docs/specs/decisions.md, point 8) ; le badge l'indique a
## l'enfant tant qu'aucune voix definitive n'a remplace le fichier.
##
## Majuscule initiale et point final sont exiges a la correction (point 22)
## sans etre entendus dans l'audio : le rappel ci-dessous est la facon dont
## l'enfant en est informe, pas une tolerance ajoutee au validateur.
signal answer_submitted(submitted_text: String)
@@ -11,6 +15,7 @@ var _dictation: Dictionary = {}
@onready var _instruction_label: Label = %InstructionLabel
@onready var _replay_button: Button = %ReplayButton
@onready var _provisional_badge: Label = %ProvisionalBadge
@onready var _convention_hint_label: Label = %ConventionHintLabel
@onready var _text_edit: LineEdit = %TextEdit
@onready var _validate_button: Button = %ValidateButton
@@ -20,6 +25,7 @@ func _ready() -> void:
_replay_button.text = UIText.get_text("dictation.replay_button")
_text_edit.placeholder_text = UIText.get_text("dictation.input_placeholder")
_provisional_badge.text = UIText.get_text("dictation.provisional_audio_badge")
_convention_hint_label.text = UIText.get_text("dictation.convention_hint")
_validate_button.text = UIText.get_text("exercise_host.validate_button")
_replay_button.pressed.connect(_on_replay_pressed)
+5
View File
@@ -37,6 +37,11 @@ unique_name_in_owner = true
layout_mode = 2
theme_override_colors/font_color = Color(0.6, 0.45, 0.1, 1)
[node name="ConventionHintLabel" type="Label" parent="Margin/VBox"]
unique_name_in_owner = true
layout_mode = 2
autowrap_mode = 2
[node name="ReplayButton" type="Button" parent="Margin/VBox"]
unique_name_in_owner = true
layout_mode = 2
+11 -26
View File
@@ -7,13 +7,13 @@ extends RefCounted
## classee (voir "Points durs" du prompt de lancement). Code pur, sans horloge
## 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 := ".!?…"
##
## Majuscule initiale et point final sont exiges comme n'importe quel autre
## mot : ce sont des conventions generales d'ecriture (toute phrase commence
## par une majuscule et finit par un point), pas une information que l'audio
## devrait faire entendre. L'enfant en est informe par le rappel affiche dans
## la scene de dictee (voir docs/specs/decisions.md, point 22), pas par
## tolerance a la correction.
static func analyze(dictation: Dictionary, submitted_text: String) -> CorrectionResult:
@@ -23,10 +23,8 @@ 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(_strip_terminal_punctuation(normalized_target))
var submitted_words := TextNormalizer.split_words(
_strip_terminal_punctuation(normalized_submitted)
)
var target_words := TextNormalizer.split_words(normalized_target)
var submitted_words := TextNormalizer.split_words(normalized_submitted)
var words_meta: Array = dictation["words"]
# L'alignement se fait insensible a la casse : une majuscule manquante est
@@ -83,27 +81,14 @@ static func analyze(dictation: Dictionary, submitted_text: String) -> Correction
static func _matches_target_or_variant(
dictation: Dictionary, normalized_submitted: String, normalized_target: String
) -> bool:
var stripped_submitted := _strip_terminal_punctuation(normalized_submitted)
if stripped_submitted == _strip_terminal_punctuation(normalized_target):
if normalized_submitted == normalized_target:
return true
for variant: String in dictation.get("accepted_variants", []):
if stripped_submitted == _strip_terminal_punctuation(TextNormalizer.normalize(variant)):
if normalized_submitted == 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", "")
+6 -14
View File
@@ -67,21 +67,13 @@ func test_normalizes_before_comparing() -> void:
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:
## Majuscule initiale et point final sont des conventions generales d'ecriture
## (voir docs/specs/decisions.md, point 22), exigees comme n'importe quel
## autre mot : l'enfant en est informe par un rappel affiche, pas par une
## tolerance a la correction.
func test_missing_terminal_punctuation_is_an_error() -> 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]["found"], "dorment")
assert_eq(result.errors[0]["expected"], "dorment.")
@@ -1,7 +1,8 @@
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.
## Reproduction de deux retours de test manuel via exercise_host, sur la
## dictee "Les petits chats dorment." (voir docs/specs/decisions.md, points
## 21 et 22) : la reponse exacte doit valider, et la majuscule/le point final
## restent exiges (conventions generales d'ecriture, pas une tolerance).
var _profile_id: String = ""
@@ -30,10 +31,7 @@ func test_exact_target_text_is_validated_as_correct() -> void:
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:
func test_missing_final_period_is_not_validated_as_correct() -> void:
var host: Control = await _ready_host()
var exercise_scene: Control = host.get_node("%ExerciseContainer").get_child(0)
@@ -41,7 +39,7 @@ func test_missing_final_period_is_still_validated_as_correct() -> void:
exercise_scene.get_node("%ValidateButton").pressed.emit()
await wait_process_frames(1)
assert_eq(_feedback_title(host), UIText.get_text("feedback_panel.correct_title"))
assert_eq(_feedback_title(host), UIText.get_text("feedback_panel.incorrect_title"))
func _ready_host() -> Control: