Spaces:
Running
Running
Claude
refactor(core): faire de core/ un cercle 1 strict, déplacer cercle 2 vers measurements/
979f3c3 unverified | """Équivalences diplomatiques granulaires — Sprint 78 (A.I.5). | |
| Sprint 78 — A.I.5 du plan d'évolution 2026. | |
| Pourquoi ce module | |
| ------------------ | |
| Aujourd'hui les profils de ``picarones/core/normalization.py`` | |
| (``medieval_french``, ``early_modern_french``, etc.) appliquent un | |
| **bloc entier** de transformations. Mais un éditeur peut vouloir | |
| nuancer : *« je tolère ``ſ → s`` mais pas ``u → v`` »* — par | |
| exemple parce qu'il édite un imprimé du XVIᵉ où u/v sont | |
| distinctes mais où le s long doit être normalisé. | |
| Ce module **éclate** chaque profil en règles d'équivalence | |
| **nommées et indépendantes** que l'utilisateur peut activer ou | |
| désactiver une par une. La couche de calcul retourne le CER | |
| recalculé avec un sous-ensemble personnalisé. | |
| Format | |
| ------ | |
| Chaque règle a : | |
| - ``name`` : identifiant stable utilisé dans les URLs et l'UX | |
| (ex. ``"longs_s"``, ``"u_eq_v"``) | |
| - ``source`` : caractère ou séquence à remplacer | |
| - ``target`` : caractère ou séquence cible | |
| - ``description`` : phrase courte FR destinée à l'utilisateur | |
| - ``profile_tag`` : nom du profil dont elle est issue (utile pour | |
| grouper dans l'UX) | |
| Stratégie de découpage | |
| ---------------------- | |
| Couche de calcul d'abord (pattern Sprint 71/75/76). L'UX panneau | |
| avancé (cases à cocher + recalcul JS client + URL state) suivra | |
| dans un sprint dédié — la couche calcul livrée ici est une | |
| fondation suffisante pour qu'un développeur frontend câble la vue. | |
| """ | |
| from __future__ import annotations | |
| import logging | |
| from dataclasses import dataclass | |
| from typing import Iterable, Optional | |
| from picarones.measurements.normalization import ( | |
| DIPLOMATIC_EN_EARLY_MODERN, | |
| DIPLOMATIC_FR_EARLY_MODERN, | |
| DIPLOMATIC_LATIN_MEDIEVAL, | |
| DIPLOMATIC_MINIMAL, | |
| ) | |
| logger = logging.getLogger(__name__) | |
| class EquivalenceRule: | |
| """Une équivalence diplomatique nommée et indépendante.""" | |
| name: str | |
| source: str | |
| target: str | |
| description: str | |
| profile_tag: str | |
| # Catalogue : on dérive des profils existants en attribuant un nom | |
| # stable à chaque transformation. Les doublons (ex. ``ſ → s`` | |
| # présent dans plusieurs profils) sont fusionnés sous un nom unique | |
| # (le premier rencontré). | |
| def _build_catalog() -> dict[str, EquivalenceRule]: | |
| catalog: dict[str, EquivalenceRule] = {} | |
| # Noms canoniques pour les transformations courantes | |
| canonical_names: dict[tuple[str, str], tuple[str, str]] = { | |
| ("ſ", "s"): ("longs_s", "s long ſ → s"), | |
| ("u", "v"): ("u_eq_v", "u/v interchangeables (vpon → upon)"), | |
| ("i", "j"): ("i_eq_j", "i/j interchangeables (ioy → joy)"), | |
| ("y", "i"): ("y_eq_i", "y → i (Latin médiéval)"), | |
| ("vv", "w"): ("vv_eq_w", "vv → w (anglais moderne)"), | |
| ("æ", "ae"): ("ae_ligature", "æ → ae"), | |
| ("œ", "oe"): ("oe_ligature", "œ → oe"), | |
| ("þ", "th"): ("thorn_th", "þ (thorn) → th"), | |
| ("ð", "th"): ("eth_th", "ð (eth) → th"), | |
| ("ȝ", "y"): ("yogh_y", "ȝ (yogh) → y"), | |
| ("&", "et"): ("ampersand_et", "& → et (esperluette)"), | |
| ("ỹ", "yn"): ("y_tilde_yn", "ỹ → yn"), | |
| ("ꝑ", "per"): ("p_per", "ꝑ → per (abréviation Capelli)"), | |
| ("ꝓ", "pro"): ("p_pro", "ꝓ → pro (abréviation Capelli)"), | |
| ("ꝗ", "que"): ("q_que", "ꝗ → que (q barré)"), | |
| } | |
| sources = [ | |
| ("medieval_french", DIPLOMATIC_LATIN_MEDIEVAL), | |
| ("early_modern_french", DIPLOMATIC_FR_EARLY_MODERN), | |
| ("early_modern_english", DIPLOMATIC_EN_EARLY_MODERN), | |
| ("minimal", DIPLOMATIC_MINIMAL), | |
| ] | |
| for profile_tag, profile_dict in sources: | |
| for source, target in profile_dict.items(): | |
| key = (source, target) | |
| if key in canonical_names: | |
| name, desc = canonical_names[key] | |
| else: | |
| # Fallback : générer un nom à partir des codepoints | |
| name = f"{source}_to_{target}".replace(" ", "_") | |
| desc = f"{source} → {target}" | |
| if name in catalog: | |
| # On garde le profile_tag du premier rencontré, mais | |
| # on note que la règle est partagée. | |
| continue | |
| catalog[name] = EquivalenceRule( | |
| name=name, | |
| source=source, | |
| target=target, | |
| description=desc, | |
| profile_tag=profile_tag, | |
| ) | |
| return catalog | |
| BUILTIN_EQUIVALENCES: dict[str, EquivalenceRule] = _build_catalog() | |
| def list_equivalences_by_profile( | |
| profile_name: Optional[str] = None, | |
| ) -> list[EquivalenceRule]: | |
| """Liste les règles d'équivalence disponibles. | |
| Si ``profile_name`` est fourni, ne retourne que les règles dont | |
| ``profile_tag == profile_name`` (ou les règles dérivées de | |
| plusieurs profils dont au moins un est ``profile_name``). | |
| """ | |
| if profile_name is None: | |
| return list(BUILTIN_EQUIVALENCES.values()) | |
| return [ | |
| rule for rule in BUILTIN_EQUIVALENCES.values() | |
| if rule.profile_tag == profile_name | |
| ] | |
| def apply_selected_equivalences( | |
| text: Optional[str], | |
| selected_names: Iterable[str], | |
| ) -> str: | |
| """Applique uniquement les règles dont le nom est dans | |
| ``selected_names``. | |
| L'ordre d'application est l'ordre du catalogue interne — les | |
| transformations sont appliquées séquentiellement sur le texte. | |
| Les règles inconnues sont silencieusement ignorées (avec | |
| warning). | |
| """ | |
| if not text: | |
| return text or "" | |
| selected_set = set(selected_names) | |
| if not selected_set: | |
| return text | |
| out = text | |
| for name, rule in BUILTIN_EQUIVALENCES.items(): | |
| if name not in selected_set: | |
| continue | |
| out = out.replace(rule.source, rule.target) | |
| # Détection des règles inconnues (pour logger explicite) | |
| unknown = selected_set - set(BUILTIN_EQUIVALENCES.keys()) | |
| if unknown: | |
| logger.warning( | |
| "[equivalence_profile] règles inconnues ignorées : %s", | |
| sorted(unknown), | |
| ) | |
| return out | |
| def compute_cer_with_equivalences( | |
| reference: Optional[str], | |
| hypothesis: Optional[str], | |
| selected_names: Iterable[str], | |
| ) -> float: | |
| """Calcule le CER après application des équivalences sélectionnées | |
| sur les **deux** côtés (GT et hypothèse). | |
| Utilise ``picarones.measurements.metrics.compute_metrics`` et extrait | |
| le champ ``cer`` du résultat. | |
| """ | |
| from picarones.measurements.metrics import compute_metrics | |
| selected_list = list(selected_names) | |
| ref = apply_selected_equivalences(reference or "", selected_list) | |
| hyp = apply_selected_equivalences(hypothesis or "", selected_list) | |
| result = compute_metrics(ref, hyp) | |
| return result.cer | |
| __all__ = [ | |
| "EquivalenceRule", | |
| "BUILTIN_EQUIVALENCES", | |
| "list_equivalences_by_profile", | |
| "apply_selected_equivalences", | |
| "compute_cer_with_equivalences", | |
| ] | |