mirror of
https://github.com/deunix-educ/aventuriers-des-mots.git
synced 2026-10-13 02:11:58 +02:00
Add pure correction and pedagogy logic with full test coverage
scripts/correction/: AnswerValidator (multiple choice, sentence correction via tap-the-faulty-word + retype) and DictationAnalyzer build the CorrectionResult structure from cahier des charges §6. Dictation analysis aligns words with WordAligner (Wagner-Fischer edit distance) before any character comparison, so an omitted or added word doesn't cascade into false errors on every following word. TextNormalizer handles non-breaking spaces, typographic apostrophes, and decomposed-vs-composed French accented characters via an explicit table (GDScript has no built-in NFC/NFD normalization). Error classification follows the data annotations only (rule_id present -> grammatical, absent -> unclassified), never a runtime linguistic guess. scripts/pedagogy/: DifficultyManager (per-skill difficulty from recent results), AttemptFlow (3-attempt graduated aid: hint, then explanation, then reveal, matching exercise-lifecycle.md's second gap), and ReviewScheduler (the five mastery states from §9, with session-counted spaced review intervals [1, 3, 6, 12] rather than wall-clock dates, since a family device's clock isn't trustworthy). All of it is pure: no scene tree, no clock or RNG read directly, every effectful input (session number, attempt outcome) is a parameter. 65 headless tests across both layers, including the word-omission-doesn't-cascade regression and the full decouverte -> acquise -> a_reviser -> acquise path. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
class_name AttemptFlow
|
||||
extends RefCounted
|
||||
## Aide graduee sur les tentatives successives d'un meme exercice, tant que la
|
||||
## reponse est fausse (docs/specs/decisions.md point 7 ; exercise-lifecycle.md,
|
||||
## ecart 2). Code pur.
|
||||
|
||||
enum SupportLevel { HINT, EXPLANATION, REVEAL }
|
||||
|
||||
const MAX_ATTEMPTS := 3
|
||||
|
||||
|
||||
## attempt_number : numero de la tentative fautive en cours (1 pour la
|
||||
## premiere erreur sur cet exercice, 2 pour la deuxieme, etc.).
|
||||
static func support_level_for_attempt(attempt_number: int) -> SupportLevel:
|
||||
if attempt_number <= 1:
|
||||
return SupportLevel.HINT
|
||||
if attempt_number < MAX_ATTEMPTS:
|
||||
return SupportLevel.EXPLANATION
|
||||
return SupportLevel.REVEAL
|
||||
|
||||
|
||||
## Vrai quand la tentative fautive courante doit mettre fin a l'exercice (la
|
||||
## reponse corrigee est alors montree, sans sanction, et la competence est
|
||||
## marquee pour une revision avec d'autres exemples).
|
||||
static func should_end_exercise(attempt_number: int) -> bool:
|
||||
return attempt_number >= MAX_ATTEMPTS
|
||||
@@ -0,0 +1 @@
|
||||
uid://dmkh8crqa1shs
|
||||
@@ -0,0 +1,35 @@
|
||||
class_name DifficultyManager
|
||||
extends RefCounted
|
||||
## Suggere le niveau de difficulte (1 a 3) du prochain exercice pour une
|
||||
## competence, a partir de l'historique recent de reussites. Code pur : ne lit
|
||||
## ni horloge ni hasard, ne modifie aucun etat (ProgressManager est responsable
|
||||
## du stockage).
|
||||
|
||||
const MIN_DIFFICULTY := 1
|
||||
const MAX_DIFFICULTY := 3
|
||||
const WINDOW := 3
|
||||
|
||||
|
||||
## recent_results : resultats des tentatives les plus recentes, la derniere en
|
||||
## dernier. Monte d'un niveau apres WINDOW reussites consecutives ; descend
|
||||
## d'un niveau apres WINDOW echecs consecutifs ; ne bouge pas sinon. Jamais en
|
||||
## dehors de [MIN_DIFFICULTY, MAX_DIFFICULTY] : la difficulte evolue sans
|
||||
## jamais bloquer durablement l'enfant (cahier des charges §2).
|
||||
static func next_difficulty(current_difficulty: int, recent_results: Array[bool]) -> int:
|
||||
if recent_results.size() < WINDOW:
|
||||
return clampi(current_difficulty, MIN_DIFFICULTY, MAX_DIFFICULTY)
|
||||
|
||||
var last_window: Array[bool] = recent_results.slice(recent_results.size() - WINDOW)
|
||||
var all_success := true
|
||||
var all_failure := true
|
||||
for result in last_window:
|
||||
if result:
|
||||
all_failure = false
|
||||
else:
|
||||
all_success = false
|
||||
|
||||
if all_success:
|
||||
return clampi(current_difficulty + 1, MIN_DIFFICULTY, MAX_DIFFICULTY)
|
||||
if all_failure:
|
||||
return clampi(current_difficulty - 1, MIN_DIFFICULTY, MAX_DIFFICULTY)
|
||||
return clampi(current_difficulty, MIN_DIFFICULTY, MAX_DIFFICULTY)
|
||||
@@ -0,0 +1 @@
|
||||
uid://dkdkwuxn8iw
|
||||
@@ -0,0 +1,200 @@
|
||||
class_name ReviewScheduler
|
||||
extends RefCounted
|
||||
## Transitions de maitrise (cahier des charges §9) et planification des
|
||||
## revisions. Code pur : l'horloge n'intervient jamais ; le numero de seance
|
||||
## est injecte par l'appelant (ProgressManager), jamais lu directement (voir
|
||||
## l'invariant d'architecture dans CLAUDE.md). Seuils et paliers detailles dans
|
||||
## docs/specs/decisions.md, point 5.
|
||||
|
||||
const RECENT_WINDOW_SMALL := 5
|
||||
const RECENT_WINDOW_LARGE := 8
|
||||
const MIN_SUCCESS_RATE_CONSOLIDATION := 0.6
|
||||
const MIN_SUCCESS_RATE_ACQUISE := 0.85
|
||||
const MIN_SUCCESSES_WITHOUT_HINT_CONSOLIDATION := 2
|
||||
const MIN_HINTLESS_RECENT_SUCCESSES_ACQUISE := 4
|
||||
const MIN_DISTINCT_SESSIONS_ACQUISE := 3
|
||||
const MIN_DISTINCT_FORMATS_ACQUISE := 2
|
||||
const REVIEW_INTERVALS_SESSIONS: Array[int] = [1, 3, 6, 12]
|
||||
|
||||
const STATUS_DECOUVERTE := "decouverte"
|
||||
const STATUS_EN_ACQUISITION := "en_acquisition"
|
||||
const STATUS_EN_CONSOLIDATION := "en_consolidation"
|
||||
const STATUS_ACQUISE := "acquise"
|
||||
const STATUS_A_REVISER := "a_reviser"
|
||||
|
||||
|
||||
static func initial_state() -> Dictionary:
|
||||
return {
|
||||
"status": STATUS_DECOUVERTE,
|
||||
"recent_results": [] as Array[bool],
|
||||
"recent_hint_used": [] as Array[bool],
|
||||
"sessions_succeeded": [] as Array[int],
|
||||
"formats_succeeded": [] as Array[String],
|
||||
"last_session_practiced": -1,
|
||||
"review_interval_index": -1,
|
||||
"next_review_session": -1,
|
||||
}
|
||||
|
||||
|
||||
## attempt : {"success": bool, "hint_used": bool, "exercise_type": String}.
|
||||
## Retourne un nouvel etat ; ne modifie jamais `state` en place.
|
||||
static func record_attempt(
|
||||
state: Dictionary, attempt: Dictionary, current_session: int
|
||||
) -> Dictionary:
|
||||
var previous_status: String = state["status"]
|
||||
var new_state: Dictionary = state.duplicate(true)
|
||||
var success: bool = attempt["success"]
|
||||
var hint_used: bool = attempt["hint_used"]
|
||||
var exercise_type: String = attempt["exercise_type"]
|
||||
|
||||
_push_bounded(new_state["recent_results"], success, RECENT_WINDOW_LARGE)
|
||||
_push_bounded(new_state["recent_hint_used"], hint_used, RECENT_WINDOW_LARGE)
|
||||
new_state["last_session_practiced"] = current_session
|
||||
|
||||
if success:
|
||||
if not new_state["sessions_succeeded"].has(current_session):
|
||||
new_state["sessions_succeeded"].append(current_session)
|
||||
if not new_state["formats_succeeded"].has(exercise_type):
|
||||
new_state["formats_succeeded"].append(exercise_type)
|
||||
|
||||
var was_due: bool = is_due_for_review(state, current_session)
|
||||
new_state["status"] = _next_status(new_state, success)
|
||||
_update_review_schedule(new_state, previous_status, success, was_due, current_session)
|
||||
|
||||
return new_state
|
||||
|
||||
|
||||
static func is_due_for_review(state: Dictionary, current_session: int) -> bool:
|
||||
var next_review: int = state.get("next_review_session", -1)
|
||||
return next_review != -1 and current_session >= next_review
|
||||
|
||||
|
||||
## A appeler au debut d'une seance pour chaque competence "acquise" avant toute
|
||||
## nouvelle tentative : un delai de revision atteint sans pratique bascule la
|
||||
## competence en "a_reviser" (cahier des charges §9, delai de revision).
|
||||
static func mark_overdue_as_review(state: Dictionary, current_session: int) -> Dictionary:
|
||||
if state["status"] == STATUS_ACQUISE and is_due_for_review(state, current_session):
|
||||
var new_state: Dictionary = state.duplicate(true)
|
||||
new_state["status"] = STATUS_A_REVISER
|
||||
new_state["review_interval_index"] = -1
|
||||
new_state["next_review_session"] = -1
|
||||
return new_state
|
||||
return state
|
||||
|
||||
|
||||
static func _update_review_schedule(
|
||||
new_state: Dictionary,
|
||||
previous_status: String,
|
||||
success: bool,
|
||||
was_due: bool,
|
||||
current_session: int,
|
||||
) -> void:
|
||||
var status: String = new_state["status"]
|
||||
|
||||
if status == STATUS_A_REVISER:
|
||||
new_state["review_interval_index"] = -1
|
||||
new_state["next_review_session"] = -1
|
||||
return
|
||||
|
||||
if status != STATUS_ACQUISE:
|
||||
return
|
||||
|
||||
if previous_status != STATUS_ACQUISE:
|
||||
# Premiere arrivee en "acquise" (depuis en_consolidation ou a_reviser).
|
||||
new_state["review_interval_index"] = 0
|
||||
new_state["next_review_session"] = current_session + REVIEW_INTERVALS_SESSIONS[0]
|
||||
return
|
||||
|
||||
if success and was_due:
|
||||
var next_index: int = mini(
|
||||
int(new_state["review_interval_index"]) + 1, REVIEW_INTERVALS_SESSIONS.size() - 1
|
||||
)
|
||||
new_state["review_interval_index"] = next_index
|
||||
new_state["next_review_session"] = current_session + REVIEW_INTERVALS_SESSIONS[next_index]
|
||||
|
||||
|
||||
static func _next_status(state: Dictionary, last_success: bool) -> String:
|
||||
var current_status: String = state["status"]
|
||||
|
||||
if current_status == STATUS_ACQUISE or current_status == STATUS_A_REVISER:
|
||||
return STATUS_ACQUISE if last_success else STATUS_A_REVISER
|
||||
|
||||
if not last_success:
|
||||
return current_status
|
||||
|
||||
if current_status == STATUS_DECOUVERTE:
|
||||
return STATUS_EN_ACQUISITION
|
||||
|
||||
if current_status == STATUS_EN_ACQUISITION:
|
||||
return STATUS_EN_CONSOLIDATION if _is_ready_for_consolidation(state) else current_status
|
||||
|
||||
if current_status == STATUS_EN_CONSOLIDATION:
|
||||
return STATUS_ACQUISE if _is_ready_for_acquise(state) else current_status
|
||||
|
||||
return current_status
|
||||
|
||||
|
||||
static func _is_ready_for_consolidation(state: Dictionary) -> bool:
|
||||
var recent: Array = _last_n(state["recent_results"], RECENT_WINDOW_SMALL)
|
||||
var hints: Array = _last_n(state["recent_hint_used"], RECENT_WINDOW_SMALL)
|
||||
if recent.is_empty():
|
||||
return false
|
||||
|
||||
var successes_without_hint := 0
|
||||
var success_count := 0
|
||||
for i in recent.size():
|
||||
if recent[i]:
|
||||
success_count += 1
|
||||
if not hints[i]:
|
||||
successes_without_hint += 1
|
||||
|
||||
var success_rate := float(success_count) / float(recent.size())
|
||||
return (
|
||||
successes_without_hint >= MIN_SUCCESSES_WITHOUT_HINT_CONSOLIDATION
|
||||
and success_rate >= MIN_SUCCESS_RATE_CONSOLIDATION
|
||||
)
|
||||
|
||||
|
||||
static func _is_ready_for_acquise(state: Dictionary) -> bool:
|
||||
var recent: Array = _last_n(state["recent_results"], RECENT_WINDOW_LARGE)
|
||||
var hints: Array = _last_n(state["recent_hint_used"], RECENT_WINDOW_LARGE)
|
||||
if recent.size() < RECENT_WINDOW_LARGE:
|
||||
return false
|
||||
|
||||
var success_count := 0
|
||||
for result in recent:
|
||||
if result:
|
||||
success_count += 1
|
||||
var success_rate := float(success_count) / float(recent.size())
|
||||
if success_rate < MIN_SUCCESS_RATE_ACQUISE:
|
||||
return false
|
||||
|
||||
var hintless_recent_successes := 0
|
||||
var i := recent.size() - 1
|
||||
while i >= 0 and hintless_recent_successes < MIN_HINTLESS_RECENT_SUCCESSES_ACQUISE:
|
||||
if recent[i]:
|
||||
if hints[i]:
|
||||
return false
|
||||
hintless_recent_successes += 1
|
||||
i -= 1
|
||||
if hintless_recent_successes < MIN_HINTLESS_RECENT_SUCCESSES_ACQUISE:
|
||||
return false
|
||||
|
||||
if state["sessions_succeeded"].size() < MIN_DISTINCT_SESSIONS_ACQUISE:
|
||||
return false
|
||||
if state["formats_succeeded"].size() < MIN_DISTINCT_FORMATS_ACQUISE:
|
||||
return false
|
||||
|
||||
return true
|
||||
|
||||
|
||||
static func _push_bounded(array: Array, value: Variant, max_size: int) -> void:
|
||||
array.append(value)
|
||||
while array.size() > max_size:
|
||||
array.remove_at(0)
|
||||
|
||||
|
||||
static func _last_n(array: Array, n: int) -> Array:
|
||||
if array.size() <= n:
|
||||
return array.duplicate()
|
||||
return array.slice(array.size() - n)
|
||||
@@ -0,0 +1 @@
|
||||
uid://cvg4qps443hwe
|
||||
Reference in New Issue
Block a user