"""Normalisation du texte fon — module autonome du chantier (aucune dépendance au code du dépôt). Trois espaces d'écriture, du plus riche au plus pauvre : brut ──normaliser()──► forme canonique : NFD, minuscules, homoglyphes corrigés ──cible_tonale()─► ce qui se prononce : lettres, espaces, tons ; ni ponctuation ni chiffres ──sans_tons()───► les mêmes lettres, tons retirés des voyelles **L'espace d'évaluation** est `espace_eval(t) = sans_tons(cible_tonale(t))`, appliqué à la référence ET à l'hypothèse. Il reproduit à l'identique la définition utilisée par FonBench et par le document scientifique du projet : c'est la condition pour que nos chiffres soient comparables aux 26 modèles externes déjà mesurés. L'identité est **vérifiée par un test** sur tout le corpus (`pipeline/tests/test_equivalence_texte.py`), pas supposée. Choix documentés (DECISIONS.md, D-TXT-*) : - NFD en interne : `ɔ́` et `ɛ́` n'ont pas de forme précomposée ; en NFC le corpus serait mixte. - Les apostrophes d'élision disparaissent (`d'é` → `dé`) : c'est la convention de l'espace d'évaluation historique. Un modèle qui les écrirait serait pénalisé à tort. - Les chiffres disparaissent : la norme veut qu'on transcrive la langue dite ; les références qui en contiennent encore (0,6 %) sont signalées à part, pas corrigées en silence. - Un ton n'est retiré que s'il porte sur une voyelle : sur une consonne (`ń`), c'est une lettre. """ from __future__ import annotations import re import unicodedata # Tons du fon, en marques combinantes (NFD) AIGU, CARON, GRAVE, CIRCONFLEXE, MACRON = "́", "̌", "̀", "̂", "̄" TONS = frozenset({AIGU, CARON, GRAVE, CIRCONFLEXE, MACRON}) # Diacritiques sans valeur tonale en fon, rencontrés dans le corpus : retirés PARASITES = frozenset({"̧", "̈", "̣"}) # cédille, tréma, point souscrit LETTRES = frozenset("abcdefghijklmnopqrstuvwxyz") | {"ɔ", "ɖ", "ɛ"} CHIFFRES = frozenset("0123456789") VOYELLES = frozenset("aeiou") | {"ɔ", "ɛ"} BALISAGE = frozenset("<>+*") HOMOGLYPHES = { # graphie fautive → lettre ou signe correct "ε": "ɛ", "Ε": "ɛ", # epsilon grec "ð": "ɖ", "đ": "ɖ", "ɗ": "ɖ", # d barrés ou crochetés "‚": ",", "“": '"', "”": '"', "‘": "'", "’": "'", "œ": "oe", } _ESPACES = re.compile(r" {2,}") def normaliser(texte: str) -> str: """Forme canonique : NFD, minuscules, homoglyphes corrigés. Idempotente.""" t = unicodedata.normalize("NFD", texte).lower() t = unicodedata.normalize("NFD", t) # la minusculisation peut recomposer for faux, juste in HOMOGLYPHES.items(): t = t.replace(faux, juste) return unicodedata.normalize("NFD", t) def cible_tonale(texte: str, chiffres: bool = False) -> str: """Ce qui se prononce : lettres, espaces et tons. Tout le reste est retiré. `chiffres=True` (D-NUM-1, variante d'entraînement) : chaque chiffre 0-9 est gardé comme un mot isolé (« 21h » → « 2 1 h ») ; `recoller_chiffres` refait les nombres après décodage. L'espace d'évaluation officiel, lui, ne change pas (chiffres retirés).""" sortie: list[str] = [] for c in normaliser(texte): if c in TONS: # un ton sans lettre avant lui (tapé après une espace) ne se prononce pas if sortie and sortie[-1] != " ": sortie.append(c) elif chiffres and c in CHIFFRES: sortie.extend((" ", c, " ")) # un chiffre = un « mot » (KenLM : vocabulaire fermé 0-9) elif c in LETTRES or c == " ": sortie.append(c) elif c.isdigit() or c in PARASITES or c in BALISAGE \ or unicodedata.category(c).startswith("P"): continue elif c.isspace(): sortie.append(" ") # tout autre caractère (hors alphabet) est retiré return _ESPACES.sub(" ", "".join(sortie)).strip() def sans_tons(texte: str) -> str: """Retire les tons portés par une voyelle ; garde ceux d'une consonne (ce sont des lettres).""" sortie, base = [], "" for c in unicodedata.normalize("NFD", texte): if c in TONS: if base in VOYELLES: continue else: base = c sortie.append(c) return "".join(sortie) def espace_eval(texte: str, chiffres: bool = False) -> str: """Espace de mesure du WER sans tons : identique à FonBench (avec `chiffres=False`).""" return " ".join(sans_tons(cible_tonale(texte, chiffres)).split()) def espace_eval_tonal(texte: str, chiffres: bool = False) -> str: """Espace de mesure du WER avec tons.""" return " ".join(cible_tonale(texte, chiffres).split()) def recoller_chiffres(t: str) -> str: """Sortie d'un modèle D-NUM-1 : les chiffres isolés consécutifs redeviennent un nombre (« 2 1 » → « 21 »).""" out: list[str] = [] for w in t.split(): if w.isdigit() and out and out[-1].isdigit(): out[-1] += w else: out.append(w) return " ".join(out) def suite_chiffres(t: str) -> str: """Mesure D-NUM-1 : la suite ordonnée des chiffres d'un texte, sans séparateurs (insensible au groupement).""" return "".join(c for c in unicodedata.normalize("NFD", t) if c in CHIFFRES) def _auto_test() -> None: assert espace_eval("Un nɔ́ ɖɔxó, kpódó mɛ!") == "un nɔ ɖɔxo kpodo mɛ" assert espace_eval("D'é mɛ 2026 \"bureau\".") == "dé mɛ bureau".replace("é", "e") assert espace_eval_tonal("Kɔ́ 2 026 F, 3.5 %", chiffres=True) == unicodedata.normalize("NFD", "kɔ́ 2 0 2 6 f 3 5") assert espace_eval("ɖo 21h", chiffres=True) == "ɖo 2 1 h" and recoller_chiffres("ɖo 2 1 h 0 5") == "ɖo 21 h 05" assert suite_chiffres("Numéro : 05 55, le 2 026") == "05552026" assert espace_eval("Kɔ 2026", chiffres=False) == "kɔ" assert espace_eval_tonal("Mǐ ná yi") == unicodedata.normalize("NFD", "mǐ ná yi") assert espace_eval("ɛ́ ε ð") == "ɛ ɛ ɖ" # homoglyphes assert sans_tons(unicodedata.normalize("NFD", "ń")) == unicodedata.normalize("NFD", "ń") assert espace_eval(" a ́ b ") == "a b" # ton orphelin, espaces multiples for t in ("Nɛ́ ká gbɔn", "É cí nú mì ɖɔ"): assert normaliser(normaliser(t)) == normaliser(t) # idempotence print("texte.py : auto-test OK") if __name__ == "__main__": _auto_test()