Never repeat an exercise within a mission; remove near-duplicate sentences (0.5.5)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Denis Defolie
2026-10-12 10:45:31 +02:00
parent f9600cfcf0
commit 409d8b46c5
24 changed files with 408 additions and 104 deletions
+22
View File
@@ -31,3 +31,25 @@ func test_returns_the_only_candidate_even_if_excluded() -> void:
var candidates: Array[Dictionary] = [{"id": "a"}]
var picked: Dictionary = ExercisePicker.pick(candidates, "a", rng)
assert_eq(picked["id"], "a")
func test_pick_unseen_never_repeats_while_others_remain() -> void:
var rng := RandomNumberGenerator.new()
rng.seed = 3
var candidates: Array[Dictionary] = [{"id": "a"}, {"id": "b"}, {"id": "c"}]
var seen: Array[String] = []
for _i in range(3):
var picked: Dictionary = ExercisePicker.pick_unseen(candidates, candidates, seen, rng)
assert_false(seen.has(picked["id"]), "un exercice deja vu a ete redonne")
seen.append(picked["id"])
assert_eq(seen.size(), 3)
func test_pick_unseen_falls_back_to_other_difficulty_then_to_repeat() -> void:
var rng := RandomNumberGenerator.new()
var preferred: Array[Dictionary] = [{"id": "a"}]
var all: Array[Dictionary] = [{"id": "a"}, {"id": "b"}]
var picked: Dictionary = ExercisePicker.pick_unseen(preferred, all, ["a"], rng)
assert_eq(picked["id"], "b")
var again: Dictionary = ExercisePicker.pick_unseen(preferred, all, ["a", "b"], rng)
assert_true(["a", "b"].has(again["id"]))
@@ -0,0 +1,100 @@
extends GutTest
## Deux phrases qui ne different que d'un mot (« Les petits chiens courent. »
## / « Les petits chiens jouent. ») donnent a l'enfant l'impression d'une
## redite. On compare les ensembles de mots : au-dela de 75 % de mots communs
## (phrases d'au moins 4 mots), les deux phrases sont jugees trop proches.
const EXERCISES_DIR := "res://data/exercises"
const DICTATIONS_DIR := "res://data/dictations"
const MAX_SIMILARITY := 0.75
const MIN_WORDS := 4
func test_no_two_sentences_are_nearly_identical() -> void:
var entries := _collect()
var close: Array[String] = []
for i in range(entries.size()):
for j in range(i + 1, entries.size()):
var a: Array = entries[i]["words"]
var b: Array = entries[j]["words"]
if a.size() < MIN_WORDS or b.size() < MIN_WORDS:
continue
var common := 0
for word: String in a:
if b.has(word):
common += 1
var similarity := float(common) / float(maxi(a.size(), b.size()))
if similarity >= MAX_SIMILARITY:
close.append(
(
"%s « %s » ~ %s « %s »"
% [
entries[i]["id"],
entries[i]["text"],
entries[j]["id"],
entries[j]["text"]
]
)
)
assert_eq(close.size(), 0, "Phrases trop proches :\n%s" % "\n".join(close))
assert_gt(entries.size(), 100)
func _collect() -> Array[Dictionary]:
var entries: Array[Dictionary] = []
for path in _json_files(EXERCISES_DIR):
var data := _read(path)
var sentence := ""
if data.get("type", "") == "multiple_choice":
var answers: Array = data["answers"]
sentence = str(data["question"]).replace(
"...", str(answers[int(data["correct_answer"])])
)
elif data.get("type", "") == "sentence_correction":
var words: Array[String] = []
for token: Dictionary in data["tokens"]:
words.append(token["text"] if token["correct"] else token["expected"])
sentence = " ".join(words)
if not sentence.is_empty():
entries.append({"id": data["id"], "text": sentence, "words": _words(sentence)})
for path in _json_files(DICTATIONS_DIR):
var data := _read(path)
var text: String = data["target_text"]
entries.append({"id": data["id"], "text": text, "words": _words(text)})
return entries
## Mots distincts, en minuscules, sans ponctuation.
func _words(sentence: String) -> Array[String]:
var cleaned := ""
for character in sentence.to_lower():
if character == "'" or character == " ":
cleaned += " "
elif character.to_upper() != character or "éèêàâîïôûùçœ".contains(character):
cleaned += character
else:
cleaned += " "
var result: Array[String] = []
for word in cleaned.split(" ", false):
if not result.has(word):
result.append(word)
return result
func _json_files(path: String) -> Array[String]:
var found: Array[String] = []
var dir := DirAccess.open(path)
if dir == null:
return found
for sub in dir.get_directories():
found.append_array(_json_files(path.path_join(sub)))
for file_name in dir.get_files():
if file_name.ends_with(".json"):
found.append(path.path_join(file_name))
return found
func _read(path: String) -> Dictionary:
var file := FileAccess.open(path, FileAccess.READ)
var parsed: Variant = JSON.parse_string(file.get_as_text())
return parsed if typeof(parsed) == TYPE_DICTIONARY else {}
@@ -0,0 +1 @@
uid://bignj6rqf37vv
@@ -86,3 +86,31 @@ func test_wrong_answer_escalates_hint_then_explanation_then_reveal() -> void:
var skill_state := ProgressManager.get_skill_state("accord_groupe_nominal")
assert_eq(skill_state["recent_results"], [false, false, false])
assert_eq(skill_state["recent_hint_used"], [false, true, true])
func test_a_mission_never_shows_the_same_exercise_twice() -> void:
# Regression : le tirage n'excluait que le dernier exercice, donc le meme
# enonce pouvait revenir dans une meme mission (A, B, A). On s'arrete au
# troisieme exercice charge, sans le terminer (pas de changement de scene).
for _run in range(10):
var host := await _start_host()
host.get_node("%OverlayContainer").get_child(0).continued.emit()
await wait_process_frames(1)
var shown: Array[String] = []
for step in range(3):
shown.append(host.get_current_exercise()["id"])
if step == 2:
break
var scene: Control = host.get_node("%ExerciseContainer").get_child(0)
scene.answer_submitted.emit(host.get_current_exercise()["correct_answer"])
await wait_process_frames(1)
var overlay: Control = host.get_node("%OverlayContainer")
var feedback: Control = overlay.get_child(overlay.get_child_count() - 1)
feedback.get_node("%ContinueButton").pressed.emit()
await wait_process_frames(1)
var unique := {}
for exercise_id in shown:
unique[exercise_id] = true
assert_eq(unique.size(), 3, "exercices vus : %s" % [shown])
host.queue_free()
await wait_process_frames(1)