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,55 @@
|
||||
class_name WordAligner
|
||||
extends RefCounted
|
||||
## Alignement mot a mot entre le texte cible et la production de l'enfant
|
||||
## (distance d'edition, algorithme de Wagner-Fischer), avant toute comparaison
|
||||
## caractere par caractere : sans cela, un mot omis ou ajoute decale tous les
|
||||
## mots suivants et les transforme en erreurs (voir "Points durs" du prompt de
|
||||
## lancement).
|
||||
|
||||
|
||||
## Chaque operation : {"op": "match" | "substitute" | "delete" | "insert",
|
||||
## "target_index": int (-1 si insert), "submitted_index": int (-1 si delete)}.
|
||||
## "delete" = mot du texte cible absent de la production ; "insert" = mot
|
||||
## ajoute par l'enfant, absent du texte cible.
|
||||
static func align(target_words: Array[String], submitted_words: Array[String]) -> Array[Dictionary]:
|
||||
var n := target_words.size()
|
||||
var m := submitted_words.size()
|
||||
|
||||
var cost: Array = []
|
||||
for i in n + 1:
|
||||
var row: Array[int] = []
|
||||
for _j in m + 1:
|
||||
row.append(0)
|
||||
cost.append(row)
|
||||
for i in n + 1:
|
||||
cost[i][0] = i
|
||||
for j in m + 1:
|
||||
cost[0][j] = j
|
||||
|
||||
for i in range(1, n + 1):
|
||||
for j in range(1, m + 1):
|
||||
if target_words[i - 1] == submitted_words[j - 1]:
|
||||
cost[i][j] = cost[i - 1][j - 1]
|
||||
else:
|
||||
cost[i][j] = 1 + mini(mini(cost[i - 1][j], cost[i][j - 1]), cost[i - 1][j - 1])
|
||||
|
||||
var ops: Array[Dictionary] = []
|
||||
var i := n
|
||||
var j := m
|
||||
while i > 0 or j > 0:
|
||||
if i > 0 and j > 0 and target_words[i - 1] == submitted_words[j - 1]:
|
||||
ops.append({"op": "match", "target_index": i - 1, "submitted_index": j - 1})
|
||||
i -= 1
|
||||
j -= 1
|
||||
elif i > 0 and j > 0 and cost[i][j] == cost[i - 1][j - 1] + 1:
|
||||
ops.append({"op": "substitute", "target_index": i - 1, "submitted_index": j - 1})
|
||||
i -= 1
|
||||
j -= 1
|
||||
elif i > 0 and cost[i][j] == cost[i - 1][j] + 1:
|
||||
ops.append({"op": "delete", "target_index": i - 1, "submitted_index": -1})
|
||||
i -= 1
|
||||
else:
|
||||
ops.append({"op": "insert", "target_index": -1, "submitted_index": j - 1})
|
||||
j -= 1
|
||||
ops.reverse()
|
||||
return ops
|
||||
Reference in New Issue
Block a user