Spaces:
Running
phaseB: extras/historical/ — 8 modules philologiques + 2 renderers en Cercle 3
Browse filesDeuxième phase de la refonte en 3 cercles. Cible : extraire les
métriques philologiques (cas d'usage patrimoniaux par période) du
cœur ``core/`` vers le sous-package ``extras/historical/``.
Modules déplacés (8 modules, ~3000 lignes)
------------------------------------------
``picarones/extras/historical/`` :
- ``unicode_blocks.py`` précision par bloc Unicode (toutes périodes)
- ``abbreviations.py`` score d'expansion (Capelli, médiéval)
- ``mufi.py`` couverture MUFI v4.0 (médiéval, PUA)
- ``early_modern_typography.py`` fl fi ſ ã & ı (XVIᵉ-XVIIIᵉ)
- ``modern_archives.py`` Mme/Mlle/°/†/₶ (XIXᵉ-XXᵉ)
- ``roman_numerals.py`` numéraux romains (toutes périodes)
- ``lexical_modernization.py`` top tokens GT modernisés
- ``philological_runner.py`` orchestration adaptive des 6 modules
Renderers (2 fichiers, ~700 lignes)
-----------------------------------
``picarones/extras/render/`` :
- ``philological_render.py`` profil philologique 6 sections
- ``lexical_modernization_render.py`` table top tokens
Rétrocompatibilité absolue (10 shims de 16 lignes)
---------------------------------------------------
Imports historiques préservés :
from picarones.core.unicode_blocks import compute_unicode_block_accuracy
from picarones.core.philological_runner import compute_philological_metrics
from picarones.report.philological_render import build_philological_profile_html
L'identité est préservée — ``shim.X is extras.X`` (test ``is``
vérifié), pas de duplication de logique.
Dépendance Cercle 2 → Cercle 3 (note architecturale)
-----------------------------------------------------
``picarones/core/numerical_sequences.py`` (Cercle 2 — measurements/)
importe ``roman_numerals`` (Cercle 3 — extras/historical/) pour
détecter les numéraux romains dans les séquences numériques. Cette
dépendance traverse le shim et fonctionne. Acceptée car :
- ``numerical_sequences`` est lui-même semi-historique (détecte
dates anciennes, foliotation archivistique).
- Le shim assure la rétrocompat sans coût d'exécution.
- Si on extrait ``picarones-historical`` en package PyPI séparé un
jour, on devra rendre cette dépendance optionnelle (try/except).
pyproject.toml — extra [historical] documenté
---------------------------------------------
Nouvel extra ``picarones[historical]`` déclaré (vide pour l'instant
— les modules sont dans le package principal). Documente l'intention
de séparation future en package PyPI distinct. Inclus dans l'extra
``[all]``.
Validation 8/8 en sandbox
-------------------------
- 10 imports rétrocompat OK (8 core + 2 render).
- Identité shim ↔ nouveau chemin préservée (3 paires testées).
- ``philological_runner`` détecte 5 modules de signal sur texte
médiéval test (⁊ par leſ XIV. fontoyers) : unicode_blocks,
abbreviations, mufi, early_modern, roman_numerals.
- Dépendance Cercle 2→3 (``numerical_sequences`` → ``roman_numerals``
via shim) : score strict 1.00 sur "Le roi Louis XIV régna jusqu'en 1715".
- Hook ``philological`` toujours présent dans les 12 hooks doc
enregistrés par ``builtin_hooks``.
- pyproject.toml : extra ``[historical]`` documenté.
- 10 shims minces (16 lignes chacun, pas de logique métier).
- Vue ``advanced_taxonomy`` du chantier 3 fonctionne avec
``lexical_modernization`` opt-in (5359 chars produits).
Tests
-----
+250 lignes dans tests/test_phaseB_migration.py organisés en 8 classes :
TestPhilologicalRetrocompat, TestNewHistoricalImports,
TestIdentityThroughShim, TestPhilologicalRunnerIntegration,
TestCercle2DependsOnCercle3ViaShim, TestPyprojectExtra,
TestBuiltinHooksStillRegisterPhilological, TestOriginalsAreShims.
Bilan cumulé phases A + B
-------------------------
- Cercle 3 contient maintenant 18 modules + 6 renderers.
- ``core/`` allégé de 12 modules (4 phase A + 8 phase B).
- ``report/`` allégé de 6 renderers (4 phase A + 2 phase B).
- Aucune ligne de fonctionnalité supprimée.
Phases suivantes
----------------
- Phase C : extras/importers/ (3-5 jours).
- Phase E : core/ → core/ (Cercle 1) + measurements/ (Cercle 2).
- Phase D : docs/api-stable.md + test_public_api.py + version 2.0.
- picarones/core/abbreviations.py +12 -345
- picarones/core/early_modern_typography.py +12 -337
- picarones/core/lexical_modernization.py +12 -258
- picarones/core/modern_archives.py +12 -595
- picarones/core/mufi.py +12 -259
- picarones/core/philological_runner.py +12 -358
- picarones/core/roman_numerals.py +12 -473
- picarones/core/unicode_blocks.py +12 -228
- picarones/extras/historical/__init__.py +30 -0
- picarones/extras/historical/abbreviations.py +350 -0
- picarones/extras/historical/early_modern_typography.py +342 -0
- picarones/extras/historical/lexical_modernization.py +263 -0
- picarones/extras/historical/modern_archives.py +600 -0
- picarones/extras/historical/mufi.py +264 -0
- picarones/extras/historical/philological_runner.py +363 -0
- picarones/extras/historical/roman_numerals.py +478 -0
- picarones/extras/historical/unicode_blocks.py +233 -0
- picarones/extras/render/lexical_modernization_render.py +119 -0
- picarones/extras/render/philological_render.py +615 -0
- picarones/report/lexical_modernization_render.py +12 -114
- picarones/report/philological_render.py +12 -610
- pyproject.toml +11 -1
- tests/test_phaseB_migration.py +249 -0
|
@@ -1,350 +1,17 @@
|
|
| 1 |
-
"""
|
| 2 |
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
| 4 |
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
Sur les manuscrits médiévaux (chartes, registres, copies de droit
|
| 8 |
-
canonique), les scribes utilisent intensivement des **signes
|
| 9 |
-
d'abréviation** : ``ꝑ`` (per/par), ``ꝓ`` (pro), ``ꝗ`` (qui),
|
| 10 |
-
``ꝙ`` (quia), ``ꝯ`` (con/-us), ``⁊`` (et), tilde combinant pour
|
| 11 |
-
``-en/-an``, etc.
|
| 12 |
-
|
| 13 |
-
Un OCR/HTR a deux comportements possibles face à ces signes :
|
| 14 |
-
|
| 15 |
-
1. **Préservation** : la forme abrégée est gardée telle quelle
|
| 16 |
-
(``ꝑ`` → ``ꝑ``). C'est le comportement attendu d'une
|
| 17 |
-
transcription **diplomatique** (édition critique).
|
| 18 |
-
2. **Développement** : le signe est remplacé par sa forme
|
| 19 |
-
développée (``ꝑ`` → ``per``). C'est le comportement attendu
|
| 20 |
-
d'une édition **modernisée**.
|
| 21 |
-
|
| 22 |
-
Une troisième possibilité — et c'est l'erreur qu'on cherche à
|
| 23 |
-
détecter : le signe est **mal restitué** (remplacé par un
|
| 24 |
-
caractère ASCII proche, supprimé, ou mal développé).
|
| 25 |
-
|
| 26 |
-
Ce module produit deux scores complémentaires :
|
| 27 |
-
|
| 28 |
-
- ``abbreviation_strict_score`` : taux d'abréviations GT dont la
|
| 29 |
-
**forme abrégée Unicode est préservée** dans l'OCR.
|
| 30 |
-
- ``abbreviation_expansion_score`` : taux d'abréviations GT dont
|
| 31 |
-
**soit** la forme abrégée, **soit** la forme développée
|
| 32 |
-
attendue, est présente dans l'OCR.
|
| 33 |
-
|
| 34 |
-
Le **ratio** des deux dit beaucoup sur la convention adoptée :
|
| 35 |
-
|
| 36 |
-
- ``strict ≈ expansion`` proche de 1 → le moteur est diplomatique
|
| 37 |
-
(préserve l'abrégé) ;
|
| 38 |
-
- ``strict << expansion`` → le moteur est modernisant (développe
|
| 39 |
-
systématiquement) ;
|
| 40 |
-
- les deux faibles → le moteur perd les abréviations (signal
|
| 41 |
-
d'erreur OCR).
|
| 42 |
-
|
| 43 |
-
Stratégie de découpage
|
| 44 |
-
----------------------
|
| 45 |
-
Cohérente avec NER (Sprint 38), Flesch (52), Reading order F1 (53),
|
| 46 |
-
Layout F1 (54), Bloc Unicode (55) : couche de calcul pure d'abord.
|
| 47 |
-
Le câblage runner et la vue HTML suivent dans des sprints dédiés.
|
| 48 |
-
|
| 49 |
-
Limites documentées
|
| 50 |
-
-------------------
|
| 51 |
-
- L'alignement est **bag-of-occurrences** (proxy positionnel
|
| 52 |
-
simple) : on compte les occurrences GT et on vérifie leur
|
| 53 |
-
présence dans l'hyp. Pas d'alignement séquentiel rigoureux.
|
| 54 |
-
- La table d'abréviations couvre les signes les plus courants en
|
| 55 |
-
scriptura latine européenne (Capelli). Elle est extensible via
|
| 56 |
-
``ABBREVIATION_EXPANSIONS``.
|
| 57 |
-
- Pour les abréviations marquées par un **tilde combinant**
|
| 58 |
-
(``p̃``, ``q̃``), on détecte la séquence ``lettre + U+0303``.
|
| 59 |
-
Pas de gestion fine des polices Capelli/MUFI complètes.
|
| 60 |
"""
|
| 61 |
|
| 62 |
-
from
|
| 63 |
-
|
| 64 |
-
import logging
|
| 65 |
-
import re
|
| 66 |
-
import unicodedata
|
| 67 |
-
from typing import Optional
|
| 68 |
-
|
| 69 |
-
from picarones.core.metric_registry import register_metric
|
| 70 |
-
from picarones.core.modules import ArtifactType
|
| 71 |
-
|
| 72 |
-
logger = logging.getLogger(__name__)
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 76 |
-
# Table d'expansions
|
| 77 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 78 |
-
|
| 79 |
-
# Signes d'abréviation latins médiévaux les plus courants.
|
| 80 |
-
# Source : Capelli, "Lexicon Abbreviaturarum" (1929) + MUFI.
|
| 81 |
-
#
|
| 82 |
-
# La clé est une chaîne (1 ou 2 code-points pour le cas tilde
|
| 83 |
-
# combinant) ; la valeur est la liste des expansions courantes
|
| 84 |
-
# acceptées (les détails varient selon la convention éditoriale,
|
| 85 |
-
# on accepte plusieurs formes).
|
| 86 |
-
ABBREVIATION_EXPANSIONS: dict[str, tuple[str, ...]] = {
|
| 87 |
-
"ꝑ": ("per", "par"), # U+A751
|
| 88 |
-
"ꝓ": ("pro",), # U+A753
|
| 89 |
-
"ꝗ": ("qui",), # U+A757
|
| 90 |
-
"ꝙ": ("quia",), # U+A759
|
| 91 |
-
"ꝯ": ("us", "con"), # U+A76F
|
| 92 |
-
"⁊": ("et",), # U+204A "et" tironien
|
| 93 |
-
"ꝝ": ("rum",), # U+A75D
|
| 94 |
-
"ꝫ": ("et",), # U+A76B
|
| 95 |
-
"ꝭ": ("is",), # U+A76D
|
| 96 |
-
# Tilde combinant après lettre (U+0303 = ̃) : pẽ, qũ, etc.
|
| 97 |
-
"p̃": ("par", "per"),
|
| 98 |
-
"q̃": ("que", "qui"),
|
| 99 |
-
"ñ": ("an", "en"), # U+00F1 (Latin-1 Sup)
|
| 100 |
-
# Note : ñ existe aussi comme caractère latin moderne (espagnol),
|
| 101 |
-
# donc l'attribuer aux abréviations introduit du bruit ; on
|
| 102 |
-
# laisse au benchmark le soin d'évaluer. Pour les éditeurs
|
| 103 |
-
# médiévistes qui veulent restreindre, ils peuvent passer par
|
| 104 |
-
# une table custom (à venir).
|
| 105 |
-
}
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
# Set des "premiers code-points" reconnus comme début d'une
|
| 109 |
-
# abréviation (pour balayage rapide).
|
| 110 |
-
_ABBR_FIRST_CHARS: frozenset[str] = frozenset(
|
| 111 |
-
abbr[0] for abbr in ABBREVIATION_EXPANSIONS
|
| 112 |
-
)
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
# Combining tilde (U+0303) — utilisé pour la détection p̃, q̃, etc.
|
| 116 |
-
_COMBINING_TILDE = "̃"
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 120 |
-
# Détection d'abréviations dans un texte
|
| 121 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
def detect_abbreviations(text: Optional[str]) -> list[str]:
|
| 125 |
-
"""Liste des abréviations médiévales détectées dans ``text``,
|
| 126 |
-
dans l'ordre d'apparition.
|
| 127 |
-
|
| 128 |
-
Reconnaît :
|
| 129 |
-
|
| 130 |
-
- Les caractères Unicode dédiés présents dans
|
| 131 |
-
``ABBREVIATION_EXPANSIONS`` (``ꝑ``, ``ꝓ``, ``⁊``…).
|
| 132 |
-
- Les séquences ``lettre + U+0303`` (tilde combinant) si la
|
| 133 |
-
paire est dans la table (``p̃``, ``q̃``).
|
| 134 |
-
|
| 135 |
-
Doublons conservés : si le texte contient deux ``ꝑ``, la liste
|
| 136 |
-
en a deux. Cohérent avec le calcul bag-of-occurrences en aval.
|
| 137 |
-
"""
|
| 138 |
-
if not text:
|
| 139 |
-
return []
|
| 140 |
-
found: list[str] = []
|
| 141 |
-
# Forme NFD pour reconnaître les ã, p̃, q̃ même quand l'utilisateur
|
| 142 |
-
# passe la forme NFC (« ñ » = U+00F1 sera traité par le mapping
|
| 143 |
-
# direct ; les séquences manuelles ``p`` + tilde combinant restent
|
| 144 |
-
# détectables).
|
| 145 |
-
text_nfd = unicodedata.normalize("NFD", text)
|
| 146 |
-
i = 0
|
| 147 |
-
while i < len(text_nfd):
|
| 148 |
-
ch = text_nfd[i]
|
| 149 |
-
# Cas 1 : lettre + tilde combinant
|
| 150 |
-
if i + 1 < len(text_nfd) and text_nfd[i + 1] == _COMBINING_TILDE:
|
| 151 |
-
seq = ch + _COMBINING_TILDE
|
| 152 |
-
if seq in ABBREVIATION_EXPANSIONS:
|
| 153 |
-
found.append(seq)
|
| 154 |
-
i += 2
|
| 155 |
-
continue
|
| 156 |
-
# Cas 2 : caractère unicode dédié
|
| 157 |
-
if ch in ABBREVIATION_EXPANSIONS:
|
| 158 |
-
found.append(ch)
|
| 159 |
-
i += 1
|
| 160 |
-
return found
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 164 |
-
# Scores
|
| 165 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
def _hyp_contains_abbr(hypothesis: str, abbr: str) -> bool:
|
| 169 |
-
"""Vrai si la forme abrégée ``abbr`` apparaît telle quelle dans
|
| 170 |
-
``hypothesis``. Sensible aux deux formes NFC / NFD pour les
|
| 171 |
-
séquences à tilde combinant."""
|
| 172 |
-
if abbr in hypothesis:
|
| 173 |
-
return True
|
| 174 |
-
# Pour les séquences ``lettre + tilde combinant``, l'hyp peut
|
| 175 |
-
# avoir une forme NFC (ex. ``ñ`` au lieu de ``n + U+0303``).
|
| 176 |
-
nfd = unicodedata.normalize("NFD", hypothesis)
|
| 177 |
-
return abbr in nfd
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
def _hyp_contains_expansion(
|
| 181 |
-
hypothesis: str, expansions: tuple[str, ...],
|
| 182 |
-
) -> bool:
|
| 183 |
-
"""Vrai si l'une des formes développées apparaît dans ``hypothesis``
|
| 184 |
-
(recherche insensible à la casse, sur les frontières de mots
|
| 185 |
-
pour limiter les faux positifs sur les sous-chaînes courtes
|
| 186 |
-
type ``us`` ou ``et``)."""
|
| 187 |
-
if not expansions:
|
| 188 |
-
return False
|
| 189 |
-
hyp_lower = hypothesis.lower()
|
| 190 |
-
for exp in expansions:
|
| 191 |
-
if not exp:
|
| 192 |
-
continue
|
| 193 |
-
# Recherche frontière de mot pour les expansions courtes.
|
| 194 |
-
# Pour ``per`` ou ``pro`` : on accepte le développement à
|
| 195 |
-
# n'importe quelle position d'un mot (tolère ``per`` dans
|
| 196 |
-
# ``permettre``, c'est imprécis mais pragmatique). Pour
|
| 197 |
-
# les expansions très courtes (≤ 2 lettres), on impose un
|
| 198 |
-
# mot complet pour limiter le bruit.
|
| 199 |
-
if len(exp) <= 2:
|
| 200 |
-
if re.search(rf"\b{re.escape(exp)}\b", hyp_lower):
|
| 201 |
-
return True
|
| 202 |
-
else:
|
| 203 |
-
if exp.lower() in hyp_lower:
|
| 204 |
-
return True
|
| 205 |
-
return False
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
def compute_abbreviation_metrics(
|
| 209 |
-
reference: Optional[str],
|
| 210 |
-
hypothesis: Optional[str],
|
| 211 |
-
) -> dict:
|
| 212 |
-
"""Calcule les scores d'abréviation strict et d'expansion.
|
| 213 |
-
|
| 214 |
-
Parameters
|
| 215 |
-
----------
|
| 216 |
-
reference:
|
| 217 |
-
Texte GT (avec abréviations médiévales originales).
|
| 218 |
-
hypothesis:
|
| 219 |
-
Texte produit par l'OCR.
|
| 220 |
-
|
| 221 |
-
Returns
|
| 222 |
-
-------
|
| 223 |
-
dict
|
| 224 |
-
``{
|
| 225 |
-
"n_abbreviations_in_reference": int,
|
| 226 |
-
"n_strict_preserved": int, # forme abrégée préservée
|
| 227 |
-
"n_expansion_preserved": int, # abrégée OU développée
|
| 228 |
-
"strict_score": float, # ∈ [0, 1]
|
| 229 |
-
"expansion_score": float, # ∈ [0, 1]
|
| 230 |
-
"per_abbreviation": [
|
| 231 |
-
{"abbr", "strict_preserved", "expansion_preserved",
|
| 232 |
-
"expansions"},
|
| 233 |
-
...
|
| 234 |
-
],
|
| 235 |
-
}``
|
| 236 |
-
|
| 237 |
-
Cas dégénérés
|
| 238 |
-
-------------
|
| 239 |
-
- GT vide ou sans abréviation détectée → tous les compteurs à 0
|
| 240 |
-
et les scores à ``0.0`` (convention : on ne récompense pas
|
| 241 |
-
l'absence d'abréviations).
|
| 242 |
-
- GT non vide avec abréviations + hyp vide → tous les scores
|
| 243 |
-
à ``0.0``.
|
| 244 |
-
"""
|
| 245 |
-
ref = reference or ""
|
| 246 |
-
hyp = hypothesis or ""
|
| 247 |
-
|
| 248 |
-
abbreviations = detect_abbreviations(ref)
|
| 249 |
-
n = len(abbreviations)
|
| 250 |
-
if n == 0:
|
| 251 |
-
return {
|
| 252 |
-
"n_abbreviations_in_reference": 0,
|
| 253 |
-
"n_strict_preserved": 0,
|
| 254 |
-
"n_expansion_preserved": 0,
|
| 255 |
-
"strict_score": 0.0,
|
| 256 |
-
"expansion_score": 0.0,
|
| 257 |
-
"per_abbreviation": [],
|
| 258 |
-
}
|
| 259 |
-
|
| 260 |
-
n_strict = 0
|
| 261 |
-
n_expansion = 0
|
| 262 |
-
per_abbr: list[dict] = []
|
| 263 |
-
for abbr in abbreviations:
|
| 264 |
-
expansions = ABBREVIATION_EXPANSIONS.get(abbr, ())
|
| 265 |
-
strict_ok = _hyp_contains_abbr(hyp, abbr)
|
| 266 |
-
# Expansion : on accepte la forme abrégée OU le développement.
|
| 267 |
-
# Convention : si l'OCR a préservé la forme abrégée, c'est
|
| 268 |
-
# aussi compté comme valide pour le score d'expansion (le
|
| 269 |
-
# moteur n'a pas perdu l'information ; il a juste choisi
|
| 270 |
-
# une convention diplomatique).
|
| 271 |
-
expansion_ok = strict_ok or _hyp_contains_expansion(hyp, expansions)
|
| 272 |
-
if strict_ok:
|
| 273 |
-
n_strict += 1
|
| 274 |
-
if expansion_ok:
|
| 275 |
-
n_expansion += 1
|
| 276 |
-
per_abbr.append({
|
| 277 |
-
"abbr": abbr,
|
| 278 |
-
"strict_preserved": strict_ok,
|
| 279 |
-
"expansion_preserved": expansion_ok,
|
| 280 |
-
"expansions": list(expansions),
|
| 281 |
-
})
|
| 282 |
-
|
| 283 |
-
return {
|
| 284 |
-
"n_abbreviations_in_reference": n,
|
| 285 |
-
"n_strict_preserved": n_strict,
|
| 286 |
-
"n_expansion_preserved": n_expansion,
|
| 287 |
-
"strict_score": n_strict / n,
|
| 288 |
-
"expansion_score": n_expansion / n,
|
| 289 |
-
"per_abbreviation": per_abbr,
|
| 290 |
-
}
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
def abbreviation_strict_score(
|
| 294 |
-
reference: Optional[str], hypothesis: Optional[str],
|
| 295 |
-
) -> float:
|
| 296 |
-
"""Raccourci : taux de préservation **stricte** des abréviations
|
| 297 |
-
Unicode (forme abrégée gardée telle quelle)."""
|
| 298 |
-
return compute_abbreviation_metrics(reference, hypothesis)["strict_score"]
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
def abbreviation_expansion_score(
|
| 302 |
-
reference: Optional[str], hypothesis: Optional[str],
|
| 303 |
-
) -> float:
|
| 304 |
-
"""Raccourci : taux de préservation par expansion (forme abrégée
|
| 305 |
-
OU forme développée présente dans l'hyp)."""
|
| 306 |
-
return compute_abbreviation_metrics(reference, hypothesis)["expansion_score"]
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 310 |
-
# Enregistrement dans le registre typé (Sprint 34)
|
| 311 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
@register_metric(
|
| 315 |
-
name="abbreviation_strict_score",
|
| 316 |
-
input_types=(ArtifactType.TEXT, ArtifactType.TEXT),
|
| 317 |
-
description=(
|
| 318 |
-
"Taux d'abréviations médiévales (Unicode dédié + lettre + "
|
| 319 |
-
"tilde combinant) dont la forme abrégée est préservée telle "
|
| 320 |
-
"quelle dans l'OCR. Idéal pour les éditions diplomatiques."
|
| 321 |
-
),
|
| 322 |
-
higher_is_better=True,
|
| 323 |
-
tags={"text", "abbreviation", "philology", "medieval"},
|
| 324 |
-
)
|
| 325 |
-
def _registered_strict(reference: str, hypothesis: str) -> float:
|
| 326 |
-
return abbreviation_strict_score(reference, hypothesis)
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
@register_metric(
|
| 330 |
-
name="abbreviation_expansion_score",
|
| 331 |
-
input_types=(ArtifactType.TEXT, ArtifactType.TEXT),
|
| 332 |
-
description=(
|
| 333 |
-
"Taux d'abréviations dont SOIT la forme abrégée Unicode SOIT "
|
| 334 |
-
"la forme développée attendue (per, pro, et…) est présente "
|
| 335 |
-
"dans l'OCR. Score plus large que strict_score."
|
| 336 |
-
),
|
| 337 |
-
higher_is_better=True,
|
| 338 |
-
tags={"text", "abbreviation", "philology", "medieval"},
|
| 339 |
-
)
|
| 340 |
-
def _registered_expansion(reference: str, hypothesis: str) -> float:
|
| 341 |
-
return abbreviation_expansion_score(reference, hypothesis)
|
| 342 |
-
|
| 343 |
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
"
|
| 347 |
-
|
| 348 |
-
"abbreviation_strict_score",
|
| 349 |
-
"abbreviation_expansion_score",
|
| 350 |
-
]
|
|
|
|
| 1 |
+
"""Alias rétrocompat — module déplacé dans :mod:`picarones.extras.historical.abbreviations`.
|
| 2 |
|
| 3 |
+
Phase B du chantier de refonte en 3 cercles (architecture-cercles.md).
|
| 4 |
+
Ce module philologique est désormais en Cercle 3 (``extras/``). L'alias
|
| 5 |
+
ici permet aux imports historiques (``from picarones.core.abbreviations
|
| 6 |
+
import ...``) de continuer à fonctionner sans modification.
|
| 7 |
|
| 8 |
+
Voir :doc:`docs/architecture-cercles.md` et l'extra
|
| 9 |
+
``picarones[historical]`` du ``pyproject.toml``.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
"""
|
| 11 |
|
| 12 |
+
from picarones.extras.historical.abbreviations import * # noqa: F401, F403
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
+
import picarones.extras.historical.abbreviations as _module
|
| 15 |
+
__all__ = getattr(_module, "__all__", [
|
| 16 |
+
name for name in dir(_module) if not name.startswith("_")
|
| 17 |
+
])
|
|
|
|
|
|
|
|
|
|
@@ -1,342 +1,17 @@
|
|
| 1 |
-
"""
|
| 2 |
|
| 3 |
-
|
| 4 |
-
|
|
|
|
|
|
|
| 5 |
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
Les Sprints 56 (abréviations Capelli) et 57 (couverture MUFI) sont
|
| 9 |
-
orientés **médiéval scribal**. Mais Picarones doit aussi servir
|
| 10 |
-
les éditeurs d'**imprimés anciens** (XVIᵉ-XVIIIᵉ siècles), pour
|
| 11 |
-
qui les marqueurs caractéristiques ne sont pas scribaux mais
|
| 12 |
-
**typographiques** : ligatures composées (fi, fl, ff, ffi, ffl, ſt),
|
| 13 |
-
s long (ſ), i sans point (ı), esperluette (&), tildes nasaux
|
| 14 |
-
indiquant une abréviation (ã = an/am, õ = on/om).
|
| 15 |
-
|
| 16 |
-
Distinction avec MUFI/abbreviations
|
| 17 |
-
------------------------------------
|
| 18 |
-
- ``mufi.py`` (Sprint 57) : caractères médiévaux scribaux
|
| 19 |
-
(Capelli + lettres þ ð ƿ + PUA MUFI).
|
| 20 |
-
- ``abbreviations.py`` (Sprint 56) : signes d'abréviation latins
|
| 21 |
-
scribaux médiévaux (ꝑ ꝓ ⁊ + tildes scribaux).
|
| 22 |
-
- ``early_modern_typography.py`` (ce module) : marqueurs
|
| 23 |
-
**typographiques** de la composition imprimée ancienne.
|
| 24 |
-
|
| 25 |
-
Les ligatures fi et fl sont communes aux deux univers (médiéval et
|
| 26 |
-
imprimé ancien) ; le choix du module à utiliser dépend du **corpus**
|
| 27 |
-
et de l'angle d'analyse éditoriale, pas du caractère pris isolément.
|
| 28 |
-
|
| 29 |
-
Catégorisation
|
| 30 |
-
--------------
|
| 31 |
-
Les marqueurs sont classés en cinq catégories pour permettre un
|
| 32 |
-
breakdown éditorial :
|
| 33 |
-
|
| 34 |
-
1. ``ligatures`` : fi fl ff ffi ffl ſt
|
| 35 |
-
2. ``long_s`` : ſ
|
| 36 |
-
3. ``dotless_i`` : ı
|
| 37 |
-
4. ``ampersand`` : & (esperluette typographique)
|
| 38 |
-
5. ``nasal_tildes`` : ã õ ũ ñ ē ī (abréviation par tilde nasal)
|
| 39 |
-
|
| 40 |
-
``compute_early_modern_metrics`` retourne le taux de préservation
|
| 41 |
-
par catégorie + global.
|
| 42 |
"""
|
| 43 |
|
| 44 |
-
from
|
| 45 |
-
|
| 46 |
-
import logging
|
| 47 |
-
from difflib import SequenceMatcher
|
| 48 |
-
from typing import Optional
|
| 49 |
-
|
| 50 |
-
from picarones.core.metric_registry import register_metric
|
| 51 |
-
from picarones.core.modules import ArtifactType
|
| 52 |
-
|
| 53 |
-
logger = logging.getLogger(__name__)
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 57 |
-
# Marqueurs typographiques imprimé ancien
|
| 58 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 59 |
-
|
| 60 |
-
# Ligatures typographiques héritées de l'incunable (XVᵉ) et toujours
|
| 61 |
-
# courantes jusqu'au XVIIIᵉ avant la normalisation typographique.
|
| 62 |
-
LIGATURES: frozenset[str] = frozenset({
|
| 63 |
-
"ff", # U+FB00 ff
|
| 64 |
-
"fi", # U+FB01 fi
|
| 65 |
-
"fl", # U+FB02 fl
|
| 66 |
-
"ffi", # U+FB03 ffi
|
| 67 |
-
"ffl", # U+FB04 ffl
|
| 68 |
-
"ſt", # U+FB05 long s + t
|
| 69 |
-
"st", # U+FB06 st
|
| 70 |
-
})
|
| 71 |
-
|
| 72 |
-
# S long : Latin Extended-A. Caractéristique de la typographie
|
| 73 |
-
# antérieure à 1800.
|
| 74 |
-
LONG_S: frozenset[str] = frozenset({"ſ"}) # U+017F
|
| 75 |
-
|
| 76 |
-
# i sans point : utilisé en typographie ancienne, parfois confondu
|
| 77 |
-
# avec un l ou un 1 par les OCR modernes.
|
| 78 |
-
DOTLESS_I: frozenset[str] = frozenset({"ı"}) # U+0131
|
| 79 |
-
|
| 80 |
-
# Esperluette typographique : "&" remplace fréquemment "et" dans
|
| 81 |
-
# les imprimés ; sa préservation discrimine un OCR diplomatique
|
| 82 |
-
# d'un OCR modernisant.
|
| 83 |
-
AMPERSAND: frozenset[str] = frozenset({"&"})
|
| 84 |
-
|
| 85 |
-
# Tildes nasaux : pré-composés (ñ ã ẽ ĩ õ ũ) ou séquences
|
| 86 |
-
# lettre + U+0303 combinant. En imprimé ancien, ã = an/am abrégé,
|
| 87 |
-
# õ = on/om, etc. Distinction avec les tildes scribaux médiévaux
|
| 88 |
-
# (Sprint 56) : ici on cible les **pré-composés** ou séquences sur
|
| 89 |
-
# des voyelles (le scribal médiéval cible plutôt p̃ q̃).
|
| 90 |
-
NASAL_TILDE_PRECOMPOSED: frozenset[str] = frozenset({
|
| 91 |
-
"ã", "Ã", # U+00E3 / U+00C3
|
| 92 |
-
"ñ", "Ñ", # U+00F1 / U+00D1
|
| 93 |
-
"õ", "Õ", # U+00F5 / U+00D5
|
| 94 |
-
"ũ", "Ũ", # U+0169 / U+0168
|
| 95 |
-
"ẽ", "Ẽ", # U+1EBD / U+1EBC
|
| 96 |
-
"ĩ", "Ĩ", # U+0129 / U+0128
|
| 97 |
-
})
|
| 98 |
-
|
| 99 |
-
# Voyelles susceptibles de porter un tilde combinant pour former
|
| 100 |
-
# un tilde nasal (couvre les écritures NFD non pré-composées).
|
| 101 |
-
_NASAL_TILDE_VOWELS: frozenset[str] = frozenset(
|
| 102 |
-
"aeiouAEIOU"
|
| 103 |
-
)
|
| 104 |
-
_COMBINING_TILDE = "̃"
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
# Catégorisation : nom → set de caractères pré-composés ou séquences.
|
| 108 |
-
_CATEGORIES: dict[str, frozenset[str]] = {
|
| 109 |
-
"ligatures": LIGATURES,
|
| 110 |
-
"long_s": LONG_S,
|
| 111 |
-
"dotless_i": DOTLESS_I,
|
| 112 |
-
"ampersand": AMPERSAND,
|
| 113 |
-
"nasal_tildes": NASAL_TILDE_PRECOMPOSED,
|
| 114 |
-
}
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
# ─────────────���────────────────────────────────────────────────────────────
|
| 118 |
-
# Détection des marqueurs dans la GT
|
| 119 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
def _detect_markers(text: str) -> list[tuple[int, str, str]]:
|
| 123 |
-
"""Retourne les positions des marqueurs typographiques dans
|
| 124 |
-
``text``.
|
| 125 |
-
|
| 126 |
-
Forme de sortie : ``[(index, marker, category), ...]`` dans
|
| 127 |
-
l'ordre d'apparition. Pour les tildes nasaux non
|
| 128 |
-
pré-composés, on détecte les séquences ``voyelle + U+0303`` et
|
| 129 |
-
on retourne l'index de la voyelle.
|
| 130 |
-
"""
|
| 131 |
-
if not text:
|
| 132 |
-
return []
|
| 133 |
-
found: list[tuple[int, str, str]] = []
|
| 134 |
-
i = 0
|
| 135 |
-
while i < len(text):
|
| 136 |
-
ch = text[i]
|
| 137 |
-
# Cas 1 : marqueur pré-composé dans une catégorie
|
| 138 |
-
category = _category_of_char(ch)
|
| 139 |
-
if category is not None:
|
| 140 |
-
found.append((i, ch, category))
|
| 141 |
-
i += 1
|
| 142 |
-
continue
|
| 143 |
-
# Cas 2 : voyelle + tilde combinant → nasal_tildes
|
| 144 |
-
if (
|
| 145 |
-
ch in _NASAL_TILDE_VOWELS
|
| 146 |
-
and i + 1 < len(text)
|
| 147 |
-
and text[i + 1] == _COMBINING_TILDE
|
| 148 |
-
):
|
| 149 |
-
seq = ch + _COMBINING_TILDE
|
| 150 |
-
found.append((i, seq, "nasal_tildes"))
|
| 151 |
-
i += 2
|
| 152 |
-
continue
|
| 153 |
-
i += 1
|
| 154 |
-
return found
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
def _category_of_char(ch: str) -> Optional[str]:
|
| 158 |
-
"""Retourne la catégorie d'un caractère typographique ou
|
| 159 |
-
``None`` s'il n'est pas reconnu."""
|
| 160 |
-
for cat, chars in _CATEGORIES.items():
|
| 161 |
-
if ch in chars:
|
| 162 |
-
return cat
|
| 163 |
-
return None
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 167 |
-
# Calcul de la préservation par catégorie
|
| 168 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
def compute_early_modern_metrics(
|
| 172 |
-
reference: Optional[str],
|
| 173 |
-
hypothesis: Optional[str],
|
| 174 |
-
) -> dict:
|
| 175 |
-
"""Mesure la préservation des marqueurs typographiques de
|
| 176 |
-
l'imprimé ancien dans l'OCR.
|
| 177 |
-
|
| 178 |
-
Stratégie d'alignement
|
| 179 |
-
----------------------
|
| 180 |
-
Pour chaque marqueur identifié dans la GT à la position ``i``,
|
| 181 |
-
on vérifie si l'OCR l'a préservé en utilisant l'alignement
|
| 182 |
-
caractère par caractère via ``difflib.SequenceMatcher`` (même
|
| 183 |
-
méthode que les Sprints 55/57) :
|
| 184 |
-
|
| 185 |
-
- Marqueur **mono-caractère** (fi, ſ, ı, &, ã…) : la position
|
| 186 |
-
``i`` est-elle dans un opcode ``equal`` ?
|
| 187 |
-
- Marqueur **bi-caractère** (voyelle + U+0303) : les positions
|
| 188 |
-
``i`` et ``i+1`` sont-elles toutes deux dans un opcode
|
| 189 |
-
``equal`` ?
|
| 190 |
-
|
| 191 |
-
Returns
|
| 192 |
-
-------
|
| 193 |
-
dict
|
| 194 |
-
``{
|
| 195 |
-
"n_markers_reference": int,
|
| 196 |
-
"n_markers_preserved": int,
|
| 197 |
-
"global_preservation": float, # ∈ [0, 1]
|
| 198 |
-
"per_category": {
|
| 199 |
-
category: {"total", "preserved", "preservation"}
|
| 200 |
-
},
|
| 201 |
-
"missed_markers": [{"index", "marker", "category"}, ...],
|
| 202 |
-
}``
|
| 203 |
-
|
| 204 |
-
Cas dégénérés : GT vide ou sans marqueur → tous compteurs à 0,
|
| 205 |
-
``global_preservation = 0``.
|
| 206 |
-
"""
|
| 207 |
-
ref = reference or ""
|
| 208 |
-
hyp = hypothesis or ""
|
| 209 |
-
|
| 210 |
-
# Forme NFD pour reconnaître les tildes nasaux décomposés (ã =
|
| 211 |
-
# 'a' + U+0303) côté GT — on conserve toutefois la forme passée
|
| 212 |
-
# pour les indices rapportés dans missed_markers.
|
| 213 |
-
markers = _detect_markers(ref)
|
| 214 |
-
n_total = len(markers)
|
| 215 |
-
|
| 216 |
-
if n_total == 0:
|
| 217 |
-
return {
|
| 218 |
-
"n_markers_reference": 0,
|
| 219 |
-
"n_markers_preserved": 0,
|
| 220 |
-
"global_preservation": 0.0,
|
| 221 |
-
"per_category": {},
|
| 222 |
-
"missed_markers": [],
|
| 223 |
-
}
|
| 224 |
-
|
| 225 |
-
# Aligner GT/hyp et récupérer le set des positions GT couvertes
|
| 226 |
-
# par un opcode "equal".
|
| 227 |
-
matcher = SequenceMatcher(a=ref, b=hyp, autojunk=False)
|
| 228 |
-
correct_positions: set[int] = set()
|
| 229 |
-
for op, i1, i2, _j1, _j2 in matcher.get_opcodes():
|
| 230 |
-
if op == "equal":
|
| 231 |
-
correct_positions.update(range(i1, i2))
|
| 232 |
-
|
| 233 |
-
per_cat_total: dict[str, int] = {}
|
| 234 |
-
per_cat_preserved: dict[str, int] = {}
|
| 235 |
-
n_preserved = 0
|
| 236 |
-
missed: list[dict] = []
|
| 237 |
-
|
| 238 |
-
for index, marker, category in markers:
|
| 239 |
-
per_cat_total[category] = per_cat_total.get(category, 0) + 1
|
| 240 |
-
# Marqueur préservé si toutes ses positions GT sont dans
|
| 241 |
-
# un opcode "equal".
|
| 242 |
-
marker_len = len(marker)
|
| 243 |
-
positions_ok = all(
|
| 244 |
-
(index + k) in correct_positions for k in range(marker_len)
|
| 245 |
-
)
|
| 246 |
-
if positions_ok:
|
| 247 |
-
per_cat_preserved[category] = (
|
| 248 |
-
per_cat_preserved.get(category, 0) + 1
|
| 249 |
-
)
|
| 250 |
-
n_preserved += 1
|
| 251 |
-
else:
|
| 252 |
-
missed.append({
|
| 253 |
-
"index": index,
|
| 254 |
-
"marker": marker,
|
| 255 |
-
"category": category,
|
| 256 |
-
})
|
| 257 |
-
|
| 258 |
-
per_category = {
|
| 259 |
-
cat: {
|
| 260 |
-
"total": per_cat_total[cat],
|
| 261 |
-
"preserved": per_cat_preserved.get(cat, 0),
|
| 262 |
-
"preservation": (
|
| 263 |
-
per_cat_preserved.get(cat, 0) / per_cat_total[cat]
|
| 264 |
-
if per_cat_total[cat] > 0
|
| 265 |
-
else 0.0
|
| 266 |
-
),
|
| 267 |
-
}
|
| 268 |
-
for cat in sorted(per_cat_total)
|
| 269 |
-
}
|
| 270 |
-
|
| 271 |
-
return {
|
| 272 |
-
"n_markers_reference": n_total,
|
| 273 |
-
"n_markers_preserved": n_preserved,
|
| 274 |
-
"global_preservation": n_preserved / n_total,
|
| 275 |
-
"per_category": per_category,
|
| 276 |
-
"missed_markers": missed,
|
| 277 |
-
}
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
def early_modern_preservation(
|
| 281 |
-
reference: Optional[str], hypothesis: Optional[str],
|
| 282 |
-
) -> float:
|
| 283 |
-
"""Raccourci : taux global de préservation des marqueurs
|
| 284 |
-
typographiques de l'imprimé ancien."""
|
| 285 |
-
return compute_early_modern_metrics(
|
| 286 |
-
reference, hypothesis,
|
| 287 |
-
)["global_preservation"]
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 291 |
-
# Helpers exposés
|
| 292 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
def detect_markers(text: Optional[str]) -> list[tuple[int, str, str]]:
|
| 296 |
-
"""Wrapper public sur ``_detect_markers`` (acceptant ``None``)."""
|
| 297 |
-
return _detect_markers(text or "")
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
def get_category(char: str) -> Optional[str]:
|
| 301 |
-
"""Retourne la catégorie typographique d'un caractère
|
| 302 |
-
(``ligatures``, ``long_s``, ``dotless_i``, ``ampersand``,
|
| 303 |
-
``nasal_tildes``) ou ``None``.
|
| 304 |
-
|
| 305 |
-
Pour un tilde combinant suivi d'une voyelle, l'utilisateur doit
|
| 306 |
-
utiliser ``detect_markers`` qui gère les séquences.
|
| 307 |
-
"""
|
| 308 |
-
return _category_of_char(char[0]) if char else None
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 312 |
-
# Enregistrement dans le registre typé (Sprint 34)
|
| 313 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
@register_metric(
|
| 317 |
-
name="early_modern_preservation",
|
| 318 |
-
input_types=(ArtifactType.TEXT, ArtifactType.TEXT),
|
| 319 |
-
description=(
|
| 320 |
-
"Taux de préservation des marqueurs typographiques de "
|
| 321 |
-
"l'imprimé ancien (XVIᵉ-XVIIIᵉ) : ligatures fi fl ff, s long ſ, "
|
| 322 |
-
"i sans point ı, esperluette &, tildes nasaux ã õ. Critère "
|
| 323 |
-
"éditorial pour les éditions diplomatiques d'imprimés anciens."
|
| 324 |
-
),
|
| 325 |
-
higher_is_better=True,
|
| 326 |
-
tags={"text", "typography", "early_modern", "philology"},
|
| 327 |
-
)
|
| 328 |
-
def _registered_early_modern(reference: str, hypothesis: str) -> float:
|
| 329 |
-
return early_modern_preservation(reference, hypothesis)
|
| 330 |
-
|
| 331 |
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
"
|
| 335 |
-
|
| 336 |
-
"AMPERSAND",
|
| 337 |
-
"NASAL_TILDE_PRECOMPOSED",
|
| 338 |
-
"detect_markers",
|
| 339 |
-
"get_category",
|
| 340 |
-
"compute_early_modern_metrics",
|
| 341 |
-
"early_modern_preservation",
|
| 342 |
-
]
|
|
|
|
| 1 |
+
"""Alias rétrocompat — module déplacé dans :mod:`picarones.extras.historical.early_modern_typography`.
|
| 2 |
|
| 3 |
+
Phase B du chantier de refonte en 3 cercles (architecture-cercles.md).
|
| 4 |
+
Ce module philologique est désormais en Cercle 3 (``extras/``). L'alias
|
| 5 |
+
ici permet aux imports historiques (``from picarones.core.early_modern_typography
|
| 6 |
+
import ...``) de continuer à fonctionner sans modification.
|
| 7 |
|
| 8 |
+
Voir :doc:`docs/architecture-cercles.md` et l'extra
|
| 9 |
+
``picarones[historical]`` du ``pyproject.toml``.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
"""
|
| 11 |
|
| 12 |
+
from picarones.extras.historical.early_modern_typography import * # noqa: F401, F403
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
+
import picarones.extras.historical.early_modern_typography as _module
|
| 15 |
+
__all__ = getattr(_module, "__all__", [
|
| 16 |
+
name for name in dir(_module) if not name.startswith("_")
|
| 17 |
+
])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -1,263 +1,17 @@
|
|
| 1 |
-
"""
|
| 2 |
-
Sprint 80 (A.I.7).
|
| 3 |
|
| 4 |
-
|
|
|
|
|
|
|
|
|
|
| 5 |
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
Le détecteur ``llm_hallucination_flag`` (Sprint 19) signale qu'un
|
| 9 |
-
moteur sur-normalise (« 0,05 % »). Mais ce score agrégé ne dit
|
| 10 |
-
rien sur **quoi** corriger dans le prompt. Ce module produit
|
| 11 |
-
une **table de fréquences détaillée** :
|
| 12 |
-
|
| 13 |
-
+----------------------+--------------------+------+----------+
|
| 14 |
-
| Forme historique GT | Forme modernisée | n GT | % modern |
|
| 15 |
-
+======================+====================+======+==========+
|
| 16 |
-
| maistre | maître | 47 | 85 % |
|
| 17 |
-
| nostre | nostre | 92 | 8 % |
|
| 18 |
-
| veoir | voir | 23 | 100 % |
|
| 19 |
-
+----------------------+--------------------+------+----------+
|
| 20 |
-
|
| 21 |
-
Lecture immédiate : *« le LLM modernise systématiquement
|
| 22 |
-
maistre → maître ; pour préserver l'orthographe historique, ajouter
|
| 23 |
-
au prompt "ne pas moderniser maistre, nostre, veoir" »*.
|
| 24 |
-
|
| 25 |
-
Méthode
|
| 26 |
-
-------
|
| 27 |
-
Alignement mot-à-mot via ``difflib.SequenceMatcher``. Chaque
|
| 28 |
-
``replace`` ou ``equal`` produit une paire ``(gt_token,
|
| 29 |
-
hyp_token)``. On accumule pour chaque ``gt_token`` :
|
| 30 |
-
|
| 31 |
-
- ``n_total`` : nombre d'occurrences du token dans la GT
|
| 32 |
-
- ``n_modernized`` : nombre d'occurrences où ``hyp_token != gt_token``
|
| 33 |
-
- ``variants`` : dict des hyp_tokens observés avec leur count
|
| 34 |
-
|
| 35 |
-
Stop-list
|
| 36 |
-
---------
|
| 37 |
-
L'utilisateur peut passer ``stop_list`` (ensemble de tokens GT à
|
| 38 |
-
ignorer). Par défaut, vide — le module ne tente pas de deviner ce
|
| 39 |
-
qui est « moderne » ou « historique », c'est au chercheur de
|
| 40 |
-
fournir le filtre adapté à son corpus.
|
| 41 |
-
|
| 42 |
-
Sortie
|
| 43 |
-
------
|
| 44 |
-
``compute_lexical_modernization`` retourne une structure adaptée
|
| 45 |
-
au rendu HTML. ``aggregate_lexical_modernization`` agrège
|
| 46 |
-
plusieurs documents.
|
| 47 |
-
|
| 48 |
-
Limites documentées
|
| 49 |
-
-------------------
|
| 50 |
-
- Tokenisation au niveau mot (split sur espace) — cohérent avec
|
| 51 |
-
``taxonomy.py`` et autres modules. Pas de stemming ni de
|
| 52 |
-
lemmatisation.
|
| 53 |
-
- La métrique mesure la **réécriture lexicale** ; elle n'attrape
|
| 54 |
-
pas les modernisations infra-mot (perte du s long ſ qui se
|
| 55 |
-
fond dans la même forme). Pour ça, voir ``early_modern_typography``
|
| 56 |
-
(Sprint 58) et ``equivalence_profile`` (Sprint 78).
|
| 57 |
"""
|
| 58 |
|
| 59 |
-
from
|
| 60 |
-
|
| 61 |
-
import difflib
|
| 62 |
-
import logging
|
| 63 |
-
from typing import Iterable, Optional
|
| 64 |
-
|
| 65 |
-
logger = logging.getLogger(__name__)
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
def _split_words(text: Optional[str]) -> list[str]:
|
| 69 |
-
"""Tokenisation simple par split sur whitespace."""
|
| 70 |
-
if not text:
|
| 71 |
-
return []
|
| 72 |
-
return text.split()
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
def compute_lexical_modernization(
|
| 76 |
-
reference: Optional[str],
|
| 77 |
-
hypothesis: Optional[str],
|
| 78 |
-
*,
|
| 79 |
-
stop_list: Optional[Iterable[str]] = None,
|
| 80 |
-
case_sensitive: bool = False,
|
| 81 |
-
) -> dict:
|
| 82 |
-
"""Calcule le tableau de modernisation lexicale pour un document.
|
| 83 |
-
|
| 84 |
-
Returns
|
| 85 |
-
-------
|
| 86 |
-
dict
|
| 87 |
-
``{
|
| 88 |
-
"n_gt_tokens": int,
|
| 89 |
-
"tokens": {
|
| 90 |
-
gt_token: {
|
| 91 |
-
"n_total": int,
|
| 92 |
-
"n_modernized": int,
|
| 93 |
-
"rate_modernized": float, # ∈ [0, 1]
|
| 94 |
-
"variants": {hyp_token: count, ...},
|
| 95 |
-
},
|
| 96 |
-
...
|
| 97 |
-
},
|
| 98 |
-
}``
|
| 99 |
-
Si ``reference`` est vide → ``tokens == {}``.
|
| 100 |
-
"""
|
| 101 |
-
ref_tokens = _split_words(reference)
|
| 102 |
-
hyp_tokens = _split_words(hypothesis)
|
| 103 |
-
if not ref_tokens:
|
| 104 |
-
return {"n_gt_tokens": 0, "tokens": {}}
|
| 105 |
-
|
| 106 |
-
if not case_sensitive:
|
| 107 |
-
ref_for_match = [t.lower() for t in ref_tokens]
|
| 108 |
-
hyp_for_match = [t.lower() for t in hyp_tokens]
|
| 109 |
-
else:
|
| 110 |
-
ref_for_match = ref_tokens
|
| 111 |
-
hyp_for_match = hyp_tokens
|
| 112 |
-
|
| 113 |
-
stop = frozenset(
|
| 114 |
-
(t.lower() if not case_sensitive else t)
|
| 115 |
-
for t in (stop_list or [])
|
| 116 |
-
)
|
| 117 |
-
|
| 118 |
-
# On accumule par gt_token (forme display = forme originale,
|
| 119 |
-
# match key = forme casée selon ``case_sensitive``).
|
| 120 |
-
tokens_data: dict[str, dict] = {}
|
| 121 |
-
|
| 122 |
-
matcher = difflib.SequenceMatcher(
|
| 123 |
-
None, ref_for_match, hyp_for_match, autojunk=False,
|
| 124 |
-
)
|
| 125 |
-
for tag, i1, i2, j1, j2 in matcher.get_opcodes():
|
| 126 |
-
if tag == "equal":
|
| 127 |
-
for k in range(i2 - i1):
|
| 128 |
-
gt_orig = ref_tokens[i1 + k]
|
| 129 |
-
gt_match = ref_for_match[i1 + k]
|
| 130 |
-
if gt_match in stop:
|
| 131 |
-
continue
|
| 132 |
-
slot = tokens_data.setdefault(
|
| 133 |
-
gt_orig,
|
| 134 |
-
{"n_total": 0, "n_modernized": 0, "variants": {}},
|
| 135 |
-
)
|
| 136 |
-
slot["n_total"] += 1
|
| 137 |
-
elif tag == "replace":
|
| 138 |
-
# Apparier 1-à-1 quand possible
|
| 139 |
-
paired = min(i2 - i1, j2 - j1)
|
| 140 |
-
for k in range(paired):
|
| 141 |
-
gt_orig = ref_tokens[i1 + k]
|
| 142 |
-
gt_match = ref_for_match[i1 + k]
|
| 143 |
-
if gt_match in stop:
|
| 144 |
-
continue
|
| 145 |
-
hyp_orig = hyp_tokens[j1 + k]
|
| 146 |
-
slot = tokens_data.setdefault(
|
| 147 |
-
gt_orig,
|
| 148 |
-
{"n_total": 0, "n_modernized": 0, "variants": {}},
|
| 149 |
-
)
|
| 150 |
-
slot["n_total"] += 1
|
| 151 |
-
slot["n_modernized"] += 1
|
| 152 |
-
slot["variants"][hyp_orig] = slot["variants"].get(hyp_orig, 0) + 1
|
| 153 |
-
# Si plus de gt que de hyp, le reste des gt_tokens est
|
| 154 |
-
# « perdu » — on les compte comme totaux mais pas comme
|
| 155 |
-
# modernisés (on ne sait pas en quoi).
|
| 156 |
-
for k in range(paired, i2 - i1):
|
| 157 |
-
gt_orig = ref_tokens[i1 + k]
|
| 158 |
-
gt_match = ref_for_match[i1 + k]
|
| 159 |
-
if gt_match in stop:
|
| 160 |
-
continue
|
| 161 |
-
slot = tokens_data.setdefault(
|
| 162 |
-
gt_orig,
|
| 163 |
-
{"n_total": 0, "n_modernized": 0, "variants": {}},
|
| 164 |
-
)
|
| 165 |
-
slot["n_total"] += 1
|
| 166 |
-
slot["n_modernized"] += 1
|
| 167 |
-
slot["variants"]["∅"] = slot["variants"].get("∅", 0) + 1
|
| 168 |
-
elif tag == "delete":
|
| 169 |
-
# gt présent, pas en hyp → modernisation par
|
| 170 |
-
# suppression (ou perte pure)
|
| 171 |
-
for k in range(i2 - i1):
|
| 172 |
-
gt_orig = ref_tokens[i1 + k]
|
| 173 |
-
gt_match = ref_for_match[i1 + k]
|
| 174 |
-
if gt_match in stop:
|
| 175 |
-
continue
|
| 176 |
-
slot = tokens_data.setdefault(
|
| 177 |
-
gt_orig,
|
| 178 |
-
{"n_total": 0, "n_modernized": 0, "variants": {}},
|
| 179 |
-
)
|
| 180 |
-
slot["n_total"] += 1
|
| 181 |
-
slot["n_modernized"] += 1
|
| 182 |
-
slot["variants"]["∅"] = slot["variants"].get("∅", 0) + 1
|
| 183 |
-
|
| 184 |
-
# Calcul du taux par token
|
| 185 |
-
for slot in tokens_data.values():
|
| 186 |
-
total = slot["n_total"]
|
| 187 |
-
slot["rate_modernized"] = (
|
| 188 |
-
slot["n_modernized"] / total if total > 0 else 0.0
|
| 189 |
-
)
|
| 190 |
-
|
| 191 |
-
return {
|
| 192 |
-
"n_gt_tokens": len(ref_tokens),
|
| 193 |
-
"tokens": tokens_data,
|
| 194 |
-
}
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
def aggregate_lexical_modernization(
|
| 198 |
-
per_doc_results: Iterable[dict],
|
| 199 |
-
) -> dict:
|
| 200 |
-
"""Agrège des ``compute_lexical_modernization`` per-doc.
|
| 201 |
-
|
| 202 |
-
Renvoie la structure agrégée corpus-wide avec la même forme
|
| 203 |
-
que ``compute_lexical_modernization``.
|
| 204 |
-
"""
|
| 205 |
-
agg_tokens: dict[str, dict] = {}
|
| 206 |
-
n_gt_total = 0
|
| 207 |
-
for doc_result in per_doc_results:
|
| 208 |
-
if not doc_result:
|
| 209 |
-
continue
|
| 210 |
-
n_gt_total += doc_result.get("n_gt_tokens", 0)
|
| 211 |
-
for gt, data in (doc_result.get("tokens") or {}).items():
|
| 212 |
-
slot = agg_tokens.setdefault(
|
| 213 |
-
gt, {"n_total": 0, "n_modernized": 0, "variants": {}},
|
| 214 |
-
)
|
| 215 |
-
slot["n_total"] += data.get("n_total", 0)
|
| 216 |
-
slot["n_modernized"] += data.get("n_modernized", 0)
|
| 217 |
-
for hyp_t, count in (data.get("variants") or {}).items():
|
| 218 |
-
slot["variants"][hyp_t] = slot["variants"].get(hyp_t, 0) + count
|
| 219 |
-
|
| 220 |
-
for slot in agg_tokens.values():
|
| 221 |
-
total = slot["n_total"]
|
| 222 |
-
slot["rate_modernized"] = (
|
| 223 |
-
slot["n_modernized"] / total if total > 0 else 0.0
|
| 224 |
-
)
|
| 225 |
-
return {
|
| 226 |
-
"n_gt_tokens": n_gt_total,
|
| 227 |
-
"tokens": agg_tokens,
|
| 228 |
-
}
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
def top_modernized_tokens(
|
| 232 |
-
data: dict,
|
| 233 |
-
*,
|
| 234 |
-
n: int = 20,
|
| 235 |
-
min_total: int = 1,
|
| 236 |
-
) -> list[tuple[str, dict]]:
|
| 237 |
-
"""Top-N tokens GT par taux de modernisation.
|
| 238 |
-
|
| 239 |
-
Filtre les tokens dont ``n_total < min_total`` (anecdotiques).
|
| 240 |
-
Tri par ``rate_modernized`` décroissant, tie-break par
|
| 241 |
-
``n_total`` décroissant.
|
| 242 |
-
"""
|
| 243 |
-
tokens = data.get("tokens") or {}
|
| 244 |
-
candidates = [
|
| 245 |
-
(gt, slot) for gt, slot in tokens.items()
|
| 246 |
-
if slot.get("n_total", 0) >= min_total
|
| 247 |
-
and slot.get("n_modernized", 0) > 0
|
| 248 |
-
]
|
| 249 |
-
candidates.sort(
|
| 250 |
-
key=lambda pair: (
|
| 251 |
-
-pair[1].get("rate_modernized", 0.0),
|
| 252 |
-
-pair[1].get("n_total", 0),
|
| 253 |
-
pair[0],
|
| 254 |
-
),
|
| 255 |
-
)
|
| 256 |
-
return candidates[:n]
|
| 257 |
-
|
| 258 |
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
"
|
| 262 |
-
|
| 263 |
-
]
|
|
|
|
| 1 |
+
"""Alias rétrocompat — module déplacé dans :mod:`picarones.extras.historical.lexical_modernization`.
|
|
|
|
| 2 |
|
| 3 |
+
Phase B du chantier de refonte en 3 cercles (architecture-cercles.md).
|
| 4 |
+
Ce module philologique est désormais en Cercle 3 (``extras/``). L'alias
|
| 5 |
+
ici permet aux imports historiques (``from picarones.core.lexical_modernization
|
| 6 |
+
import ...``) de continuer à fonctionner sans modification.
|
| 7 |
|
| 8 |
+
Voir :doc:`docs/architecture-cercles.md` et l'extra
|
| 9 |
+
``picarones[historical]`` du ``pyproject.toml``.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
"""
|
| 11 |
|
| 12 |
+
from picarones.extras.historical.lexical_modernization import * # noqa: F401, F403
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
+
import picarones.extras.historical.lexical_modernization as _module
|
| 15 |
+
__all__ = getattr(_module, "__all__", [
|
| 16 |
+
name for name in dir(_module) if not name.startswith("_")
|
| 17 |
+
])
|
|
|
|
@@ -1,600 +1,17 @@
|
|
| 1 |
-
"""
|
| 2 |
-
(XIXᵉ-XXᵉ siècles) — Sprint 59.
|
| 3 |
|
| 4 |
-
|
| 5 |
-
|
|
|
|
|
|
|
| 6 |
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
Les Sprints 56-57 sont orientés **médiéval scribal** (Capelli, MUFI),
|
| 10 |
-
le Sprint 58 cible l'**imprimé ancien** XVIᵉ-XVIIIᵉ. Ce sprint étend
|
| 11 |
-
la couverture aux **archives modernes** (XIXᵉ-XXᵉ), période où la
|
| 12 |
-
typographie historique a disparu mais où subsistent des conventions
|
| 13 |
-
d'abréviation propres aux corpus institutionnels (état civil,
|
| 14 |
-
recensements, presse, monographies, archives militaires).
|
| 15 |
-
|
| 16 |
-
Distinction avec les modules précédents
|
| 17 |
-
---------------------------------------
|
| 18 |
-
- ``mufi.py`` (Sprint 57) : caractères médiévaux scribaux.
|
| 19 |
-
- ``abbreviations.py`` (Sprint 56) : signes scribaux médiévaux.
|
| 20 |
-
- ``early_modern_typography.py`` (Sprint 58) : marqueurs
|
| 21 |
-
typographiques imprimé ancien (fi ſ ı &…).
|
| 22 |
-
- ``modern_archives.py`` (ce module) : abréviations et conventions
|
| 23 |
-
de l'archive moderne XIXᵉ-XXᵉ.
|
| 24 |
-
|
| 25 |
-
Catégories
|
| 26 |
-
----------
|
| 27 |
-
1. ``civility_titles`` : Mme, M., Mlle, Mgr, Dr, Pr, Me, R.P., S.M.,
|
| 28 |
-
S.A.R., S.E., S.S.
|
| 29 |
-
2. ``ordinals`` : 1ᵉʳ, 1ʳᵉ, 2ᵉ, 2ᵈ, Vᵉ (avec exposants Unicode)
|
| 30 |
-
3. ``currency`` : ₶ (livre tournois), ₣ ƒ (franc), £, l. s. d.
|
| 31 |
-
(livre/sol/denier d'Ancien Régime)
|
| 32 |
-
4. ``administrative`` : arr., dép., cant., com., reg., prov.
|
| 33 |
-
5. ``civil_status`` : °, †, ✶, ⚭, ép., vve
|
| 34 |
-
6. ``typographic_punctuation`` : « », –, —, …, ’
|
| 35 |
-
7. ``latin_abbr_modern`` : e.g., i.e., etc., cf., ibid., op. cit.,
|
| 36 |
-
ad lib.
|
| 37 |
-
8. ``bibliographic`` : vol., t., p., pp., n°, fasc., éd., ms.,
|
| 38 |
-
r°, v°
|
| 39 |
-
9. ``address`` : bd, av., r., pl., imp., fbg
|
| 40 |
-
|
| 41 |
-
Sortie
|
| 42 |
-
------
|
| 43 |
-
``compute_modern_archives_metrics(ref, hyp)`` retourne deux scores
|
| 44 |
-
par catégorie (pattern Sprint 56) :
|
| 45 |
-
|
| 46 |
-
- ``strict_score`` : forme abrégée préservée telle quelle ;
|
| 47 |
-
- ``expansion_score`` : forme abrégée OU forme développée présente.
|
| 48 |
-
|
| 49 |
-
Le **ratio strict/expansion** par catégorie permet au chercheur de
|
| 50 |
-
juger lui-même la convention adoptée par chaque moteur, sans
|
| 51 |
-
classification automatique imposée par le module.
|
| 52 |
-
|
| 53 |
-
Stratégie de découpage
|
| 54 |
-
----------------------
|
| 55 |
-
Cohérente avec NER (38), Flesch (52), Reading order F1 (53),
|
| 56 |
-
Layout F1 (54), Bloc Unicode (55), Abréviations (56), MUFI (57),
|
| 57 |
-
Imprimé ancien (58) : couche de calcul pure d'abord ; câblage
|
| 58 |
-
runner et HTML dans des sprints dédiés.
|
| 59 |
"""
|
| 60 |
|
| 61 |
-
from
|
| 62 |
-
|
| 63 |
-
import logging
|
| 64 |
-
import re
|
| 65 |
-
from typing import Optional
|
| 66 |
-
|
| 67 |
-
from picarones.core.metric_registry import register_metric
|
| 68 |
-
from picarones.core.modules import ArtifactType
|
| 69 |
-
|
| 70 |
-
logger = logging.getLogger(__name__)
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 74 |
-
# Tables d'abréviations par catégorie
|
| 75 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 76 |
-
#
|
| 77 |
-
# Format : tuple ``(marker, expansions, regex_strict_pattern_or_None)``
|
| 78 |
-
# où :
|
| 79 |
-
# - ``marker`` : forme abrégée canonique (str)
|
| 80 |
-
# - ``expansions`` : tuple de formes développées
|
| 81 |
-
# acceptées (insensible à la casse)
|
| 82 |
-
# - ``regex_strict_pattern`` : pattern Python regex pour la
|
| 83 |
-
# détection dans la GT. ``None``
|
| 84 |
-
# = on dérive automatiquement
|
| 85 |
-
# ``\b<marker_escaped>\b`` (avec
|
| 86 |
-
# garde-fou sur les abréviations
|
| 87 |
-
# contenant un point).
|
| 88 |
-
#
|
| 89 |
-
# Détection : pour les abréviations contenant un ``.`` (« M. »),
|
| 90 |
-
# on n'utilise pas ``\b`` standard car « M.\b » match dans
|
| 91 |
-
# « M.A. » (le ``.`` étant non-mot, ``\b`` est satisfait). On
|
| 92 |
-
# exige donc explicitement une frontière espace/début/fin/
|
| 93 |
-
# ponctuation après le point.
|
| 94 |
-
|
| 95 |
-
CIVILITY_TITLES: tuple[tuple[str, tuple[str, ...]], ...] = (
|
| 96 |
-
("Mme", ("Madame",)),
|
| 97 |
-
("Mlle", ("Mademoiselle",)),
|
| 98 |
-
("Mgr", ("Monseigneur",)),
|
| 99 |
-
("Dr", ("Docteur",)),
|
| 100 |
-
("Pr", ("Professeur",)),
|
| 101 |
-
("Me", ("Maître",)),
|
| 102 |
-
("M.", ("Monsieur",)),
|
| 103 |
-
("R.P.", ("Révérend Père",)),
|
| 104 |
-
("S.M.", ("Sa Majesté",)),
|
| 105 |
-
("S.A.R.", ("Son Altesse Royale",)),
|
| 106 |
-
("S.E.", ("Son Excellence",)),
|
| 107 |
-
("S.S.", ("Sa Sainteté",)),
|
| 108 |
-
)
|
| 109 |
-
|
| 110 |
-
# Ordinaux : la forme **strict** porte l'exposant Unicode
|
| 111 |
-
# (1ᵉʳ U+1D49 U+02B3, 1ʳᵉ, 2ᵈ, 2ᵉ, 3ᵉ…) ; la forme **expansion**
|
| 112 |
-
# accepte la version plate (« 1er », « 1re », « 2nd ») ou la forme
|
| 113 |
-
# textuelle (« premier », « première »).
|
| 114 |
-
#
|
| 115 |
-
# On définit chaque ordinal explicitement (1-12 + Vᵉ pour les
|
| 116 |
-
# numéraux romains de siècle). Au-delà, l'exposant ᵉ seul couvre
|
| 117 |
-
# les usages courants (3ᵉ, 4ᵉ, 5ᵉ, 6ᵉ, 7ᵉ, 8ᵉ, 9ᵉ, 10ᵉ).
|
| 118 |
-
|
| 119 |
-
ORDINALS: tuple[tuple[str, tuple[str, ...]], ...] = (
|
| 120 |
-
("1ᵉʳ", ("1er", "premier")),
|
| 121 |
-
("1ʳᵉ", ("1re", "première", "premiere")),
|
| 122 |
-
("2ᵈ", ("2d", "second")),
|
| 123 |
-
("2ᵈᵉ", ("2de", "seconde")),
|
| 124 |
-
("2ᵉ", ("2e", "deuxième", "deuxieme")),
|
| 125 |
-
("3ᵉ", ("3e", "troisième", "troisieme")),
|
| 126 |
-
("Iᵉʳ", ("Ier", "premier")),
|
| 127 |
-
("Vᵉ", ("Ve", "cinquième", "cinquieme")),
|
| 128 |
-
("XIᵉ", ("XIe", "onzième", "onzieme")),
|
| 129 |
-
("XIIᵉ", ("XIIe", "douzième", "douzieme")),
|
| 130 |
-
("XVIᵉ", ("XVIe", "seizième", "seizieme")),
|
| 131 |
-
("XVIIᵉ", ("XVIIe", "dix-septième", "dix-septieme")),
|
| 132 |
-
("XVIIIᵉ", ("XVIIIe", "dix-huitième", "dix-huitieme")),
|
| 133 |
-
("XIXᵉ", ("XIXe", "dix-neuvième", "dix-neuvieme")),
|
| 134 |
-
("XXᵉ", ("XXe", "vingtième", "vingtieme")),
|
| 135 |
-
)
|
| 136 |
-
|
| 137 |
-
CURRENCY: tuple[tuple[str, tuple[str, ...]], ...] = (
|
| 138 |
-
("₶", ("livre tournois", "livres tournois")),
|
| 139 |
-
("₣", ("franc", "francs")),
|
| 140 |
-
("ƒ", ("florin", "florins")),
|
| 141 |
-
("£", ("livre", "livres", "pound", "pounds")),
|
| 142 |
-
("l.", ("livre", "livres")),
|
| 143 |
-
("s.", ("sol", "sols", "sou", "sous")),
|
| 144 |
-
("d.", ("denier", "deniers")),
|
| 145 |
-
)
|
| 146 |
-
|
| 147 |
-
ADMINISTRATIVE: tuple[tuple[str, tuple[str, ...]], ...] = (
|
| 148 |
-
("arr.", ("arrondissement",)),
|
| 149 |
-
("dép.", ("département", "departement")),
|
| 150 |
-
("cant.", ("canton",)),
|
| 151 |
-
("com.", ("commune",)),
|
| 152 |
-
("reg.", ("régiment", "regiment")),
|
| 153 |
-
("prov.", ("province",)),
|
| 154 |
-
)
|
| 155 |
-
|
| 156 |
-
# État civil : signes typographiques (° = né, † = mort, ⚭ = marié)
|
| 157 |
-
# et abréviations textuelles (ép. = épouse/époux, vve = veuve).
|
| 158 |
-
CIVIL_STATUS: tuple[tuple[str, tuple[str, ...]], ...] = (
|
| 159 |
-
("°", ("né", "née")),
|
| 160 |
-
("†", ("mort", "morte", "décédé", "décédée")),
|
| 161 |
-
("✶", ("naissance",)),
|
| 162 |
-
("⚭", ("marié", "mariée", "épousa", "epousa")),
|
| 163 |
-
("ép.", ("épouse", "époux", "epouse", "epoux")),
|
| 164 |
-
("vve", ("veuve",)),
|
| 165 |
-
)
|
| 166 |
-
|
| 167 |
-
# Ponctuation typographique : ces marqueurs sont préservés en
|
| 168 |
-
# diplomatique et remplacés par leur équivalent ASCII en
|
| 169 |
-
# modernisant. L'expansion n'est pas une « expansion » au sens
|
| 170 |
-
# linguistique mais un substitut typographique.
|
| 171 |
-
TYPOGRAPHIC_PUNCTUATION: tuple[tuple[str, tuple[str, ...]], ...] = (
|
| 172 |
-
("«", ('"',)),
|
| 173 |
-
("»", ('"',)),
|
| 174 |
-
("—", ("-", "--")),
|
| 175 |
-
("–", ("-",)),
|
| 176 |
-
("…", ("...",)),
|
| 177 |
-
("’", ("'",)),
|
| 178 |
-
("‘", ("'",)),
|
| 179 |
-
)
|
| 180 |
-
|
| 181 |
-
LATIN_ABBR_MODERN: tuple[tuple[str, tuple[str, ...]], ...] = (
|
| 182 |
-
("e.g.", ("for example", "par exemple", "exempli gratia")),
|
| 183 |
-
("i.e.", ("c'est-à-dire", "id est", "that is")),
|
| 184 |
-
("etc.", ("et cetera", "et caetera")),
|
| 185 |
-
("cf.", ("confer", "voir")),
|
| 186 |
-
("ibid.", ("ibidem",)),
|
| 187 |
-
("op. cit.", ("opere citato", "opus citatum")),
|
| 188 |
-
("ad lib.", ("ad libitum",)),
|
| 189 |
-
("N.B.", ("nota bene",)),
|
| 190 |
-
)
|
| 191 |
-
|
| 192 |
-
BIBLIOGRAPHIC: tuple[tuple[str, tuple[str, ...]], ...] = (
|
| 193 |
-
("vol.", ("volume",)),
|
| 194 |
-
("t.", ("tome",)),
|
| 195 |
-
("p.", ("page",)),
|
| 196 |
-
("pp.", ("pages",)),
|
| 197 |
-
("n°", ("numéro", "numero", "no")),
|
| 198 |
-
("fasc.", ("fascicule",)),
|
| 199 |
-
("éd.", ("édition", "edition")),
|
| 200 |
-
("ms.", ("manuscrit",)),
|
| 201 |
-
("f.", ("folio",)),
|
| 202 |
-
("r°", ("recto",)),
|
| 203 |
-
("v°", ("verso",)),
|
| 204 |
-
)
|
| 205 |
-
|
| 206 |
-
ADDRESS: tuple[tuple[str, tuple[str, ...]], ...] = (
|
| 207 |
-
("bd", ("boulevard",)),
|
| 208 |
-
("av.", ("avenue",)),
|
| 209 |
-
("r.", ("rue",)),
|
| 210 |
-
("pl.", ("place",)),
|
| 211 |
-
("imp.", ("impasse",)),
|
| 212 |
-
("fbg", ("faubourg",)),
|
| 213 |
-
)
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 217 |
-
# Indexation par catégorie
|
| 218 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 219 |
-
|
| 220 |
-
_CATEGORIES: dict[str, tuple[tuple[str, tuple[str, ...]], ...]] = {
|
| 221 |
-
"civility_titles": CIVILITY_TITLES,
|
| 222 |
-
"ordinals": ORDINALS,
|
| 223 |
-
"currency": CURRENCY,
|
| 224 |
-
"administrative": ADMINISTRATIVE,
|
| 225 |
-
"civil_status": CIVIL_STATUS,
|
| 226 |
-
"typographic_punctuation": TYPOGRAPHIC_PUNCTUATION,
|
| 227 |
-
"latin_abbr_modern": LATIN_ABBR_MODERN,
|
| 228 |
-
"bibliographic": BIBLIOGRAPHIC,
|
| 229 |
-
"address": ADDRESS,
|
| 230 |
-
}
|
| 231 |
-
|
| 232 |
-
# Liste plate de tous les marqueurs avec leur catégorie. Triée par
|
| 233 |
-
# longueur décroissante pour que la détection préfère le marqueur
|
| 234 |
-
# le plus long quand plusieurs préfixes matchent (ex. « S.A.R. »
|
| 235 |
-
# avant « S.A. ").
|
| 236 |
-
_ALL_MARKERS: list[tuple[str, tuple[str, ...], str]] = sorted(
|
| 237 |
-
[
|
| 238 |
-
(marker, expansions, category)
|
| 239 |
-
for category, entries in _CATEGORIES.items()
|
| 240 |
-
for marker, expansions in entries
|
| 241 |
-
],
|
| 242 |
-
key=lambda triple: -len(triple[0]),
|
| 243 |
-
)
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 247 |
-
# Compilation des patterns regex
|
| 248 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 249 |
-
#
|
| 250 |
-
# Pour chaque marqueur, on compile un pattern qui exige une
|
| 251 |
-
# frontière de mot adaptée :
|
| 252 |
-
#
|
| 253 |
-
# - Marqueur alphabétique seul (« Mme », « bd ») → ``\b<marker>\b``
|
| 254 |
-
# (le ``\b`` Python gère correctement les bords).
|
| 255 |
-
# - Marqueur contenant un point (« M. », « S.A.R. », « arr. »,
|
| 256 |
-
# « r° », « n° ») → frontière espace/début/fin/ponctuation
|
| 257 |
-
# explicite (le ``.`` final étant non-mot, ``\b`` standard
|
| 258 |
-
# matcherait dans « arr.acher »).
|
| 259 |
-
# - Marqueur contenant un caractère non ASCII (exposant, monnaie,
|
| 260 |
-
# guillemet, croix d'état civil) → match littéral, pas de
|
| 261 |
-
# frontière de mot car ``\b`` ne fonctionne pas sur les
|
| 262 |
-
# caractères non-mot Unicode.
|
| 263 |
-
#
|
| 264 |
-
# La frontière de droite après un point exige soit la fin de
|
| 265 |
-
# chaîne, soit un blanc, soit une ponctuation usuelle (« , ; : ! ? )
|
| 266 |
-
# … » »).
|
| 267 |
-
|
| 268 |
-
_TRAILING_BOUNDARY = r"(?=$|[\s,;:!?\)\]\»\"\'\n\r\t…])"
|
| 269 |
-
_LEADING_BOUNDARY = r"(?:^|(?<=[\s,;:!?\(\[\«\"\'\n\r\t]))"
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
def _is_alphanumeric_only(text: str) -> bool:
|
| 273 |
-
"""Vrai si tous les caractères sont alphanumériques ASCII."""
|
| 274 |
-
return all(c.isascii() and c.isalnum() for c in text)
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
def _compile_pattern(marker: str) -> re.Pattern[str]:
|
| 278 |
-
"""Compile le pattern regex pour la détection d'un marqueur
|
| 279 |
-
dans la GT et l'hypothèse.
|
| 280 |
-
|
| 281 |
-
La logique de frontière de mot dépend de la composition du
|
| 282 |
-
marqueur (cf. commentaire principal).
|
| 283 |
-
"""
|
| 284 |
-
escaped = re.escape(marker)
|
| 285 |
-
if "." in marker:
|
| 286 |
-
# Frontière explicite après le point final.
|
| 287 |
-
return re.compile(_LEADING_BOUNDARY + escaped + _TRAILING_BOUNDARY)
|
| 288 |
-
if _is_alphanumeric_only(marker):
|
| 289 |
-
return re.compile(r"\b" + escaped + r"\b")
|
| 290 |
-
# Marqueurs Unicode (exposants, monnaies, guillemets, ponctuation
|
| 291 |
-
# typographique, croix) : match littéral, pas de \b.
|
| 292 |
-
return re.compile(escaped)
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
# Cache des patterns compilés : (marker, category) → pattern.
|
| 296 |
-
_PATTERNS: dict[tuple[str, str], re.Pattern[str]] = {
|
| 297 |
-
(marker, category): _compile_pattern(marker)
|
| 298 |
-
for marker, _expansions, category in _ALL_MARKERS
|
| 299 |
-
}
|
| 300 |
-
|
| 301 |
-
# Patterns d'expansion (insensibles à la casse, frontière de mot
|
| 302 |
-
# si la forme développée est purement alphabétique).
|
| 303 |
-
_EXPANSION_PATTERNS: dict[str, list[re.Pattern[str]]] = {}
|
| 304 |
-
for marker, expansions, _category in _ALL_MARKERS:
|
| 305 |
-
compiled: list[re.Pattern[str]] = []
|
| 306 |
-
for exp in expansions:
|
| 307 |
-
escaped = re.escape(exp)
|
| 308 |
-
if exp and _is_alphanumeric_only(exp):
|
| 309 |
-
compiled.append(re.compile(r"\b" + escaped + r"\b", re.IGNORECASE))
|
| 310 |
-
else:
|
| 311 |
-
compiled.append(re.compile(escaped, re.IGNORECASE))
|
| 312 |
-
_EXPANSION_PATTERNS[marker] = compiled
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 316 |
-
# API publique : catégorisation + détection
|
| 317 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
def get_category(marker: str) -> Optional[str]:
|
| 321 |
-
"""Retourne la catégorie d'un marqueur ou ``None`` si inconnu.
|
| 322 |
-
|
| 323 |
-
La comparaison est exacte (sensible à la casse, aux exposants
|
| 324 |
-
Unicode et aux points).
|
| 325 |
-
"""
|
| 326 |
-
if not marker:
|
| 327 |
-
return None
|
| 328 |
-
for category, entries in _CATEGORIES.items():
|
| 329 |
-
for known, _expansions in entries:
|
| 330 |
-
if known == marker:
|
| 331 |
-
return category
|
| 332 |
-
return None
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
def get_expansions(marker: str) -> tuple[str, ...]:
|
| 336 |
-
"""Retourne les formes développées connues pour un marqueur,
|
| 337 |
-
ou un tuple vide si inconnu."""
|
| 338 |
-
if not marker:
|
| 339 |
-
return ()
|
| 340 |
-
for _category, entries in _CATEGORIES.items():
|
| 341 |
-
for known, expansions in entries:
|
| 342 |
-
if known == marker:
|
| 343 |
-
return expansions
|
| 344 |
-
return ()
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
def detect_modern_markers(
|
| 348 |
-
text: Optional[str],
|
| 349 |
-
) -> list[tuple[int, str, str]]:
|
| 350 |
-
"""Retourne les marqueurs trouvés dans ``text``.
|
| 351 |
-
|
| 352 |
-
Forme de sortie : ``[(index, marker, category), ...]`` triée
|
| 353 |
-
par index croissant. Si plusieurs marqueurs se chevauchent, le
|
| 354 |
-
plus long gagne (ex. « S.A.R. » plutôt que « S. " puis « A.R. »).
|
| 355 |
-
|
| 356 |
-
Tolérance casse
|
| 357 |
-
---------------
|
| 358 |
-
Les marqueurs alphabétiques courts (« Mme », « Dr », « bd »)
|
| 359 |
-
sont matchés tels quels (sensibilité à la casse) — on n'élargit
|
| 360 |
-
pas car « me » en minuscule n'est pas une abréviation de
|
| 361 |
-
« Maître ».
|
| 362 |
-
"""
|
| 363 |
-
if not text:
|
| 364 |
-
return []
|
| 365 |
-
# Collecte tous les matches de tous les marqueurs.
|
| 366 |
-
candidates: list[tuple[int, int, str, str]] = [] # start, end, marker, cat
|
| 367 |
-
for marker, _expansions, category in _ALL_MARKERS:
|
| 368 |
-
pattern = _PATTERNS[(marker, category)]
|
| 369 |
-
for match in pattern.finditer(text):
|
| 370 |
-
candidates.append((match.start(), match.end(), marker, category))
|
| 371 |
-
# Tri par (start, -length) pour appliquer une stratégie greedy
|
| 372 |
-
# « plus long gagne » à chaque position.
|
| 373 |
-
candidates.sort(key=lambda c: (c[0], -(c[1] - c[0])))
|
| 374 |
-
chosen: list[tuple[int, str, str]] = []
|
| 375 |
-
last_end = -1
|
| 376 |
-
for start, end, marker, category in candidates:
|
| 377 |
-
if start < last_end:
|
| 378 |
-
continue
|
| 379 |
-
chosen.append((start, marker, category))
|
| 380 |
-
last_end = end
|
| 381 |
-
return chosen
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 385 |
-
# Calcul des scores strict / expansion
|
| 386 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
def _hyp_contains_marker(
|
| 390 |
-
hypothesis: str, marker: str, category: str,
|
| 391 |
-
) -> bool:
|
| 392 |
-
"""Vrai si le marqueur est présent (au moins une occurrence) dans
|
| 393 |
-
l'hypothèse, avec la même règle de frontière qu'en GT."""
|
| 394 |
-
pattern = _PATTERNS[(marker, category)]
|
| 395 |
-
return pattern.search(hypothesis) is not None
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
def _hyp_contains_expansion(hypothesis: str, marker: str) -> bool:
|
| 399 |
-
"""Vrai si une forme développée connue du marqueur est présente
|
| 400 |
-
dans l'hypothèse (insensible à la casse)."""
|
| 401 |
-
for pattern in _EXPANSION_PATTERNS.get(marker, ()):
|
| 402 |
-
if pattern.search(hypothesis) is not None:
|
| 403 |
-
return True
|
| 404 |
-
return False
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
def compute_modern_archives_metrics(
|
| 408 |
-
reference: Optional[str],
|
| 409 |
-
hypothesis: Optional[str],
|
| 410 |
-
) -> dict:
|
| 411 |
-
"""Calcule la préservation des marqueurs d'archives modernes.
|
| 412 |
-
|
| 413 |
-
Pour chaque catégorie : retourne le ``strict_score`` (forme
|
| 414 |
-
abrégée préservée) et l'``expansion_score`` (abrégée OU
|
| 415 |
-
développée présente). Le ratio des deux donne au chercheur la
|
| 416 |
-
convention adoptée (diplomatique / modernisante / mixte) sans
|
| 417 |
-
qu'aucune classification ne soit imposée.
|
| 418 |
-
|
| 419 |
-
Returns
|
| 420 |
-
-------
|
| 421 |
-
dict
|
| 422 |
-
``{
|
| 423 |
-
"n_markers_reference": int,
|
| 424 |
-
"n_strict_preserved": int,
|
| 425 |
-
"n_expansion_preserved": int,
|
| 426 |
-
"global_strict_score": float,
|
| 427 |
-
"global_expansion_score": float,
|
| 428 |
-
"per_category": {
|
| 429 |
-
category: {
|
| 430 |
-
"n_total": int,
|
| 431 |
-
"n_strict_preserved": int,
|
| 432 |
-
"n_expansion_preserved": int,
|
| 433 |
-
"strict_score": float,
|
| 434 |
-
"expansion_score": float,
|
| 435 |
-
}
|
| 436 |
-
},
|
| 437 |
-
"missed_markers": [
|
| 438 |
-
{"index": int, "marker": str, "category": str,
|
| 439 |
-
"expansion_preserved": bool}
|
| 440 |
-
],
|
| 441 |
-
}``
|
| 442 |
-
|
| 443 |
-
Cas dégénérés
|
| 444 |
-
-------------
|
| 445 |
-
- GT vide ou sans marqueur → tous les compteurs à 0, scores à
|
| 446 |
-
``0.0``, ``per_category == {}``.
|
| 447 |
-
- GT non vide avec marqueurs + hyp vide → tous les scores à
|
| 448 |
-
``0.0``, tous les marqueurs dans ``missed_markers``.
|
| 449 |
-
"""
|
| 450 |
-
ref = reference or ""
|
| 451 |
-
hyp = hypothesis or ""
|
| 452 |
-
|
| 453 |
-
detected = detect_modern_markers(ref)
|
| 454 |
-
n_total = len(detected)
|
| 455 |
-
if n_total == 0:
|
| 456 |
-
return {
|
| 457 |
-
"n_markers_reference": 0,
|
| 458 |
-
"n_strict_preserved": 0,
|
| 459 |
-
"n_expansion_preserved": 0,
|
| 460 |
-
"global_strict_score": 0.0,
|
| 461 |
-
"global_expansion_score": 0.0,
|
| 462 |
-
"per_category": {},
|
| 463 |
-
"missed_markers": [],
|
| 464 |
-
}
|
| 465 |
-
|
| 466 |
-
per_cat_total: dict[str, int] = {}
|
| 467 |
-
per_cat_strict: dict[str, int] = {}
|
| 468 |
-
per_cat_expansion: dict[str, int] = {}
|
| 469 |
-
n_strict = 0
|
| 470 |
-
n_expansion = 0
|
| 471 |
-
missed: list[dict] = []
|
| 472 |
-
|
| 473 |
-
for index, marker, category in detected:
|
| 474 |
-
per_cat_total[category] = per_cat_total.get(category, 0) + 1
|
| 475 |
-
strict_ok = _hyp_contains_marker(hyp, marker, category)
|
| 476 |
-
# Convention identique à Sprint 56 : si l'abrégé est
|
| 477 |
-
# préservé, c'est aussi un succès pour expansion (l'OCR n'a
|
| 478 |
-
# pas perdu l'information).
|
| 479 |
-
expansion_ok = strict_ok or _hyp_contains_expansion(hyp, marker)
|
| 480 |
-
if strict_ok:
|
| 481 |
-
per_cat_strict[category] = per_cat_strict.get(category, 0) + 1
|
| 482 |
-
n_strict += 1
|
| 483 |
-
if expansion_ok:
|
| 484 |
-
per_cat_expansion[category] = per_cat_expansion.get(category, 0) + 1
|
| 485 |
-
n_expansion += 1
|
| 486 |
-
if not strict_ok:
|
| 487 |
-
missed.append({
|
| 488 |
-
"index": index,
|
| 489 |
-
"marker": marker,
|
| 490 |
-
"category": category,
|
| 491 |
-
"expansion_preserved": expansion_ok,
|
| 492 |
-
})
|
| 493 |
-
|
| 494 |
-
per_category = {
|
| 495 |
-
cat: {
|
| 496 |
-
"n_total": per_cat_total[cat],
|
| 497 |
-
"n_strict_preserved": per_cat_strict.get(cat, 0),
|
| 498 |
-
"n_expansion_preserved": per_cat_expansion.get(cat, 0),
|
| 499 |
-
"strict_score": (
|
| 500 |
-
per_cat_strict.get(cat, 0) / per_cat_total[cat]
|
| 501 |
-
if per_cat_total[cat] > 0 else 0.0
|
| 502 |
-
),
|
| 503 |
-
"expansion_score": (
|
| 504 |
-
per_cat_expansion.get(cat, 0) / per_cat_total[cat]
|
| 505 |
-
if per_cat_total[cat] > 0 else 0.0
|
| 506 |
-
),
|
| 507 |
-
}
|
| 508 |
-
for cat in sorted(per_cat_total)
|
| 509 |
-
}
|
| 510 |
-
|
| 511 |
-
return {
|
| 512 |
-
"n_markers_reference": n_total,
|
| 513 |
-
"n_strict_preserved": n_strict,
|
| 514 |
-
"n_expansion_preserved": n_expansion,
|
| 515 |
-
"global_strict_score": n_strict / n_total,
|
| 516 |
-
"global_expansion_score": n_expansion / n_total,
|
| 517 |
-
"per_category": per_category,
|
| 518 |
-
"missed_markers": missed,
|
| 519 |
-
}
|
| 520 |
-
|
| 521 |
-
|
| 522 |
-
def modern_archives_strict_score(
|
| 523 |
-
reference: Optional[str], hypothesis: Optional[str],
|
| 524 |
-
) -> float:
|
| 525 |
-
"""Raccourci : taux global de préservation **stricte** des
|
| 526 |
-
marqueurs d'archives modernes ∈ [0, 1]."""
|
| 527 |
-
return compute_modern_archives_metrics(
|
| 528 |
-
reference, hypothesis,
|
| 529 |
-
)["global_strict_score"]
|
| 530 |
-
|
| 531 |
-
|
| 532 |
-
def modern_archives_expansion_score(
|
| 533 |
-
reference: Optional[str], hypothesis: Optional[str],
|
| 534 |
-
) -> float:
|
| 535 |
-
"""Raccourci : taux global de préservation **étendue** (abrégée
|
| 536 |
-
OU développée) des marqueurs d'archives modernes ∈ [0, 1]."""
|
| 537 |
-
return compute_modern_archives_metrics(
|
| 538 |
-
reference, hypothesis,
|
| 539 |
-
)["global_expansion_score"]
|
| 540 |
-
|
| 541 |
-
|
| 542 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 543 |
-
# Enregistrement dans le registre typé (Sprint 34)
|
| 544 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 545 |
-
|
| 546 |
-
|
| 547 |
-
@register_metric(
|
| 548 |
-
name="modern_archives_strict_score",
|
| 549 |
-
input_types=(ArtifactType.TEXT, ArtifactType.TEXT),
|
| 550 |
-
description=(
|
| 551 |
-
"Taux de préservation stricte des abréviations et marqueurs "
|
| 552 |
-
"typographiques caractéristiques des archives modernes "
|
| 553 |
-
"(XIXᵉ-XXᵉ) : titres de civilité, ordinaux, monnaies, "
|
| 554 |
-
"abréviations administratives, état civil, ponctuation "
|
| 555 |
-
"typographique, abréviations latines, abréviations "
|
| 556 |
-
"bibliographiques, abréviations d'adresse. Forme abrégée "
|
| 557 |
-
"préservée telle quelle (signal d'édition diplomatique)."
|
| 558 |
-
),
|
| 559 |
-
higher_is_better=True,
|
| 560 |
-
tags={"text", "modern_archives", "philology", "abbreviations"},
|
| 561 |
-
)
|
| 562 |
-
def _registered_strict(reference: str, hypothesis: str) -> float:
|
| 563 |
-
return modern_archives_strict_score(reference, hypothesis)
|
| 564 |
-
|
| 565 |
-
|
| 566 |
-
@register_metric(
|
| 567 |
-
name="modern_archives_expansion_score",
|
| 568 |
-
input_types=(ArtifactType.TEXT, ArtifactType.TEXT),
|
| 569 |
-
description=(
|
| 570 |
-
"Taux de préservation étendue (forme abrégée OU forme "
|
| 571 |
-
"développée présente) des marqueurs d'archives modernes "
|
| 572 |
-
"XIXᵉ-XXᵉ. Le ratio strict/expansion par catégorie "
|
| 573 |
-
"permet au chercheur de juger lui-même la convention "
|
| 574 |
-
"éditoriale adoptée."
|
| 575 |
-
),
|
| 576 |
-
higher_is_better=True,
|
| 577 |
-
tags={"text", "modern_archives", "philology", "abbreviations"},
|
| 578 |
-
)
|
| 579 |
-
def _registered_expansion(reference: str, hypothesis: str) -> float:
|
| 580 |
-
return modern_archives_expansion_score(reference, hypothesis)
|
| 581 |
-
|
| 582 |
-
|
| 583 |
-
__all__ = [
|
| 584 |
-
"CIVILITY_TITLES",
|
| 585 |
-
"ORDINALS",
|
| 586 |
-
"CURRENCY",
|
| 587 |
-
"ADMINISTRATIVE",
|
| 588 |
-
"CIVIL_STATUS",
|
| 589 |
-
"TYPOGRAPHIC_PUNCTUATION",
|
| 590 |
-
"LATIN_ABBR_MODERN",
|
| 591 |
-
"BIBLIOGRAPHIC",
|
| 592 |
-
"ADDRESS",
|
| 593 |
-
"compute_modern_archives_metrics",
|
| 594 |
-
"detect_modern_markers",
|
| 595 |
-
"get_category",
|
| 596 |
-
"get_expansions",
|
| 597 |
-
"modern_archives_strict_score",
|
| 598 |
-
"modern_archives_expansion_score",
|
| 599 |
-
]
|
| 600 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Alias rétrocompat — module déplacé dans :mod:`picarones.extras.historical.modern_archives`.
|
|
|
|
| 2 |
|
| 3 |
+
Phase B du chantier de refonte en 3 cercles (architecture-cercles.md).
|
| 4 |
+
Ce module philologique est désormais en Cercle 3 (``extras/``). L'alias
|
| 5 |
+
ici permet aux imports historiques (``from picarones.core.modern_archives
|
| 6 |
+
import ...``) de continuer à fonctionner sans modification.
|
| 7 |
|
| 8 |
+
Voir :doc:`docs/architecture-cercles.md` et l'extra
|
| 9 |
+
``picarones[historical]`` du ``pyproject.toml``.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
"""
|
| 11 |
|
| 12 |
+
from picarones.extras.historical.modern_archives import * # noqa: F401, F403
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
+
import picarones.extras.historical.modern_archives as _module
|
| 15 |
+
__all__ = getattr(_module, "__all__", [
|
| 16 |
+
name for name in dir(_module) if not name.startswith("_")
|
| 17 |
+
])
|
|
@@ -1,264 +1,17 @@
|
|
| 1 |
-
"""
|
| 2 |
|
| 3 |
-
|
| 4 |
-
philologique).
|
|
|
|
|
|
|
| 5 |
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
La **Medieval Unicode Font Initiative** (MUFI v4.0) standardise les
|
| 9 |
-
caractères médiévaux que les éditeurs critiques attendent dans une
|
| 10 |
-
transcription fidèle : signes d'abréviation, ligatures, lettres
|
| 11 |
-
spéciales (ƿ wynn, þ thorn), ponctuation médiévale, marques
|
| 12 |
-
diacritiques rares, etc. Pour les médiévistes, la **couverture
|
| 13 |
-
MUFI** d'un moteur OCR/HTR est un critère éditorial central.
|
| 14 |
-
|
| 15 |
-
Ce module mesure le taux de **caractères MUFI de la GT
|
| 16 |
-
correctement restitués** dans l'OCR, après alignement caractère par
|
| 17 |
-
caractère (même approche que la précision par bloc Unicode du
|
| 18 |
-
Sprint 55).
|
| 19 |
-
|
| 20 |
-
Détection des caractères MUFI
|
| 21 |
-
-----------------------------
|
| 22 |
-
La spécification MUFI v4.0 référence ~1300 caractères dans plusieurs
|
| 23 |
-
plages Unicode. Plutôt que d'embarquer la liste exhaustive (qui
|
| 24 |
-
évolue), on utilise un **set de plages caractéristiques** suffisant
|
| 25 |
-
pour les corpus patrimoniaux européens courants :
|
| 26 |
-
|
| 27 |
-
- PUA principal (U+E000–U+F8FF) : zone usuelle des glyphes MUFI
|
| 28 |
-
qui n'ont pas (encore) de point de code Unicode standard.
|
| 29 |
-
- Latin Extended-D (U+A720–U+A7FF) : abréviations latines
|
| 30 |
-
médiévales (ꝑ, ꝓ, ꝗ, etc.).
|
| 31 |
-
- Combining Diacritical Marks Supplement (U+1DC0–U+1DFF) :
|
| 32 |
-
diacritiques médiévaux rares (macron suscript, etc.).
|
| 33 |
-
- Alphabetic Presentation Forms (U+FB00–U+FB4F) : ligatures
|
| 34 |
-
(fi, fl, ff).
|
| 35 |
-
- Une **liste explicite** de caractères médiévaux dans les blocs
|
| 36 |
-
Latin Extended-A/B/Additional (þ, ð, ƿ, ſ, æ, œ, etc.)
|
| 37 |
-
|
| 38 |
-
L'utilisateur peut personnaliser via le paramètre ``custom_chars``
|
| 39 |
-
de ``compute_mufi_coverage`` pour étendre ou restreindre.
|
| 40 |
-
|
| 41 |
-
Stratégie de découpage
|
| 42 |
-
----------------------
|
| 43 |
-
Cohérente avec NER (Sprint 38), Flesch (52), Reading order F1 (53),
|
| 44 |
-
Layout F1 (54), Bloc Unicode (55), Abréviations (56) : couche de
|
| 45 |
-
calcul pure d'abord. Le câblage runner et la vue HTML suivent dans
|
| 46 |
-
des sprints dédiés.
|
| 47 |
"""
|
| 48 |
|
| 49 |
-
from
|
| 50 |
-
|
| 51 |
-
import logging
|
| 52 |
-
from difflib import SequenceMatcher
|
| 53 |
-
from typing import Iterable, Optional
|
| 54 |
-
|
| 55 |
-
from picarones.core.metric_registry import register_metric
|
| 56 |
-
from picarones.core.modules import ArtifactType
|
| 57 |
-
|
| 58 |
-
logger = logging.getLogger(__name__)
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 62 |
-
# Plages Unicode considérées comme MUFI
|
| 63 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 64 |
-
|
| 65 |
-
# Triplets (nom, lo, hi) inclusifs. Source : MUFI v4.0 spec
|
| 66 |
-
# (https://mufi.info/) + revue manuelle des caractères patrimoniaux
|
| 67 |
-
# courants.
|
| 68 |
-
_MUFI_RANGES: tuple[tuple[str, int, int], ...] = (
|
| 69 |
-
("Private Use Area", 0xE000, 0xF8FF),
|
| 70 |
-
("Latin Extended-D", 0xA720, 0xA7FF),
|
| 71 |
-
("Combining Diacritical Marks Supplement", 0x1DC0, 0x1DFF),
|
| 72 |
-
("Alphabetic Presentation Forms", 0xFB00, 0xFB4F),
|
| 73 |
-
)
|
| 74 |
-
|
| 75 |
-
# Caractères MUFI explicites hors plages couvertes par les ranges.
|
| 76 |
-
# Surtout des glyphes médiévaux standardisés en Unicode mais qui ne
|
| 77 |
-
# sont pas dans le PUA ni dans Latin Extended-D : þ, ð, ƿ, ſ, æ, œ,
|
| 78 |
-
# ø, ƀ, ƕ, etc. Liste raisonnée pour les corpus européens médiévaux.
|
| 79 |
-
_MUFI_EXPLICIT_CHARS: frozenset[str] = frozenset(
|
| 80 |
-
[
|
| 81 |
-
# Lettres médiévales standard
|
| 82 |
-
"þ", "Þ", # thorn — vieil anglais, islandais
|
| 83 |
-
"ð", "Ð", # eth — vieil anglais, islandais
|
| 84 |
-
"ƿ", "Ƿ", # wynn — vieil anglais
|
| 85 |
-
"ſ", # s long médiéval (déjà U+017F)
|
| 86 |
-
"æ", "Æ", # ash
|
| 87 |
-
"œ", "Œ", # ethel
|
| 88 |
-
"ø", "Ø", # o barré
|
| 89 |
-
# Lettres rares avec barré (pour préfixes abréviés)
|
| 90 |
-
"ƀ", # b barré
|
| 91 |
-
"ŧ", # t barré
|
| 92 |
-
"đ", # d barré
|
| 93 |
-
"ħ", # h barré
|
| 94 |
-
# Yogh
|
| 95 |
-
"ȝ", "Ȝ",
|
| 96 |
-
# Autres signes médiévaux courants
|
| 97 |
-
"ꜿ", # con
|
| 98 |
-
# Note : la liste est volontairement courte ; pour étendre,
|
| 99 |
-
# l'utilisateur peut passer ``custom_chars`` à
|
| 100 |
-
# ``compute_mufi_coverage``.
|
| 101 |
-
]
|
| 102 |
-
)
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
def is_mufi_char(char: str, custom_chars: Optional[frozenset[str]] = None) -> bool:
|
| 106 |
-
"""Retourne ``True`` si ``char`` est considéré comme MUFI.
|
| 107 |
-
|
| 108 |
-
Reconnaît :
|
| 109 |
-
|
| 110 |
-
- les caractères dans les plages Unicode MUFI (``_MUFI_RANGES``),
|
| 111 |
-
- les caractères de la liste explicite (``_MUFI_EXPLICIT_CHARS``),
|
| 112 |
-
- tout caractère supplémentaire fourni via ``custom_chars``.
|
| 113 |
-
|
| 114 |
-
Pour une chaîne multi-caractères, seul le premier code-point
|
| 115 |
-
est considéré.
|
| 116 |
-
"""
|
| 117 |
-
if not char:
|
| 118 |
-
return False
|
| 119 |
-
cp = ord(char[0])
|
| 120 |
-
for _name, lo, hi in _MUFI_RANGES:
|
| 121 |
-
if lo <= cp <= hi:
|
| 122 |
-
return True
|
| 123 |
-
if char[0] in _MUFI_EXPLICIT_CHARS:
|
| 124 |
-
return True
|
| 125 |
-
if custom_chars and char[0] in custom_chars:
|
| 126 |
-
return True
|
| 127 |
-
return False
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 131 |
-
# Calcul de couverture MUFI
|
| 132 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
def compute_mufi_coverage(
|
| 136 |
-
reference: Optional[str],
|
| 137 |
-
hypothesis: Optional[str],
|
| 138 |
-
custom_chars: Optional[Iterable[str]] = None,
|
| 139 |
-
) -> dict:
|
| 140 |
-
"""Calcule la couverture MUFI : taux de caractères MUFI de la GT
|
| 141 |
-
correctement restitués dans l'hypothèse.
|
| 142 |
-
|
| 143 |
-
Parameters
|
| 144 |
-
----------
|
| 145 |
-
reference:
|
| 146 |
-
Texte GT.
|
| 147 |
-
hypothesis:
|
| 148 |
-
Texte produit par l'OCR.
|
| 149 |
-
custom_chars:
|
| 150 |
-
Itérable optionnel de caractères supplémentaires à considérer
|
| 151 |
-
comme MUFI (utile pour les éditeurs ayant une convention
|
| 152 |
-
propre). Chaque entrée doit être un caractère unique.
|
| 153 |
-
|
| 154 |
-
Returns
|
| 155 |
-
-------
|
| 156 |
-
dict
|
| 157 |
-
``{
|
| 158 |
-
"n_mufi_chars_reference": int, # caractères MUFI dans la GT
|
| 159 |
-
"n_mufi_chars_preserved": int, # MUFI restitués correctement
|
| 160 |
-
"coverage": float, # ∈ [0, 1] ou 0 si N=0
|
| 161 |
-
"per_char": {char: {"total", "preserved", "coverage"}},
|
| 162 |
-
"missed_chars": list[str], # caractères MUFI ratés
|
| 163 |
-
}``
|
| 164 |
-
|
| 165 |
-
Cas dégénérés
|
| 166 |
-
-------------
|
| 167 |
-
- GT vide ou sans caractère MUFI → ``coverage = 0`` (convention :
|
| 168 |
-
pas de récompense gratuite).
|
| 169 |
-
- Hyp vide + MUFI dans GT → ``coverage = 0``.
|
| 170 |
-
- GT et hyp identiques avec MUFI → ``coverage = 1``.
|
| 171 |
-
"""
|
| 172 |
-
ref = reference or ""
|
| 173 |
-
hyp = hypothesis or ""
|
| 174 |
-
extra: Optional[frozenset[str]] = (
|
| 175 |
-
frozenset(c for c in custom_chars if c) if custom_chars else None
|
| 176 |
-
)
|
| 177 |
-
|
| 178 |
-
# 1. Identifier les positions MUFI dans la GT
|
| 179 |
-
mufi_positions = [i for i, ch in enumerate(ref) if is_mufi_char(ch, extra)]
|
| 180 |
-
n_total = len(mufi_positions)
|
| 181 |
-
|
| 182 |
-
if n_total == 0:
|
| 183 |
-
return {
|
| 184 |
-
"n_mufi_chars_reference": 0,
|
| 185 |
-
"n_mufi_chars_preserved": 0,
|
| 186 |
-
"coverage": 0.0,
|
| 187 |
-
"per_char": {},
|
| 188 |
-
"missed_chars": [],
|
| 189 |
-
}
|
| 190 |
-
|
| 191 |
-
# 2. Aligner via SequenceMatcher (même méthode que Sprint 55)
|
| 192 |
-
matcher = SequenceMatcher(a=ref, b=hyp, autojunk=False)
|
| 193 |
-
correct_positions: set[int] = set()
|
| 194 |
-
for op, i1, i2, _j1, _j2 in matcher.get_opcodes():
|
| 195 |
-
if op == "equal":
|
| 196 |
-
correct_positions.update(range(i1, i2))
|
| 197 |
-
|
| 198 |
-
# 3. Compter par caractère
|
| 199 |
-
per_char_total: dict[str, int] = {}
|
| 200 |
-
per_char_preserved: dict[str, int] = {}
|
| 201 |
-
missed: list[str] = []
|
| 202 |
-
for i in mufi_positions:
|
| 203 |
-
ch = ref[i]
|
| 204 |
-
per_char_total[ch] = per_char_total.get(ch, 0) + 1
|
| 205 |
-
if i in correct_positions:
|
| 206 |
-
per_char_preserved[ch] = per_char_preserved.get(ch, 0) + 1
|
| 207 |
-
else:
|
| 208 |
-
missed.append(ch)
|
| 209 |
-
|
| 210 |
-
n_preserved = sum(per_char_preserved.values())
|
| 211 |
-
per_char = {
|
| 212 |
-
ch: {
|
| 213 |
-
"total": per_char_total[ch],
|
| 214 |
-
"preserved": per_char_preserved.get(ch, 0),
|
| 215 |
-
"coverage": (
|
| 216 |
-
per_char_preserved.get(ch, 0) / per_char_total[ch]
|
| 217 |
-
if per_char_total[ch] > 0
|
| 218 |
-
else 0.0
|
| 219 |
-
),
|
| 220 |
-
}
|
| 221 |
-
for ch in sorted(per_char_total)
|
| 222 |
-
}
|
| 223 |
-
|
| 224 |
-
return {
|
| 225 |
-
"n_mufi_chars_reference": n_total,
|
| 226 |
-
"n_mufi_chars_preserved": n_preserved,
|
| 227 |
-
"coverage": n_preserved / n_total,
|
| 228 |
-
"per_char": per_char,
|
| 229 |
-
"missed_chars": missed,
|
| 230 |
-
}
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
def mufi_coverage(
|
| 234 |
-
reference: Optional[str], hypothesis: Optional[str],
|
| 235 |
-
) -> float:
|
| 236 |
-
"""Raccourci : retourne la couverture MUFI globale ∈ [0, 1]."""
|
| 237 |
-
return compute_mufi_coverage(reference, hypothesis)["coverage"]
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 241 |
-
# Enregistrement dans le registre typé (Sprint 34)
|
| 242 |
-
# ──────────────────────────────────────────��───────────────────────────────
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
@register_metric(
|
| 246 |
-
name="mufi_coverage",
|
| 247 |
-
input_types=(ArtifactType.TEXT, ArtifactType.TEXT),
|
| 248 |
-
description=(
|
| 249 |
-
"Taux de caractères MUFI (Medieval Unicode Font Initiative) "
|
| 250 |
-
"de la GT correctement restitués dans l'OCR. Critère "
|
| 251 |
-
"éditorial central pour les médiévistes."
|
| 252 |
-
),
|
| 253 |
-
higher_is_better=True,
|
| 254 |
-
tags={"text", "mufi", "philology", "medieval"},
|
| 255 |
-
)
|
| 256 |
-
def _registered_mufi_coverage(reference: str, hypothesis: str) -> float:
|
| 257 |
-
return mufi_coverage(reference, hypothesis)
|
| 258 |
-
|
| 259 |
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
"
|
| 263 |
-
|
| 264 |
-
]
|
|
|
|
| 1 |
+
"""Alias rétrocompat — module déplacé dans :mod:`picarones.extras.historical.mufi`.
|
| 2 |
|
| 3 |
+
Phase B du chantier de refonte en 3 cercles (architecture-cercles.md).
|
| 4 |
+
Ce module philologique est désormais en Cercle 3 (``extras/``). L'alias
|
| 5 |
+
ici permet aux imports historiques (``from picarones.core.mufi
|
| 6 |
+
import ...``) de continuer à fonctionner sans modification.
|
| 7 |
|
| 8 |
+
Voir :doc:`docs/architecture-cercles.md` et l'extra
|
| 9 |
+
``picarones[historical]`` du ``pyproject.toml``.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
"""
|
| 11 |
|
| 12 |
+
from picarones.extras.historical.mufi import * # noqa: F401, F403
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
+
import picarones.extras.historical.mufi as _module
|
| 15 |
+
__all__ = getattr(_module, "__all__", [
|
| 16 |
+
name for name in dir(_module) if not name.startswith("_")
|
| 17 |
+
])
|
|
|
|
@@ -1,363 +1,17 @@
|
|
| 1 |
-
"""
|
| 2 |
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
| 4 |
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
- ``mufi`` (Sprint 57)
|
| 8 |
-
- ``early_modern`` (Sprint 58)
|
| 9 |
-
- ``modern_archives`` (Sprint 59)
|
| 10 |
-
- ``roman_numerals`` (Sprint 60)
|
| 11 |
-
|
| 12 |
-
Principe « adaptive »
|
| 13 |
-
----------------------
|
| 14 |
-
Un module n'est inclus dans le résultat que si la **GT contient du
|
| 15 |
-
signal exploitable** pour ce module. Cette logique évite de polluer
|
| 16 |
-
les rapports sur les corpus sans marqueurs philologiques (typique
|
| 17 |
-
sur des données XXIᵉ ou des transcriptions modernes propres).
|
| 18 |
-
|
| 19 |
-
Coût
|
| 20 |
-
----
|
| 21 |
-
Les 6 calculs sont O(N) sur la longueur du texte ; le surcoût total
|
| 22 |
-
par document est négligeable face à un appel OCR. L'activation est
|
| 23 |
-
donc **automatique** (pas d'opt-in), contrairement aux backends NER
|
| 24 |
-
ou calibration qui exigent une dépendance externe ou des données
|
| 25 |
-
spécifiques.
|
| 26 |
"""
|
| 27 |
|
| 28 |
-
from
|
| 29 |
-
|
| 30 |
-
import logging
|
| 31 |
-
from typing import Optional
|
| 32 |
-
|
| 33 |
-
from picarones.core.abbreviations import compute_abbreviation_metrics
|
| 34 |
-
from picarones.core.early_modern_typography import compute_early_modern_metrics
|
| 35 |
-
from picarones.core.modern_archives import compute_modern_archives_metrics
|
| 36 |
-
from picarones.core.mufi import compute_mufi_coverage
|
| 37 |
-
from picarones.core.roman_numerals import compute_roman_numeral_metrics
|
| 38 |
-
from picarones.core.unicode_blocks import compute_unicode_block_accuracy
|
| 39 |
-
|
| 40 |
-
logger = logging.getLogger(__name__)
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 44 |
-
# Critères « le module a-t-il du signal sur ce document ? »
|
| 45 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 46 |
-
#
|
| 47 |
-
# Pour chaque module, on définit un prédicat sur le résultat : si vrai,
|
| 48 |
-
# le module est inclus ; sinon, il est omis pour ne pas alourdir le
|
| 49 |
-
# rapport.
|
| 50 |
-
|
| 51 |
-
def _has_unicode_signal(result: dict) -> bool:
|
| 52 |
-
# Le module retourne toujours du signal dès que GT non-vide ; on
|
| 53 |
-
# n'inclut que si la GT a au moins un caractère **hors Basic
|
| 54 |
-
# Latin** (sinon le breakdown se réduit à 100 % Basic Latin et
|
| 55 |
-
# n'apporte rien au lecteur).
|
| 56 |
-
per_block = result.get("per_block", {})
|
| 57 |
-
for block, stats in per_block.items():
|
| 58 |
-
if block == "Basic Latin":
|
| 59 |
-
continue
|
| 60 |
-
if stats.get("total", 0) > 0:
|
| 61 |
-
return True
|
| 62 |
-
return False
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
def _has_abbreviation_signal(result: dict) -> bool:
|
| 66 |
-
return result.get("n_abbreviations_in_reference", 0) > 0
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
def _has_mufi_signal(result: dict) -> bool:
|
| 70 |
-
return result.get("n_mufi_chars_reference", 0) > 0
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
def _has_early_modern_signal(result: dict) -> bool:
|
| 74 |
-
return result.get("n_markers_reference", 0) > 0
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
def _has_modern_archives_signal(result: dict) -> bool:
|
| 78 |
-
return result.get("n_markers_reference", 0) > 0
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
def _has_roman_numeral_signal(result: dict) -> bool:
|
| 82 |
-
return result.get("n_numerals_reference", 0) > 0
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
# Ordre fixé pour la reproductibilité des sorties.
|
| 86 |
-
_PHILOLOGICAL_MODULES: tuple[
|
| 87 |
-
tuple[str, callable, callable], ...
|
| 88 |
-
] = (
|
| 89 |
-
("unicode_blocks", compute_unicode_block_accuracy, _has_unicode_signal),
|
| 90 |
-
("abbreviations", compute_abbreviation_metrics, _has_abbreviation_signal),
|
| 91 |
-
("mufi", compute_mufi_coverage, _has_mufi_signal),
|
| 92 |
-
("early_modern", compute_early_modern_metrics, _has_early_modern_signal),
|
| 93 |
-
("modern_archives", compute_modern_archives_metrics, _has_modern_archives_signal),
|
| 94 |
-
("roman_numerals", compute_roman_numeral_metrics, _has_roman_numeral_signal),
|
| 95 |
-
)
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 99 |
-
# Calcul par document
|
| 100 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
def compute_philological_metrics(
|
| 104 |
-
reference: Optional[str],
|
| 105 |
-
hypothesis: Optional[str],
|
| 106 |
-
) -> Optional[dict]:
|
| 107 |
-
"""Calcule les 6 métriques philologiques pour un document.
|
| 108 |
-
|
| 109 |
-
Retourne un dict avec une clé par module ayant du signal, ou
|
| 110 |
-
``None`` si aucun module n'en a (corpus sans marqueur
|
| 111 |
-
philologique pertinent).
|
| 112 |
-
|
| 113 |
-
En cas d'erreur dans un module individuel, le module est
|
| 114 |
-
silencieusement omis et un warning est émis (les autres modules
|
| 115 |
-
restent calculés).
|
| 116 |
-
"""
|
| 117 |
-
ref = reference or ""
|
| 118 |
-
if not ref:
|
| 119 |
-
return None
|
| 120 |
-
out: dict = {}
|
| 121 |
-
for name, compute_fn, has_signal_fn in _PHILOLOGICAL_MODULES:
|
| 122 |
-
try:
|
| 123 |
-
result = compute_fn(ref, hypothesis or "")
|
| 124 |
-
except Exception as exc: # pragma: no cover — défense en profondeur
|
| 125 |
-
logger.warning(
|
| 126 |
-
"[philological_runner] module %s a échoué : %s", name, exc,
|
| 127 |
-
)
|
| 128 |
-
continue
|
| 129 |
-
if has_signal_fn(result):
|
| 130 |
-
out[name] = result
|
| 131 |
-
return out if out else None
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 135 |
-
# Agrégation corpus-wide par moteur
|
| 136 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
def _aggregate_unicode(per_doc: list[dict]) -> dict:
|
| 140 |
-
total_correct = 0
|
| 141 |
-
total_chars = 0
|
| 142 |
-
per_block: dict[str, dict[str, int]] = {}
|
| 143 |
-
for d in per_doc:
|
| 144 |
-
for block, stats in d.get("per_block", {}).items():
|
| 145 |
-
slot = per_block.setdefault(block, {"correct": 0, "total": 0})
|
| 146 |
-
slot["correct"] += stats.get("correct", 0)
|
| 147 |
-
slot["total"] += stats.get("total", 0)
|
| 148 |
-
total_correct += stats.get("correct", 0)
|
| 149 |
-
total_chars += stats.get("total", 0)
|
| 150 |
-
out_per_block = {
|
| 151 |
-
block: {
|
| 152 |
-
"correct": slot["correct"],
|
| 153 |
-
"total": slot["total"],
|
| 154 |
-
"accuracy": (
|
| 155 |
-
slot["correct"] / slot["total"] if slot["total"] > 0 else 0.0
|
| 156 |
-
),
|
| 157 |
-
}
|
| 158 |
-
for block, slot in sorted(per_block.items())
|
| 159 |
-
}
|
| 160 |
-
return {
|
| 161 |
-
"global_accuracy": total_correct / total_chars if total_chars > 0 else 0.0,
|
| 162 |
-
"n_chars_total": total_chars,
|
| 163 |
-
"n_chars_correct": total_correct,
|
| 164 |
-
"per_block": out_per_block,
|
| 165 |
-
"doc_count": len(per_doc),
|
| 166 |
-
}
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
def _aggregate_abbreviations(per_doc: list[dict]) -> dict:
|
| 170 |
-
n_total = 0
|
| 171 |
-
n_strict = 0
|
| 172 |
-
n_expansion = 0
|
| 173 |
-
per_abbr: dict[str, dict[str, int]] = {}
|
| 174 |
-
for d in per_doc:
|
| 175 |
-
n_total += d.get("n_abbreviations_in_reference", 0)
|
| 176 |
-
n_strict += d.get("n_strict_preserved", 0)
|
| 177 |
-
n_expansion += d.get("n_expansion_preserved", 0)
|
| 178 |
-
for entry in d.get("per_abbreviation", []):
|
| 179 |
-
slot = per_abbr.setdefault(
|
| 180 |
-
entry["abbr"],
|
| 181 |
-
{"total": 0, "strict": 0, "expansion": 0},
|
| 182 |
-
)
|
| 183 |
-
slot["total"] += 1
|
| 184 |
-
if entry.get("strict_preserved"):
|
| 185 |
-
slot["strict"] += 1
|
| 186 |
-
if entry.get("expansion_preserved"):
|
| 187 |
-
slot["expansion"] += 1
|
| 188 |
-
return {
|
| 189 |
-
"n_abbreviations_in_reference": n_total,
|
| 190 |
-
"n_strict_preserved": n_strict,
|
| 191 |
-
"n_expansion_preserved": n_expansion,
|
| 192 |
-
"global_strict_score": n_strict / n_total if n_total > 0 else 0.0,
|
| 193 |
-
"global_expansion_score": n_expansion / n_total if n_total > 0 else 0.0,
|
| 194 |
-
"per_abbreviation": {
|
| 195 |
-
abbr: {
|
| 196 |
-
"n_total": slot["total"],
|
| 197 |
-
"n_strict": slot["strict"],
|
| 198 |
-
"n_expansion": slot["expansion"],
|
| 199 |
-
"strict_score": slot["strict"] / slot["total"],
|
| 200 |
-
"expansion_score": slot["expansion"] / slot["total"],
|
| 201 |
-
}
|
| 202 |
-
for abbr, slot in sorted(per_abbr.items())
|
| 203 |
-
},
|
| 204 |
-
"doc_count": len(per_doc),
|
| 205 |
-
}
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
def _aggregate_mufi(per_doc: list[dict]) -> dict:
|
| 209 |
-
n_total = 0
|
| 210 |
-
n_preserved = 0
|
| 211 |
-
per_char: dict[str, dict[str, int]] = {}
|
| 212 |
-
for d in per_doc:
|
| 213 |
-
n_total += d.get("n_mufi_chars_reference", 0)
|
| 214 |
-
n_preserved += d.get("n_mufi_chars_preserved", 0)
|
| 215 |
-
for ch, stats in d.get("per_char", {}).items():
|
| 216 |
-
slot = per_char.setdefault(ch, {"total": 0, "preserved": 0})
|
| 217 |
-
slot["total"] += stats.get("total", 0)
|
| 218 |
-
slot["preserved"] += stats.get("preserved", 0)
|
| 219 |
-
return {
|
| 220 |
-
"n_mufi_chars_reference": n_total,
|
| 221 |
-
"n_mufi_chars_preserved": n_preserved,
|
| 222 |
-
"coverage": n_preserved / n_total if n_total > 0 else 0.0,
|
| 223 |
-
"per_char": {
|
| 224 |
-
ch: {
|
| 225 |
-
"total": slot["total"],
|
| 226 |
-
"preserved": slot["preserved"],
|
| 227 |
-
"coverage": slot["preserved"] / slot["total"],
|
| 228 |
-
}
|
| 229 |
-
for ch, slot in sorted(per_char.items())
|
| 230 |
-
},
|
| 231 |
-
"doc_count": len(per_doc),
|
| 232 |
-
}
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
def _aggregate_early_modern(per_doc: list[dict]) -> dict:
|
| 236 |
-
n_total = 0
|
| 237 |
-
n_preserved = 0
|
| 238 |
-
per_cat: dict[str, dict[str, int]] = {}
|
| 239 |
-
for d in per_doc:
|
| 240 |
-
n_total += d.get("n_markers_reference", 0)
|
| 241 |
-
n_preserved += d.get("n_markers_preserved", 0)
|
| 242 |
-
for cat, stats in d.get("per_category", {}).items():
|
| 243 |
-
slot = per_cat.setdefault(cat, {"total": 0, "preserved": 0})
|
| 244 |
-
slot["total"] += stats.get("total", 0)
|
| 245 |
-
slot["preserved"] += stats.get("preserved", 0)
|
| 246 |
-
return {
|
| 247 |
-
"n_markers_reference": n_total,
|
| 248 |
-
"n_markers_preserved": n_preserved,
|
| 249 |
-
"global_preservation": n_preserved / n_total if n_total > 0 else 0.0,
|
| 250 |
-
"per_category": {
|
| 251 |
-
cat: {
|
| 252 |
-
"total": slot["total"],
|
| 253 |
-
"preserved": slot["preserved"],
|
| 254 |
-
"preservation": slot["preserved"] / slot["total"],
|
| 255 |
-
}
|
| 256 |
-
for cat, slot in sorted(per_cat.items())
|
| 257 |
-
},
|
| 258 |
-
"doc_count": len(per_doc),
|
| 259 |
-
}
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
def _aggregate_modern_archives(per_doc: list[dict]) -> dict:
|
| 263 |
-
n_total = 0
|
| 264 |
-
n_strict = 0
|
| 265 |
-
n_expansion = 0
|
| 266 |
-
per_cat: dict[str, dict[str, int]] = {}
|
| 267 |
-
for d in per_doc:
|
| 268 |
-
n_total += d.get("n_markers_reference", 0)
|
| 269 |
-
n_strict += d.get("n_strict_preserved", 0)
|
| 270 |
-
n_expansion += d.get("n_expansion_preserved", 0)
|
| 271 |
-
for cat, stats in d.get("per_category", {}).items():
|
| 272 |
-
slot = per_cat.setdefault(
|
| 273 |
-
cat, {"total": 0, "strict": 0, "expansion": 0},
|
| 274 |
-
)
|
| 275 |
-
slot["total"] += stats.get("n_total", 0)
|
| 276 |
-
slot["strict"] += stats.get("n_strict_preserved", 0)
|
| 277 |
-
slot["expansion"] += stats.get("n_expansion_preserved", 0)
|
| 278 |
-
return {
|
| 279 |
-
"n_markers_reference": n_total,
|
| 280 |
-
"n_strict_preserved": n_strict,
|
| 281 |
-
"n_expansion_preserved": n_expansion,
|
| 282 |
-
"global_strict_score": n_strict / n_total if n_total > 0 else 0.0,
|
| 283 |
-
"global_expansion_score": n_expansion / n_total if n_total > 0 else 0.0,
|
| 284 |
-
"per_category": {
|
| 285 |
-
cat: {
|
| 286 |
-
"n_total": slot["total"],
|
| 287 |
-
"n_strict_preserved": slot["strict"],
|
| 288 |
-
"n_expansion_preserved": slot["expansion"],
|
| 289 |
-
"strict_score": slot["strict"] / slot["total"],
|
| 290 |
-
"expansion_score": slot["expansion"] / slot["total"],
|
| 291 |
-
}
|
| 292 |
-
for cat, slot in sorted(per_cat.items())
|
| 293 |
-
},
|
| 294 |
-
"doc_count": len(per_doc),
|
| 295 |
-
}
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
def _aggregate_roman_numerals(per_doc: list[dict]) -> dict:
|
| 299 |
-
from picarones.core.roman_numerals import ALL_STATUSES, VALUE_PRESERVING_STATUSES
|
| 300 |
-
|
| 301 |
-
n_total = 0
|
| 302 |
-
per_status: dict[str, int] = {s: 0 for s in ALL_STATUSES}
|
| 303 |
-
for d in per_doc:
|
| 304 |
-
n_total += d.get("n_numerals_reference", 0)
|
| 305 |
-
for status, count in d.get("per_status", {}).items():
|
| 306 |
-
per_status[status] = per_status.get(status, 0) + count
|
| 307 |
-
n_strict = per_status.get("strict_preserved", 0)
|
| 308 |
-
n_value = sum(per_status.get(s, 0) for s in VALUE_PRESERVING_STATUSES)
|
| 309 |
-
return {
|
| 310 |
-
"n_numerals_reference": n_total,
|
| 311 |
-
"n_strict_preserved": n_strict,
|
| 312 |
-
"n_value_preserved": n_value,
|
| 313 |
-
"global_strict_score": n_strict / n_total if n_total > 0 else 0.0,
|
| 314 |
-
"global_value_score": n_value / n_total if n_total > 0 else 0.0,
|
| 315 |
-
"per_status": per_status,
|
| 316 |
-
"doc_count": len(per_doc),
|
| 317 |
-
}
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
_AGGREGATORS = {
|
| 321 |
-
"unicode_blocks": _aggregate_unicode,
|
| 322 |
-
"abbreviations": _aggregate_abbreviations,
|
| 323 |
-
"mufi": _aggregate_mufi,
|
| 324 |
-
"early_modern": _aggregate_early_modern,
|
| 325 |
-
"modern_archives": _aggregate_modern_archives,
|
| 326 |
-
"roman_numerals": _aggregate_roman_numerals,
|
| 327 |
-
}
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
def aggregate_philological_metrics(
|
| 331 |
-
doc_metrics: list[Optional[dict]],
|
| 332 |
-
) -> Optional[dict]:
|
| 333 |
-
"""Agrège les ``philological_metrics`` per-document en un dict
|
| 334 |
-
corpus-wide par module.
|
| 335 |
-
|
| 336 |
-
Pour chaque module, on agrège uniquement les documents qui ont
|
| 337 |
-
eu du signal pour ce module. Si aucun module n'a été calculé
|
| 338 |
-
sur aucun document, retourne ``None``.
|
| 339 |
-
"""
|
| 340 |
-
by_module: dict[str, list[dict]] = {}
|
| 341 |
-
for doc in doc_metrics:
|
| 342 |
-
if not doc:
|
| 343 |
-
continue
|
| 344 |
-
for module, payload in doc.items():
|
| 345 |
-
by_module.setdefault(module, []).append(payload)
|
| 346 |
-
if not by_module:
|
| 347 |
-
return None
|
| 348 |
-
out: dict = {}
|
| 349 |
-
for module, payloads in by_module.items():
|
| 350 |
-
aggregator = _AGGREGATORS.get(module)
|
| 351 |
-
if aggregator is None: # pragma: no cover
|
| 352 |
-
logger.warning(
|
| 353 |
-
"[philological_runner] aucun agrégateur pour %s", module,
|
| 354 |
-
)
|
| 355 |
-
continue
|
| 356 |
-
out[module] = aggregator(payloads)
|
| 357 |
-
return out if out else None
|
| 358 |
-
|
| 359 |
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
"
|
| 363 |
-
]
|
|
|
|
| 1 |
+
"""Alias rétrocompat — module déplacé dans :mod:`picarones.extras.historical.philological_runner`.
|
| 2 |
|
| 3 |
+
Phase B du chantier de refonte en 3 cercles (architecture-cercles.md).
|
| 4 |
+
Ce module philologique est désormais en Cercle 3 (``extras/``). L'alias
|
| 5 |
+
ici permet aux imports historiques (``from picarones.core.philological_runner
|
| 6 |
+
import ...``) de continuer à fonctionner sans modification.
|
| 7 |
|
| 8 |
+
Voir :doc:`docs/architecture-cercles.md` et l'extra
|
| 9 |
+
``picarones[historical]`` du ``pyproject.toml``.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
"""
|
| 11 |
|
| 12 |
+
from picarones.extras.historical.philological_runner import * # noqa: F401, F403
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
+
import picarones.extras.historical.philological_runner as _module
|
| 15 |
+
__all__ = getattr(_module, "__all__", [
|
| 16 |
+
name for name in dir(_module) if not name.startswith("_")
|
| 17 |
+
])
|
|
@@ -1,478 +1,17 @@
|
|
| 1 |
-
"""
|
| 2 |
|
| 3 |
-
|
| 4 |
-
|
|
|
|
|
|
|
| 5 |
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
Les numéraux romains traversent **toutes les périodes patrimoniales**
|
| 9 |
-
servies par Picarones :
|
| 10 |
-
|
| 11 |
-
- **Médiéval** : minuscules avec ``j`` final pour le dernier ``i``
|
| 12 |
-
(``ij`` = 2, ``iij`` = 3, ``viij`` = 8, ``mcclxxxij`` = 1282).
|
| 13 |
-
Convention scribale standard dans les chartes et registres.
|
| 14 |
-
- **Imprimé ancien** : majuscules (``Tome IV``, ``Chap. VII``).
|
| 15 |
-
- **Moderne** : majuscules pour les souverains (``Louis XIV``) et
|
| 16 |
-
les siècles (``XIXᵉ siècle`` — la partie exposant ᵉ est gérée
|
| 17 |
-
par le Sprint 59 ``ordinals``, ce module ne traite que la partie
|
| 18 |
-
numérale ``XIX``).
|
| 19 |
-
|
| 20 |
-
Quatre traitements possibles d'un numéral par l'OCR
|
| 21 |
-
----------------------------------------------------
|
| 22 |
-
Pour chaque numéral romain présent dans la GT, l'OCR peut :
|
| 23 |
-
|
| 24 |
-
1. **Préserver strictement** : forme exacte gardée
|
| 25 |
-
(``mcclxxxij`` → ``mcclxxxij``). Édition diplomatique idéale.
|
| 26 |
-
2. **Préserver en changeant la casse** : la valeur est intacte mais
|
| 27 |
-
la convention typographique est modifiée
|
| 28 |
-
(``xiv`` → ``XIV``). Modernisation typographique courante.
|
| 29 |
-
3. **Préserver en supprimant le ``j`` final** :
|
| 30 |
-
(``mcclxxxij`` → ``mcclxxxii``). Modernisation orthographique
|
| 31 |
-
médiévale → standard académique moderne.
|
| 32 |
-
4. **Convertir en chiffres arabes** : la valeur est préservée mais
|
| 33 |
-
le système de numération est modernisé
|
| 34 |
-
(``XIV`` → ``14``). Modernisation profonde, perte de
|
| 35 |
-
l'information typographique.
|
| 36 |
-
5. **Perdre** : aucune trace de la valeur dans l'hypothèse.
|
| 37 |
-
|
| 38 |
-
Ce module retourne un breakdown par statut pour que le chercheur
|
| 39 |
-
juge lui-même la convention adoptée par chaque moteur, **sans
|
| 40 |
-
classification automatique imposée**.
|
| 41 |
-
|
| 42 |
-
Stratégie de découpage
|
| 43 |
-
----------------------
|
| 44 |
-
Cohérente avec NER (38), Flesch (52), Reading order F1 (53),
|
| 45 |
-
Layout F1 (54), Bloc Unicode (55), Abréviations (56), MUFI (57),
|
| 46 |
-
Imprimé ancien (58), Archives modernes (59) : couche de calcul
|
| 47 |
-
pure d'abord ; câblage runner et HTML dans des sprints dédiés.
|
| 48 |
-
|
| 49 |
-
Limites documentées
|
| 50 |
-
-------------------
|
| 51 |
-
- Détection greedy par regex ``\\b[IVXLCDMivxlcdmj]+\\b`` puis
|
| 52 |
-
validation par parsing. Les faux positifs restent possibles sur
|
| 53 |
-
des mots courts (``I`` pronom anglais, ``MM`` initiales, ``LL``).
|
| 54 |
-
Le paramètre ``min_length`` permet de filtrer les single-letter.
|
| 55 |
-
- Pas de gestion des notations rares avec barre suscript pour
|
| 56 |
-
multiplier par 1000 (V̄ = 5000, X̄ = 10000) — usage très rare en
|
| 57 |
-
corpus patrimonial européen courant.
|
| 58 |
"""
|
| 59 |
|
| 60 |
-
from
|
| 61 |
-
|
| 62 |
-
import logging
|
| 63 |
-
import re
|
| 64 |
-
from typing import Optional
|
| 65 |
-
|
| 66 |
-
from picarones.core.metric_registry import register_metric
|
| 67 |
-
from picarones.core.modules import ArtifactType
|
| 68 |
-
|
| 69 |
-
logger = logging.getLogger(__name__)
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 73 |
-
# Table de conversion + parsing
|
| 74 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 75 |
-
|
| 76 |
-
ROMAN_VALUES: dict[str, int] = {
|
| 77 |
-
"I": 1, "V": 5, "X": 10,
|
| 78 |
-
"L": 50, "C": 100, "D": 500, "M": 1000,
|
| 79 |
-
}
|
| 80 |
-
|
| 81 |
-
# Caractères acceptés en entrée (incluant minuscules + j médiéval).
|
| 82 |
-
_ROMAN_CHARS = "IVXLCDMivxlcdmj"
|
| 83 |
-
_ROMAN_RE = re.compile(rf"\b[{_ROMAN_CHARS}]+\b")
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
def _normalize_roman(s: str) -> str:
|
| 87 |
-
"""Normalise un numéral romain : majuscule + ``j`` final → ``i``.
|
| 88 |
-
|
| 89 |
-
Les manuscrits médiévaux notent traditionnellement le dernier
|
| 90 |
-
``i`` d'une suite par ``j`` (« ij », « iij », « viij »…). On
|
| 91 |
-
convertit pour pouvoir parser comme un numéral standard.
|
| 92 |
-
"""
|
| 93 |
-
if not s:
|
| 94 |
-
return ""
|
| 95 |
-
upper = s.upper()
|
| 96 |
-
if upper.endswith("J"):
|
| 97 |
-
upper = upper[:-1] + "I"
|
| 98 |
-
return upper
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
def _parse_normalized_roman(s: str) -> Optional[int]:
|
| 102 |
-
"""Parse un numéral romain **après normalisation** (majuscule,
|
| 103 |
-
sans ``j`` médiéval). Retourne ``None`` si la chaîne n'est pas
|
| 104 |
-
un numéral romain valide.
|
| 105 |
-
|
| 106 |
-
Validation : on parse en additionnant/soustrayant selon la règle
|
| 107 |
-
classique, puis on **regénère la forme standard** et on compare
|
| 108 |
-
pour rejeter les formes non canoniques (« IIII » au lieu de
|
| 109 |
-
« IV », « VV » au lieu de « X »). Cette stricte validation
|
| 110 |
-
garantit qu'on ne compte pas des séquences absurdes comme
|
| 111 |
-
« XXXX » comme un numéral.
|
| 112 |
-
|
| 113 |
-
Note : les manuscrits médiévaux utilisent fréquemment « IIII »
|
| 114 |
-
pour 4 (notation soustractive plus tardive). On accepte donc
|
| 115 |
-
aussi cette forme via une règle relâchée : tant que les valeurs
|
| 116 |
-
sont décroissantes ou suivent la règle soustractive standard,
|
| 117 |
-
on accepte.
|
| 118 |
-
"""
|
| 119 |
-
if not s or not all(c in "IVXLCDM" for c in s):
|
| 120 |
-
return None
|
| 121 |
-
# Calcul par soustraction.
|
| 122 |
-
total = 0
|
| 123 |
-
prev_value = 0
|
| 124 |
-
for ch in reversed(s):
|
| 125 |
-
v = ROMAN_VALUES[ch]
|
| 126 |
-
if v < prev_value:
|
| 127 |
-
total -= v
|
| 128 |
-
else:
|
| 129 |
-
total += v
|
| 130 |
-
prev_value = v
|
| 131 |
-
if total <= 0:
|
| 132 |
-
return None
|
| 133 |
-
# Validation relâchée : on accepte les formes médiévales (IIII,
|
| 134 |
-
# VIIII) mais on rejette les vraiment absurdes (IIIII, VVVV).
|
| 135 |
-
if not _is_plausible_roman(s):
|
| 136 |
-
return None
|
| 137 |
-
return total
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
def _is_plausible_roman(s: str) -> bool:
|
| 141 |
-
"""Validation relâchée d'un numéral romain (majuscule).
|
| 142 |
-
|
| 143 |
-
On rejette :
|
| 144 |
-
|
| 145 |
-
- 5 caractères identiques d'affilée ou plus (« IIIII », « XXXXX »).
|
| 146 |
-
- Les répétitions de V, L, D (jamais répétés en notation
|
| 147 |
-
classique : « VV », « LL », « DD »).
|
| 148 |
-
- Les paires soustractives non standard. En romain canonique,
|
| 149 |
-
seules sont valides : IV, IX, XL, XC, CD, CM. Toute autre
|
| 150 |
-
combinaison « petit avant grand » est rejetée. Cela élimine
|
| 151 |
-
les faux positifs sur des mots français comme « ici » (qui
|
| 152 |
-
formerait sinon « I + C » = 99) ou « IL » qui formerait 49.
|
| 153 |
-
"""
|
| 154 |
-
if not s:
|
| 155 |
-
return False
|
| 156 |
-
# Pas de répétitions invalides
|
| 157 |
-
for forbidden in ("VV", "LL", "DD", "IIIII", "XXXXX", "CCCCC", "MMMMMM"):
|
| 158 |
-
if forbidden in s:
|
| 159 |
-
return False
|
| 160 |
-
# Paires soustractives autorisées (toutes les autres sont rejetées)
|
| 161 |
-
legal_subtractive = {"IV", "IX", "XL", "XC", "CD", "CM"}
|
| 162 |
-
for i in range(len(s) - 1):
|
| 163 |
-
a, b = s[i], s[i + 1]
|
| 164 |
-
if ROMAN_VALUES[a] < ROMAN_VALUES[b]:
|
| 165 |
-
if (a + b) not in legal_subtractive:
|
| 166 |
-
return False
|
| 167 |
-
return True
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
def roman_to_int(s: Optional[str]) -> Optional[int]:
|
| 171 |
-
"""Convertit une chaîne en numéral romain entier. Tolère casse
|
| 172 |
-
et ``j`` médiéval final. Retourne ``None`` si invalide.
|
| 173 |
-
"""
|
| 174 |
-
if not s:
|
| 175 |
-
return None
|
| 176 |
-
return _parse_normalized_roman(_normalize_roman(s))
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
def int_to_roman(n: int) -> str:
|
| 180 |
-
"""Convertit un entier en numéral romain majuscule standard.
|
| 181 |
-
|
| 182 |
-
Utilise la notation classique (IV, IX, XL, XC, CD, CM) — pas la
|
| 183 |
-
forme médiévale relâchée.
|
| 184 |
-
"""
|
| 185 |
-
if n <= 0:
|
| 186 |
-
raise ValueError("n must be positive")
|
| 187 |
-
pairs = [
|
| 188 |
-
(1000, "M"), (900, "CM"), (500, "D"), (400, "CD"),
|
| 189 |
-
(100, "C"), (90, "XC"), (50, "L"), (40, "XL"),
|
| 190 |
-
(10, "X"), (9, "IX"), (5, "V"), (4, "IV"),
|
| 191 |
-
(1, "I"),
|
| 192 |
-
]
|
| 193 |
-
out: list[str] = []
|
| 194 |
-
for value, symbol in pairs:
|
| 195 |
-
while n >= value:
|
| 196 |
-
out.append(symbol)
|
| 197 |
-
n -= value
|
| 198 |
-
return "".join(out)
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 202 |
-
# Détection dans le texte
|
| 203 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
def detect_roman_numerals(
|
| 207 |
-
text: Optional[str],
|
| 208 |
-
*,
|
| 209 |
-
min_length: int = 1,
|
| 210 |
-
) -> list[tuple[int, str, int]]:
|
| 211 |
-
"""Retourne les numéraux romains valides dans ``text``.
|
| 212 |
-
|
| 213 |
-
Forme : ``[(start_index, numeral_string, integer_value), ...]``
|
| 214 |
-
triée par index croissant.
|
| 215 |
-
|
| 216 |
-
Parameters
|
| 217 |
-
----------
|
| 218 |
-
text:
|
| 219 |
-
Texte à analyser.
|
| 220 |
-
min_length:
|
| 221 |
-
Longueur minimale d'un numéral retenu. Par défaut ``1``.
|
| 222 |
-
Mettre à ``2`` pour filtrer les single-letter ambigus (``I``
|
| 223 |
-
pronom, ``M`` initiale).
|
| 224 |
-
|
| 225 |
-
Faux positifs connus
|
| 226 |
-
--------------------
|
| 227 |
-
- ``I`` (pronom anglais), ``M`` ou ``D`` en initiale d'une
|
| 228 |
-
personne ne peuvent pas être distingués sans NER. Le chercheur
|
| 229 |
-
qui s'inquiète de ces faux positifs peut passer
|
| 230 |
-
``min_length=2``.
|
| 231 |
-
"""
|
| 232 |
-
if not text:
|
| 233 |
-
return []
|
| 234 |
-
found: list[tuple[int, str, int]] = []
|
| 235 |
-
for match in _ROMAN_RE.finditer(text):
|
| 236 |
-
s = match.group(0)
|
| 237 |
-
if len(s) < min_length:
|
| 238 |
-
continue
|
| 239 |
-
value = roman_to_int(s)
|
| 240 |
-
if value is None:
|
| 241 |
-
continue
|
| 242 |
-
found.append((match.start(), s, value))
|
| 243 |
-
return found
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
# ──────────────────────────────────────────────────────────────���───────────
|
| 247 |
-
# Classification de la restitution dans l'hypothèse
|
| 248 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 249 |
-
|
| 250 |
-
# Statuts possibles, dans l'ordre de priorité (un numéral est
|
| 251 |
-
# classé selon le premier statut qui s'applique).
|
| 252 |
-
|
| 253 |
-
STATUS_STRICT_PRESERVED = "strict_preserved"
|
| 254 |
-
STATUS_CASE_CHANGED = "case_changed"
|
| 255 |
-
STATUS_J_DROPPED = "j_dropped"
|
| 256 |
-
STATUS_CONVERTED_TO_ARABIC = "converted_to_arabic"
|
| 257 |
-
STATUS_LOST = "lost"
|
| 258 |
-
|
| 259 |
-
ALL_STATUSES = (
|
| 260 |
-
STATUS_STRICT_PRESERVED,
|
| 261 |
-
STATUS_CASE_CHANGED,
|
| 262 |
-
STATUS_J_DROPPED,
|
| 263 |
-
STATUS_CONVERTED_TO_ARABIC,
|
| 264 |
-
STATUS_LOST,
|
| 265 |
-
)
|
| 266 |
-
|
| 267 |
-
# Statuts qui indiquent une préservation de la valeur (par opposition
|
| 268 |
-
# à la perte).
|
| 269 |
-
VALUE_PRESERVING_STATUSES = frozenset({
|
| 270 |
-
STATUS_STRICT_PRESERVED,
|
| 271 |
-
STATUS_CASE_CHANGED,
|
| 272 |
-
STATUS_J_DROPPED,
|
| 273 |
-
STATUS_CONVERTED_TO_ARABIC,
|
| 274 |
-
})
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
def _classify_restitution(numeral: str, value: int, hyp: str) -> str:
|
| 278 |
-
"""Classifie comment ``numeral`` (de valeur ``value``) est
|
| 279 |
-
restitué dans ``hyp`` selon les 5 statuts définis."""
|
| 280 |
-
# 1. Forme stricte présente
|
| 281 |
-
if re.search(r"(?<![A-Za-z])" + re.escape(numeral) + r"(?![A-Za-z])", hyp):
|
| 282 |
-
return STATUS_STRICT_PRESERVED
|
| 283 |
-
# 2. Variante de casse seule
|
| 284 |
-
swapped = numeral.swapcase()
|
| 285 |
-
if swapped != numeral and re.search(
|
| 286 |
-
r"(?<![A-Za-z])" + re.escape(swapped) + r"(?![A-Za-z])", hyp,
|
| 287 |
-
):
|
| 288 |
-
return STATUS_CASE_CHANGED
|
| 289 |
-
# 3. ``j`` final remplacé par ``i`` (ou inverse)
|
| 290 |
-
if numeral.lower().endswith("j"):
|
| 291 |
-
no_j = numeral[:-1] + ("I" if numeral[-1] == "J" else "i")
|
| 292 |
-
elif numeral.lower().endswith("i"):
|
| 293 |
-
no_j = numeral[:-1] + ("J" if numeral[-1] == "I" else "j")
|
| 294 |
-
else:
|
| 295 |
-
no_j = numeral
|
| 296 |
-
if no_j != numeral and re.search(
|
| 297 |
-
r"(?<![A-Za-z])" + re.escape(no_j) + r"(?![A-Za-z])", hyp,
|
| 298 |
-
):
|
| 299 |
-
return STATUS_J_DROPPED
|
| 300 |
-
# Variante de casse + j-flip combinés
|
| 301 |
-
no_j_swapped = no_j.swapcase()
|
| 302 |
-
if no_j_swapped != numeral and re.search(
|
| 303 |
-
r"(?<![A-Za-z])" + re.escape(no_j_swapped) + r"(?![A-Za-z])", hyp,
|
| 304 |
-
):
|
| 305 |
-
return STATUS_J_DROPPED
|
| 306 |
-
# 4. Conversion en chiffres arabes
|
| 307 |
-
if re.search(r"(?<!\d)" + str(value) + r"(?!\d)", hyp):
|
| 308 |
-
return STATUS_CONVERTED_TO_ARABIC
|
| 309 |
-
# 5. Perdu
|
| 310 |
-
return STATUS_LOST
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 314 |
-
# Calcul de la métrique
|
| 315 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
def compute_roman_numeral_metrics(
|
| 319 |
-
reference: Optional[str],
|
| 320 |
-
hypothesis: Optional[str],
|
| 321 |
-
*,
|
| 322 |
-
min_length: int = 1,
|
| 323 |
-
) -> dict:
|
| 324 |
-
"""Calcule la préservation des numéraux romains.
|
| 325 |
-
|
| 326 |
-
Pour chaque numéral romain dans la GT, on classifie sa
|
| 327 |
-
restitution dans l'hypothèse selon l'un des 5 statuts (forme
|
| 328 |
-
stricte / casse modifiée / j supprimé / conversion arabe / perdu).
|
| 329 |
-
|
| 330 |
-
Returns
|
| 331 |
-
-------
|
| 332 |
-
dict
|
| 333 |
-
``{
|
| 334 |
-
"n_numerals_reference": int,
|
| 335 |
-
"n_strict_preserved": int,
|
| 336 |
-
"n_value_preserved": int, # tous statuts sauf LOST
|
| 337 |
-
"global_strict_score": float,
|
| 338 |
-
"global_value_score": float,
|
| 339 |
-
"per_status": {status: count for status in ALL_STATUSES},
|
| 340 |
-
"per_numeral": [
|
| 341 |
-
{"index", "numeral", "value", "status"}
|
| 342 |
-
],
|
| 343 |
-
"lost_numerals": [
|
| 344 |
-
{"index", "numeral", "value"}
|
| 345 |
-
],
|
| 346 |
-
}``
|
| 347 |
-
|
| 348 |
-
Cas dégénérés
|
| 349 |
-
-------------
|
| 350 |
-
- GT vide ou sans numéral → tous compteurs à 0, scores à 0.0,
|
| 351 |
-
``per_status`` initialisé à 0 sur tous les statuts.
|
| 352 |
-
- GT avec numéraux + hyp vide → tous classés ``lost``,
|
| 353 |
-
strict_score = value_score = 0.0.
|
| 354 |
-
"""
|
| 355 |
-
ref = reference or ""
|
| 356 |
-
hyp = hypothesis or ""
|
| 357 |
-
|
| 358 |
-
detected = detect_roman_numerals(ref, min_length=min_length)
|
| 359 |
-
n_total = len(detected)
|
| 360 |
-
per_status_init = {status: 0 for status in ALL_STATUSES}
|
| 361 |
-
|
| 362 |
-
if n_total == 0:
|
| 363 |
-
return {
|
| 364 |
-
"n_numerals_reference": 0,
|
| 365 |
-
"n_strict_preserved": 0,
|
| 366 |
-
"n_value_preserved": 0,
|
| 367 |
-
"global_strict_score": 0.0,
|
| 368 |
-
"global_value_score": 0.0,
|
| 369 |
-
"per_status": per_status_init,
|
| 370 |
-
"per_numeral": [],
|
| 371 |
-
"lost_numerals": [],
|
| 372 |
-
}
|
| 373 |
-
|
| 374 |
-
per_status: dict[str, int] = dict(per_status_init)
|
| 375 |
-
per_numeral: list[dict] = []
|
| 376 |
-
lost: list[dict] = []
|
| 377 |
-
for index, numeral, value in detected:
|
| 378 |
-
status = _classify_restitution(numeral, value, hyp)
|
| 379 |
-
per_status[status] = per_status.get(status, 0) + 1
|
| 380 |
-
per_numeral.append({
|
| 381 |
-
"index": index,
|
| 382 |
-
"numeral": numeral,
|
| 383 |
-
"value": value,
|
| 384 |
-
"status": status,
|
| 385 |
-
})
|
| 386 |
-
if status == STATUS_LOST:
|
| 387 |
-
lost.append({"index": index, "numeral": numeral, "value": value})
|
| 388 |
-
|
| 389 |
-
n_strict = per_status[STATUS_STRICT_PRESERVED]
|
| 390 |
-
n_value = sum(per_status[s] for s in VALUE_PRESERVING_STATUSES)
|
| 391 |
-
|
| 392 |
-
return {
|
| 393 |
-
"n_numerals_reference": n_total,
|
| 394 |
-
"n_strict_preserved": n_strict,
|
| 395 |
-
"n_value_preserved": n_value,
|
| 396 |
-
"global_strict_score": n_strict / n_total,
|
| 397 |
-
"global_value_score": n_value / n_total,
|
| 398 |
-
"per_status": per_status,
|
| 399 |
-
"per_numeral": per_numeral,
|
| 400 |
-
"lost_numerals": lost,
|
| 401 |
-
}
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
def roman_numeral_strict_score(
|
| 405 |
-
reference: Optional[str], hypothesis: Optional[str],
|
| 406 |
-
) -> float:
|
| 407 |
-
"""Raccourci : taux global de préservation **stricte** des
|
| 408 |
-
numéraux romains ∈ [0, 1]."""
|
| 409 |
-
return compute_roman_numeral_metrics(
|
| 410 |
-
reference, hypothesis,
|
| 411 |
-
)["global_strict_score"]
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
def roman_numeral_value_score(
|
| 415 |
-
reference: Optional[str], hypothesis: Optional[str],
|
| 416 |
-
) -> float:
|
| 417 |
-
"""Raccourci : taux global de préservation de la **valeur** des
|
| 418 |
-
numéraux romains (toute forme confondue : strict, case_changed,
|
| 419 |
-
j_dropped, arabe) ∈ [0, 1]."""
|
| 420 |
-
return compute_roman_numeral_metrics(
|
| 421 |
-
reference, hypothesis,
|
| 422 |
-
)["global_value_score"]
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 426 |
-
# Enregistrement dans le registre typé (Sprint 34)
|
| 427 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
@register_metric(
|
| 431 |
-
name="roman_numeral_strict_score",
|
| 432 |
-
input_types=(ArtifactType.TEXT, ArtifactType.TEXT),
|
| 433 |
-
description=(
|
| 434 |
-
"Taux de préservation stricte des numéraux romains "
|
| 435 |
-
"(forme exacte gardée : casse, j médiéval final). "
|
| 436 |
-
"Métrique transversale aux périodes médiévale, imprimé "
|
| 437 |
-
"ancien et moderne."
|
| 438 |
-
),
|
| 439 |
-
higher_is_better=True,
|
| 440 |
-
tags={"text", "roman_numerals", "philology"},
|
| 441 |
-
)
|
| 442 |
-
def _registered_strict(reference: str, hypothesis: str) -> float:
|
| 443 |
-
return roman_numeral_strict_score(reference, hypothesis)
|
| 444 |
-
|
| 445 |
-
|
| 446 |
-
@register_metric(
|
| 447 |
-
name="roman_numeral_value_score",
|
| 448 |
-
input_types=(ArtifactType.TEXT, ArtifactType.TEXT),
|
| 449 |
-
description=(
|
| 450 |
-
"Taux de préservation de la valeur numérique des numéraux "
|
| 451 |
-
"romains, indépendamment de la forme (strict, casse "
|
| 452 |
-
"changée, j supprimé, conversion en chiffres arabes). "
|
| 453 |
-
"Le breakdown per_status permet au chercheur de juger la "
|
| 454 |
-
"convention adoptée."
|
| 455 |
-
),
|
| 456 |
-
higher_is_better=True,
|
| 457 |
-
tags={"text", "roman_numerals", "philology"},
|
| 458 |
-
)
|
| 459 |
-
def _registered_value(reference: str, hypothesis: str) -> float:
|
| 460 |
-
return roman_numeral_value_score(reference, hypothesis)
|
| 461 |
-
|
| 462 |
|
| 463 |
-
|
| 464 |
-
|
| 465 |
-
"
|
| 466 |
-
|
| 467 |
-
"STATUS_CASE_CHANGED",
|
| 468 |
-
"STATUS_J_DROPPED",
|
| 469 |
-
"STATUS_CONVERTED_TO_ARABIC",
|
| 470 |
-
"STATUS_LOST",
|
| 471 |
-
"VALUE_PRESERVING_STATUSES",
|
| 472 |
-
"compute_roman_numeral_metrics",
|
| 473 |
-
"detect_roman_numerals",
|
| 474 |
-
"int_to_roman",
|
| 475 |
-
"roman_numeral_strict_score",
|
| 476 |
-
"roman_numeral_value_score",
|
| 477 |
-
"roman_to_int",
|
| 478 |
-
]
|
|
|
|
| 1 |
+
"""Alias rétrocompat — module déplacé dans :mod:`picarones.extras.historical.roman_numerals`.
|
| 2 |
|
| 3 |
+
Phase B du chantier de refonte en 3 cercles (architecture-cercles.md).
|
| 4 |
+
Ce module philologique est désormais en Cercle 3 (``extras/``). L'alias
|
| 5 |
+
ici permet aux imports historiques (``from picarones.core.roman_numerals
|
| 6 |
+
import ...``) de continuer à fonctionner sans modification.
|
| 7 |
|
| 8 |
+
Voir :doc:`docs/architecture-cercles.md` et l'extra
|
| 9 |
+
``picarones[historical]`` du ``pyproject.toml``.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
"""
|
| 11 |
|
| 12 |
+
from picarones.extras.historical.roman_numerals import * # noqa: F401, F403
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
+
import picarones.extras.historical.roman_numerals as _module
|
| 15 |
+
__all__ = getattr(_module, "__all__", [
|
| 16 |
+
name for name in dir(_module) if not name.startswith("_")
|
| 17 |
+
])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -1,233 +1,17 @@
|
|
| 1 |
-
"""
|
| 2 |
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
| 4 |
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
Pour un éditeur d'imprimés anciens ou un médiéviste, la question
|
| 8 |
-
n'est pas seulement *« quel CER global ? »* mais *« quels caractères
|
| 9 |
-
historiques ce moteur restitue-t-il fidèlement ? »*. Une phrase de
|
| 10 |
-
synthèse actionnable en un coup d'œil :
|
| 11 |
-
|
| 12 |
-
> *« GPT-4o restitue 95 % du Latin de Base mais seulement 12 % des
|
| 13 |
-
> formes de présentation latine (fi, fl, ſ…). »*
|
| 14 |
-
|
| 15 |
-
Ce module agrège la précision par **bloc Unicode standard** (Latin de
|
| 16 |
-
Base, Latin Étendu A/B, Diacritiques combinants, Présentation latine,
|
| 17 |
-
etc.). Le résultat permet directement de choisir un moteur selon le
|
| 18 |
-
type de glyphes attendus dans le corpus.
|
| 19 |
-
|
| 20 |
-
Stratégie de découpage
|
| 21 |
-
----------------------
|
| 22 |
-
Cohérente avec NER (Sprint 38), Flesch (Sprint 52), Reading order F1
|
| 23 |
-
(Sprint 53), Layout F1 (Sprint 54) : couche de calcul pure d'abord.
|
| 24 |
-
Le câblage runner et la vue HTML suivent dans des sprints dédiés.
|
| 25 |
-
|
| 26 |
-
Convention d'alignement
|
| 27 |
-
-----------------------
|
| 28 |
-
Alignement caractère par caractère via ``difflib.SequenceMatcher`` :
|
| 29 |
-
|
| 30 |
-
- chaque caractère de la GT est classé dans son bloc Unicode,
|
| 31 |
-
- pour chaque position GT couverte par un opcode ``equal`` →
|
| 32 |
-
+1 dans ``correct[bloc]``,
|
| 33 |
-
- pour chaque position GT non couverte (replace, delete) → +0,
|
| 34 |
-
- les insertions côté hypothèse (caractères absents de la GT) ne
|
| 35 |
-
contribuent à aucun bloc — elles sont visibles uniquement via le
|
| 36 |
-
CER global.
|
| 37 |
-
|
| 38 |
-
Précision par bloc = ``correct[bloc] / total[bloc]``.
|
| 39 |
-
|
| 40 |
-
Liste des blocs reconnus
|
| 41 |
-
------------------------
|
| 42 |
-
Centrée sur les glyphes courants des corpus patrimoniaux européens.
|
| 43 |
-
Tout caractère hors de cette table est classé dans ``"Other"``
|
| 44 |
-
(garantit une couverture exhaustive : ``sum(total[bloc]) ==
|
| 45 |
-
len(GT)``).
|
| 46 |
"""
|
| 47 |
|
| 48 |
-
from
|
| 49 |
-
|
| 50 |
-
import logging
|
| 51 |
-
from difflib import SequenceMatcher
|
| 52 |
-
from typing import Optional
|
| 53 |
-
|
| 54 |
-
from picarones.core.metric_registry import register_metric
|
| 55 |
-
from picarones.core.modules import ArtifactType
|
| 56 |
-
|
| 57 |
-
logger = logging.getLogger(__name__)
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 61 |
-
# Table des blocs Unicode reconnus
|
| 62 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 63 |
-
|
| 64 |
-
# Triplets (nom, code_point_min, code_point_max) — bornes inclusives.
|
| 65 |
-
# Centré sur les blocs pertinents pour les corpus patrimoniaux
|
| 66 |
-
# européens (manuscrits médiévaux, imprimés anciens, archives).
|
| 67 |
-
# Source : https://www.unicode.org/charts/
|
| 68 |
-
_UNICODE_BLOCKS: tuple[tuple[str, int, int], ...] = (
|
| 69 |
-
("Basic Latin", 0x0000, 0x007F),
|
| 70 |
-
("Latin-1 Supplement", 0x0080, 0x00FF),
|
| 71 |
-
("Latin Extended-A", 0x0100, 0x017F),
|
| 72 |
-
("Latin Extended-B", 0x0180, 0x024F),
|
| 73 |
-
("IPA Extensions", 0x0250, 0x02AF),
|
| 74 |
-
("Spacing Modifier Letters", 0x02B0, 0x02FF),
|
| 75 |
-
("Combining Diacritical Marks", 0x0300, 0x036F),
|
| 76 |
-
("Greek and Coptic", 0x0370, 0x03FF),
|
| 77 |
-
("Cyrillic", 0x0400, 0x04FF),
|
| 78 |
-
("Hebrew", 0x0590, 0x05FF),
|
| 79 |
-
("Arabic", 0x0600, 0x06FF),
|
| 80 |
-
("General Punctuation", 0x2000, 0x206F),
|
| 81 |
-
("Superscripts and Subscripts", 0x2070, 0x209F),
|
| 82 |
-
("Currency Symbols", 0x20A0, 0x20CF),
|
| 83 |
-
("Combining Diacritical Marks Supplement", 0x1DC0, 0x1DFF),
|
| 84 |
-
("Latin Extended Additional", 0x1E00, 0x1EFF),
|
| 85 |
-
("Latin Extended-C", 0x2C60, 0x2C7F),
|
| 86 |
-
("Latin Extended-D", 0xA720, 0xA7FF), # médiéval
|
| 87 |
-
("Latin Extended-E", 0xAB30, 0xAB6F),
|
| 88 |
-
("Alphabetic Presentation Forms", 0xFB00, 0xFB4F), # fi, fl, ff…
|
| 89 |
-
("Mathematical Alphanumeric Symbols", 0x1D400, 0x1D7FF),
|
| 90 |
-
("Medieval Unicode Font Initiative (MUFI)", 0xE000, 0xF8FF), # PUA
|
| 91 |
-
)
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
def get_block(char: str) -> str:
|
| 95 |
-
"""Retourne le nom du bloc Unicode contenant ``char``.
|
| 96 |
-
|
| 97 |
-
Pour un caractère hors des blocs listés (ex. CJK, emoji, etc.),
|
| 98 |
-
retourne ``"Other"``. Pour une chaîne multi-caractères, on
|
| 99 |
-
considère uniquement le premier code-point.
|
| 100 |
-
"""
|
| 101 |
-
if not char:
|
| 102 |
-
return "Other"
|
| 103 |
-
cp = ord(char[0])
|
| 104 |
-
for name, lo, hi in _UNICODE_BLOCKS:
|
| 105 |
-
if lo <= cp <= hi:
|
| 106 |
-
return name
|
| 107 |
-
return "Other"
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 111 |
-
# Calcul d'accuracy par bloc
|
| 112 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
def compute_unicode_block_accuracy(
|
| 116 |
-
reference: Optional[str],
|
| 117 |
-
hypothesis: Optional[str],
|
| 118 |
-
) -> dict:
|
| 119 |
-
"""Calcule la précision (recall caractère) par bloc Unicode.
|
| 120 |
-
|
| 121 |
-
Parameters
|
| 122 |
-
----------
|
| 123 |
-
reference:
|
| 124 |
-
Texte GT. Chaque caractère est classé dans son bloc Unicode.
|
| 125 |
-
hypothesis:
|
| 126 |
-
Texte produit par le moteur OCR.
|
| 127 |
-
|
| 128 |
-
Returns
|
| 129 |
-
-------
|
| 130 |
-
dict
|
| 131 |
-
``{
|
| 132 |
-
"per_block": {
|
| 133 |
-
bloc_name: {
|
| 134 |
-
"correct": int, # caractères GT correctement restitués
|
| 135 |
-
"total": int, # caractères GT du bloc
|
| 136 |
-
"accuracy": float, # correct / total ∈ [0, 1]
|
| 137 |
-
},
|
| 138 |
-
...
|
| 139 |
-
},
|
| 140 |
-
"global_accuracy": float, # somme(correct) / somme(total)
|
| 141 |
-
"n_chars_reference": int,
|
| 142 |
-
}``
|
| 143 |
-
|
| 144 |
-
Cas dégénérés
|
| 145 |
-
-------------
|
| 146 |
-
- GT vide → ``per_block`` vide, ``global_accuracy = 0.0``,
|
| 147 |
-
``n_chars_reference = 0``.
|
| 148 |
-
- hypothèse vide + GT non-vide → tous les blocs à
|
| 149 |
-
``accuracy = 0``.
|
| 150 |
-
- GT et hyp identiques → tous les blocs à ``accuracy = 1``.
|
| 151 |
-
"""
|
| 152 |
-
ref = reference or ""
|
| 153 |
-
hyp = hypothesis or ""
|
| 154 |
-
n_ref = len(ref)
|
| 155 |
-
|
| 156 |
-
if n_ref == 0:
|
| 157 |
-
return {
|
| 158 |
-
"per_block": {},
|
| 159 |
-
"global_accuracy": 0.0,
|
| 160 |
-
"n_chars_reference": 0,
|
| 161 |
-
}
|
| 162 |
-
|
| 163 |
-
# 1. Compter le total par bloc
|
| 164 |
-
total: dict[str, int] = {}
|
| 165 |
-
for ch in ref:
|
| 166 |
-
b = get_block(ch)
|
| 167 |
-
total[b] = total.get(b, 0) + 1
|
| 168 |
-
|
| 169 |
-
# 2. Aligner par opcodes de SequenceMatcher
|
| 170 |
-
# Pour chaque opcode ``equal``, les positions ``i1..i2-1`` du GT
|
| 171 |
-
# sont correctement restituées → +1 par caractère dans son bloc.
|
| 172 |
-
correct: dict[str, int] = {b: 0 for b in total}
|
| 173 |
-
matcher = SequenceMatcher(a=ref, b=hyp, autojunk=False)
|
| 174 |
-
for op, i1, i2, _j1, _j2 in matcher.get_opcodes():
|
| 175 |
-
if op != "equal":
|
| 176 |
-
continue
|
| 177 |
-
for i in range(i1, i2):
|
| 178 |
-
b = get_block(ref[i])
|
| 179 |
-
correct[b] = correct.get(b, 0) + 1
|
| 180 |
-
|
| 181 |
-
per_block: dict[str, dict] = {}
|
| 182 |
-
for b in sorted(total):
|
| 183 |
-
n = total[b]
|
| 184 |
-
c = correct.get(b, 0)
|
| 185 |
-
per_block[b] = {
|
| 186 |
-
"correct": c,
|
| 187 |
-
"total": n,
|
| 188 |
-
"accuracy": c / n if n > 0 else 0.0,
|
| 189 |
-
}
|
| 190 |
-
|
| 191 |
-
n_correct_total = sum(d["correct"] for d in per_block.values())
|
| 192 |
-
return {
|
| 193 |
-
"per_block": per_block,
|
| 194 |
-
"global_accuracy": n_correct_total / n_ref,
|
| 195 |
-
"n_chars_reference": n_ref,
|
| 196 |
-
}
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
def unicode_block_global_accuracy(
|
| 200 |
-
reference: Optional[str],
|
| 201 |
-
hypothesis: Optional[str],
|
| 202 |
-
) -> float:
|
| 203 |
-
"""Raccourci : retourne ``global_accuracy`` (fraction de
|
| 204 |
-
caractères GT correctement restitués)."""
|
| 205 |
-
return compute_unicode_block_accuracy(reference, hypothesis)["global_accuracy"]
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 209 |
-
# Enregistrement dans le registre typé (Sprint 34)
|
| 210 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
@register_metric(
|
| 214 |
-
name="unicode_block_global_accuracy",
|
| 215 |
-
input_types=(ArtifactType.TEXT, ArtifactType.TEXT),
|
| 216 |
-
description=(
|
| 217 |
-
"Fraction de caractères GT correctement restitués par "
|
| 218 |
-
"l'OCR (alignement caractère par caractère via difflib). "
|
| 219 |
-
"Pour le détail par bloc Unicode (Latin de Base, Présentation "
|
| 220 |
-
"latine, etc.), utiliser compute_unicode_block_accuracy."
|
| 221 |
-
),
|
| 222 |
-
higher_is_better=True,
|
| 223 |
-
tags={"text", "unicode", "philology"},
|
| 224 |
-
)
|
| 225 |
-
def _registered_global_accuracy(reference: str, hypothesis: str) -> float:
|
| 226 |
-
return unicode_block_global_accuracy(reference, hypothesis)
|
| 227 |
-
|
| 228 |
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
"
|
| 232 |
-
|
| 233 |
-
]
|
|
|
|
| 1 |
+
"""Alias rétrocompat — module déplacé dans :mod:`picarones.extras.historical.unicode_blocks`.
|
| 2 |
|
| 3 |
+
Phase B du chantier de refonte en 3 cercles (architecture-cercles.md).
|
| 4 |
+
Ce module philologique est désormais en Cercle 3 (``extras/``). L'alias
|
| 5 |
+
ici permet aux imports historiques (``from picarones.core.unicode_blocks
|
| 6 |
+
import ...``) de continuer à fonctionner sans modification.
|
| 7 |
|
| 8 |
+
Voir :doc:`docs/architecture-cercles.md` et l'extra
|
| 9 |
+
``picarones[historical]`` du ``pyproject.toml``.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
"""
|
| 11 |
|
| 12 |
+
from picarones.extras.historical.unicode_blocks import * # noqa: F401, F403
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
+
import picarones.extras.historical.unicode_blocks as _module
|
| 15 |
+
__all__ = getattr(_module, "__all__", [
|
| 16 |
+
name for name in dir(_module) if not name.startswith("_")
|
| 17 |
+
])
|
|
|
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Métriques philologiques pour documents historiques (Cercle 3).
|
| 2 |
+
|
| 3 |
+
Modules orientés cas d'usage patrimoniaux par période :
|
| 4 |
+
|
| 5 |
+
- :mod:`unicode_blocks` — précision par bloc Unicode (toutes périodes)
|
| 6 |
+
- :mod:`abbreviations` — score d'expansion d'abréviations (médiéval)
|
| 7 |
+
- :mod:`mufi` — couverture MUFI v4.0 (médiéval, PUA)
|
| 8 |
+
- :mod:`early_modern_typography` — fl, fi, ſ, ã, &, ı (XVIᵉ-XVIIIᵉ siècles)
|
| 9 |
+
- :mod:`modern_archives` — Mme/Mlle/°/†/₶ (XIXᵉ-XXᵉ siècles)
|
| 10 |
+
- :mod:`roman_numerals` — numéraux romains (toutes périodes)
|
| 11 |
+
- :mod:`lexical_modernization` — top tokens GT modernisés par le moteur
|
| 12 |
+
- :mod:`philological_runner` — orchestration adaptive des 6 modules
|
| 13 |
+
|
| 14 |
+
Utilité
|
| 15 |
+
-------
|
| 16 |
+
Ces métriques répondent à la question éditoriale *« quels caractères
|
| 17 |
+
historiques ce moteur restitue-t-il fidèlement ? »*. Elles ne
|
| 18 |
+
participent pas à la décision « peut-on déployer ce moteur en prod ? »
|
| 19 |
+
quand le corpus est moderne (les modules retournent ``None`` via
|
| 20 |
+
adaptive masking sur un texte sans signal philologique).
|
| 21 |
+
|
| 22 |
+
Plugin séparable
|
| 23 |
+
----------------
|
| 24 |
+
Distribué via l'extra pip ``picarones[historical]``. Les imports
|
| 25 |
+
historiques ``from picarones.core.unicode_blocks import ...`` restent
|
| 26 |
+
fonctionnels via des fichiers-shims dans :mod:`picarones.core`.
|
| 27 |
+
|
| 28 |
+
Phase B du chantier de refonte en 3 cercles — voir
|
| 29 |
+
:doc:`docs/architecture-cercles.md`.
|
| 30 |
+
"""
|
|
@@ -0,0 +1,350 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Score d'expansion d'abréviations médiévales — Sprint 56.
|
| 2 |
+
|
| 3 |
+
Sprint 56 — A.II.3.2 du plan d'évolution 2026 (axe philologique).
|
| 4 |
+
|
| 5 |
+
Pourquoi ce module
|
| 6 |
+
------------------
|
| 7 |
+
Sur les manuscrits médiévaux (chartes, registres, copies de droit
|
| 8 |
+
canonique), les scribes utilisent intensivement des **signes
|
| 9 |
+
d'abréviation** : ``ꝑ`` (per/par), ``ꝓ`` (pro), ``ꝗ`` (qui),
|
| 10 |
+
``ꝙ`` (quia), ``ꝯ`` (con/-us), ``⁊`` (et), tilde combinant pour
|
| 11 |
+
``-en/-an``, etc.
|
| 12 |
+
|
| 13 |
+
Un OCR/HTR a deux comportements possibles face à ces signes :
|
| 14 |
+
|
| 15 |
+
1. **Préservation** : la forme abrégée est gardée telle quelle
|
| 16 |
+
(``ꝑ`` → ``ꝑ``). C'est le comportement attendu d'une
|
| 17 |
+
transcription **diplomatique** (édition critique).
|
| 18 |
+
2. **Développement** : le signe est remplacé par sa forme
|
| 19 |
+
développée (``ꝑ`` → ``per``). C'est le comportement attendu
|
| 20 |
+
d'une édition **modernisée**.
|
| 21 |
+
|
| 22 |
+
Une troisième possibilité — et c'est l'erreur qu'on cherche à
|
| 23 |
+
détecter : le signe est **mal restitué** (remplacé par un
|
| 24 |
+
caractère ASCII proche, supprimé, ou mal développé).
|
| 25 |
+
|
| 26 |
+
Ce module produit deux scores complémentaires :
|
| 27 |
+
|
| 28 |
+
- ``abbreviation_strict_score`` : taux d'abréviations GT dont la
|
| 29 |
+
**forme abrégée Unicode est préservée** dans l'OCR.
|
| 30 |
+
- ``abbreviation_expansion_score`` : taux d'abréviations GT dont
|
| 31 |
+
**soit** la forme abrégée, **soit** la forme développée
|
| 32 |
+
attendue, est présente dans l'OCR.
|
| 33 |
+
|
| 34 |
+
Le **ratio** des deux dit beaucoup sur la convention adoptée :
|
| 35 |
+
|
| 36 |
+
- ``strict ≈ expansion`` proche de 1 → le moteur est diplomatique
|
| 37 |
+
(préserve l'abrégé) ;
|
| 38 |
+
- ``strict << expansion`` → le moteur est modernisant (développe
|
| 39 |
+
systématiquement) ;
|
| 40 |
+
- les deux faibles → le moteur perd les abréviations (signal
|
| 41 |
+
d'erreur OCR).
|
| 42 |
+
|
| 43 |
+
Stratégie de découpage
|
| 44 |
+
----------------------
|
| 45 |
+
Cohérente avec NER (Sprint 38), Flesch (52), Reading order F1 (53),
|
| 46 |
+
Layout F1 (54), Bloc Unicode (55) : couche de calcul pure d'abord.
|
| 47 |
+
Le câblage runner et la vue HTML suivent dans des sprints dédiés.
|
| 48 |
+
|
| 49 |
+
Limites documentées
|
| 50 |
+
-------------------
|
| 51 |
+
- L'alignement est **bag-of-occurrences** (proxy positionnel
|
| 52 |
+
simple) : on compte les occurrences GT et on vérifie leur
|
| 53 |
+
présence dans l'hyp. Pas d'alignement séquentiel rigoureux.
|
| 54 |
+
- La table d'abréviations couvre les signes les plus courants en
|
| 55 |
+
scriptura latine européenne (Capelli). Elle est extensible via
|
| 56 |
+
``ABBREVIATION_EXPANSIONS``.
|
| 57 |
+
- Pour les abréviations marquées par un **tilde combinant**
|
| 58 |
+
(``p̃``, ``q̃``), on détecte la séquence ``lettre + U+0303``.
|
| 59 |
+
Pas de gestion fine des polices Capelli/MUFI complètes.
|
| 60 |
+
"""
|
| 61 |
+
|
| 62 |
+
from __future__ import annotations
|
| 63 |
+
|
| 64 |
+
import logging
|
| 65 |
+
import re
|
| 66 |
+
import unicodedata
|
| 67 |
+
from typing import Optional
|
| 68 |
+
|
| 69 |
+
from picarones.core.metric_registry import register_metric
|
| 70 |
+
from picarones.core.modules import ArtifactType
|
| 71 |
+
|
| 72 |
+
logger = logging.getLogger(__name__)
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 76 |
+
# Table d'expansions
|
| 77 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 78 |
+
|
| 79 |
+
# Signes d'abréviation latins médiévaux les plus courants.
|
| 80 |
+
# Source : Capelli, "Lexicon Abbreviaturarum" (1929) + MUFI.
|
| 81 |
+
#
|
| 82 |
+
# La clé est une chaîne (1 ou 2 code-points pour le cas tilde
|
| 83 |
+
# combinant) ; la valeur est la liste des expansions courantes
|
| 84 |
+
# acceptées (les détails varient selon la convention éditoriale,
|
| 85 |
+
# on accepte plusieurs formes).
|
| 86 |
+
ABBREVIATION_EXPANSIONS: dict[str, tuple[str, ...]] = {
|
| 87 |
+
"ꝑ": ("per", "par"), # U+A751
|
| 88 |
+
"ꝓ": ("pro",), # U+A753
|
| 89 |
+
"ꝗ": ("qui",), # U+A757
|
| 90 |
+
"ꝙ": ("quia",), # U+A759
|
| 91 |
+
"ꝯ": ("us", "con"), # U+A76F
|
| 92 |
+
"⁊": ("et",), # U+204A "et" tironien
|
| 93 |
+
"ꝝ": ("rum",), # U+A75D
|
| 94 |
+
"ꝫ": ("et",), # U+A76B
|
| 95 |
+
"ꝭ": ("is",), # U+A76D
|
| 96 |
+
# Tilde combinant après lettre (U+0303 = ̃) : pẽ, qũ, etc.
|
| 97 |
+
"p̃": ("par", "per"),
|
| 98 |
+
"q̃": ("que", "qui"),
|
| 99 |
+
"ñ": ("an", "en"), # U+00F1 (Latin-1 Sup)
|
| 100 |
+
# Note : ñ existe aussi comme caractère latin moderne (espagnol),
|
| 101 |
+
# donc l'attribuer aux abréviations introduit du bruit ; on
|
| 102 |
+
# laisse au benchmark le soin d'évaluer. Pour les éditeurs
|
| 103 |
+
# médiévistes qui veulent restreindre, ils peuvent passer par
|
| 104 |
+
# une table custom (à venir).
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
# Set des "premiers code-points" reconnus comme début d'une
|
| 109 |
+
# abréviation (pour balayage rapide).
|
| 110 |
+
_ABBR_FIRST_CHARS: frozenset[str] = frozenset(
|
| 111 |
+
abbr[0] for abbr in ABBREVIATION_EXPANSIONS
|
| 112 |
+
)
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
# Combining tilde (U+0303) — utilisé pour la détection p̃, q̃, etc.
|
| 116 |
+
_COMBINING_TILDE = "̃"
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 120 |
+
# Détection d'abréviations dans un texte
|
| 121 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def detect_abbreviations(text: Optional[str]) -> list[str]:
|
| 125 |
+
"""Liste des abréviations médiévales détectées dans ``text``,
|
| 126 |
+
dans l'ordre d'apparition.
|
| 127 |
+
|
| 128 |
+
Reconnaît :
|
| 129 |
+
|
| 130 |
+
- Les caractères Unicode dédiés présents dans
|
| 131 |
+
``ABBREVIATION_EXPANSIONS`` (``ꝑ``, ``ꝓ``, ``⁊``…).
|
| 132 |
+
- Les séquences ``lettre + U+0303`` (tilde combinant) si la
|
| 133 |
+
paire est dans la table (``p̃``, ``q̃``).
|
| 134 |
+
|
| 135 |
+
Doublons conservés : si le texte contient deux ``ꝑ``, la liste
|
| 136 |
+
en a deux. Cohérent avec le calcul bag-of-occurrences en aval.
|
| 137 |
+
"""
|
| 138 |
+
if not text:
|
| 139 |
+
return []
|
| 140 |
+
found: list[str] = []
|
| 141 |
+
# Forme NFD pour reconnaître les ã, p̃, q̃ même quand l'utilisateur
|
| 142 |
+
# passe la forme NFC (« ñ » = U+00F1 sera traité par le mapping
|
| 143 |
+
# direct ; les séquences manuelles ``p`` + tilde combinant restent
|
| 144 |
+
# détectables).
|
| 145 |
+
text_nfd = unicodedata.normalize("NFD", text)
|
| 146 |
+
i = 0
|
| 147 |
+
while i < len(text_nfd):
|
| 148 |
+
ch = text_nfd[i]
|
| 149 |
+
# Cas 1 : lettre + tilde combinant
|
| 150 |
+
if i + 1 < len(text_nfd) and text_nfd[i + 1] == _COMBINING_TILDE:
|
| 151 |
+
seq = ch + _COMBINING_TILDE
|
| 152 |
+
if seq in ABBREVIATION_EXPANSIONS:
|
| 153 |
+
found.append(seq)
|
| 154 |
+
i += 2
|
| 155 |
+
continue
|
| 156 |
+
# Cas 2 : caractère unicode dédié
|
| 157 |
+
if ch in ABBREVIATION_EXPANSIONS:
|
| 158 |
+
found.append(ch)
|
| 159 |
+
i += 1
|
| 160 |
+
return found
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 164 |
+
# Scores
|
| 165 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
def _hyp_contains_abbr(hypothesis: str, abbr: str) -> bool:
|
| 169 |
+
"""Vrai si la forme abrégée ``abbr`` apparaît telle quelle dans
|
| 170 |
+
``hypothesis``. Sensible aux deux formes NFC / NFD pour les
|
| 171 |
+
séquences à tilde combinant."""
|
| 172 |
+
if abbr in hypothesis:
|
| 173 |
+
return True
|
| 174 |
+
# Pour les séquences ``lettre + tilde combinant``, l'hyp peut
|
| 175 |
+
# avoir une forme NFC (ex. ``ñ`` au lieu de ``n + U+0303``).
|
| 176 |
+
nfd = unicodedata.normalize("NFD", hypothesis)
|
| 177 |
+
return abbr in nfd
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
def _hyp_contains_expansion(
|
| 181 |
+
hypothesis: str, expansions: tuple[str, ...],
|
| 182 |
+
) -> bool:
|
| 183 |
+
"""Vrai si l'une des formes développées apparaît dans ``hypothesis``
|
| 184 |
+
(recherche insensible à la casse, sur les frontières de mots
|
| 185 |
+
pour limiter les faux positifs sur les sous-chaînes courtes
|
| 186 |
+
type ``us`` ou ``et``)."""
|
| 187 |
+
if not expansions:
|
| 188 |
+
return False
|
| 189 |
+
hyp_lower = hypothesis.lower()
|
| 190 |
+
for exp in expansions:
|
| 191 |
+
if not exp:
|
| 192 |
+
continue
|
| 193 |
+
# Recherche frontière de mot pour les expansions courtes.
|
| 194 |
+
# Pour ``per`` ou ``pro`` : on accepte le développement à
|
| 195 |
+
# n'importe quelle position d'un mot (tolère ``per`` dans
|
| 196 |
+
# ``permettre``, c'est imprécis mais pragmatique). Pour
|
| 197 |
+
# les expansions très courtes (≤ 2 lettres), on impose un
|
| 198 |
+
# mot complet pour limiter le bruit.
|
| 199 |
+
if len(exp) <= 2:
|
| 200 |
+
if re.search(rf"\b{re.escape(exp)}\b", hyp_lower):
|
| 201 |
+
return True
|
| 202 |
+
else:
|
| 203 |
+
if exp.lower() in hyp_lower:
|
| 204 |
+
return True
|
| 205 |
+
return False
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
def compute_abbreviation_metrics(
|
| 209 |
+
reference: Optional[str],
|
| 210 |
+
hypothesis: Optional[str],
|
| 211 |
+
) -> dict:
|
| 212 |
+
"""Calcule les scores d'abréviation strict et d'expansion.
|
| 213 |
+
|
| 214 |
+
Parameters
|
| 215 |
+
----------
|
| 216 |
+
reference:
|
| 217 |
+
Texte GT (avec abréviations médiévales originales).
|
| 218 |
+
hypothesis:
|
| 219 |
+
Texte produit par l'OCR.
|
| 220 |
+
|
| 221 |
+
Returns
|
| 222 |
+
-------
|
| 223 |
+
dict
|
| 224 |
+
``{
|
| 225 |
+
"n_abbreviations_in_reference": int,
|
| 226 |
+
"n_strict_preserved": int, # forme abrégée préservée
|
| 227 |
+
"n_expansion_preserved": int, # abrégée OU développée
|
| 228 |
+
"strict_score": float, # ∈ [0, 1]
|
| 229 |
+
"expansion_score": float, # ∈ [0, 1]
|
| 230 |
+
"per_abbreviation": [
|
| 231 |
+
{"abbr", "strict_preserved", "expansion_preserved",
|
| 232 |
+
"expansions"},
|
| 233 |
+
...
|
| 234 |
+
],
|
| 235 |
+
}``
|
| 236 |
+
|
| 237 |
+
Cas dégénérés
|
| 238 |
+
-------------
|
| 239 |
+
- GT vide ou sans abréviation détectée → tous les compteurs à 0
|
| 240 |
+
et les scores à ``0.0`` (convention : on ne récompense pas
|
| 241 |
+
l'absence d'abréviations).
|
| 242 |
+
- GT non vide avec abréviations + hyp vide → tous les scores
|
| 243 |
+
à ``0.0``.
|
| 244 |
+
"""
|
| 245 |
+
ref = reference or ""
|
| 246 |
+
hyp = hypothesis or ""
|
| 247 |
+
|
| 248 |
+
abbreviations = detect_abbreviations(ref)
|
| 249 |
+
n = len(abbreviations)
|
| 250 |
+
if n == 0:
|
| 251 |
+
return {
|
| 252 |
+
"n_abbreviations_in_reference": 0,
|
| 253 |
+
"n_strict_preserved": 0,
|
| 254 |
+
"n_expansion_preserved": 0,
|
| 255 |
+
"strict_score": 0.0,
|
| 256 |
+
"expansion_score": 0.0,
|
| 257 |
+
"per_abbreviation": [],
|
| 258 |
+
}
|
| 259 |
+
|
| 260 |
+
n_strict = 0
|
| 261 |
+
n_expansion = 0
|
| 262 |
+
per_abbr: list[dict] = []
|
| 263 |
+
for abbr in abbreviations:
|
| 264 |
+
expansions = ABBREVIATION_EXPANSIONS.get(abbr, ())
|
| 265 |
+
strict_ok = _hyp_contains_abbr(hyp, abbr)
|
| 266 |
+
# Expansion : on accepte la forme abrégée OU le développement.
|
| 267 |
+
# Convention : si l'OCR a préservé la forme abrégée, c'est
|
| 268 |
+
# aussi compté comme valide pour le score d'expansion (le
|
| 269 |
+
# moteur n'a pas perdu l'information ; il a juste choisi
|
| 270 |
+
# une convention diplomatique).
|
| 271 |
+
expansion_ok = strict_ok or _hyp_contains_expansion(hyp, expansions)
|
| 272 |
+
if strict_ok:
|
| 273 |
+
n_strict += 1
|
| 274 |
+
if expansion_ok:
|
| 275 |
+
n_expansion += 1
|
| 276 |
+
per_abbr.append({
|
| 277 |
+
"abbr": abbr,
|
| 278 |
+
"strict_preserved": strict_ok,
|
| 279 |
+
"expansion_preserved": expansion_ok,
|
| 280 |
+
"expansions": list(expansions),
|
| 281 |
+
})
|
| 282 |
+
|
| 283 |
+
return {
|
| 284 |
+
"n_abbreviations_in_reference": n,
|
| 285 |
+
"n_strict_preserved": n_strict,
|
| 286 |
+
"n_expansion_preserved": n_expansion,
|
| 287 |
+
"strict_score": n_strict / n,
|
| 288 |
+
"expansion_score": n_expansion / n,
|
| 289 |
+
"per_abbreviation": per_abbr,
|
| 290 |
+
}
|
| 291 |
+
|
| 292 |
+
|
| 293 |
+
def abbreviation_strict_score(
|
| 294 |
+
reference: Optional[str], hypothesis: Optional[str],
|
| 295 |
+
) -> float:
|
| 296 |
+
"""Raccourci : taux de préservation **stricte** des abréviations
|
| 297 |
+
Unicode (forme abrégée gardée telle quelle)."""
|
| 298 |
+
return compute_abbreviation_metrics(reference, hypothesis)["strict_score"]
|
| 299 |
+
|
| 300 |
+
|
| 301 |
+
def abbreviation_expansion_score(
|
| 302 |
+
reference: Optional[str], hypothesis: Optional[str],
|
| 303 |
+
) -> float:
|
| 304 |
+
"""Raccourci : taux de préservation par expansion (forme abrégée
|
| 305 |
+
OU forme développée présente dans l'hyp)."""
|
| 306 |
+
return compute_abbreviation_metrics(reference, hypothesis)["expansion_score"]
|
| 307 |
+
|
| 308 |
+
|
| 309 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 310 |
+
# Enregistrement dans le registre typé (Sprint 34)
|
| 311 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 312 |
+
|
| 313 |
+
|
| 314 |
+
@register_metric(
|
| 315 |
+
name="abbreviation_strict_score",
|
| 316 |
+
input_types=(ArtifactType.TEXT, ArtifactType.TEXT),
|
| 317 |
+
description=(
|
| 318 |
+
"Taux d'abréviations médiévales (Unicode dédié + lettre + "
|
| 319 |
+
"tilde combinant) dont la forme abrégée est préservée telle "
|
| 320 |
+
"quelle dans l'OCR. Idéal pour les éditions diplomatiques."
|
| 321 |
+
),
|
| 322 |
+
higher_is_better=True,
|
| 323 |
+
tags={"text", "abbreviation", "philology", "medieval"},
|
| 324 |
+
)
|
| 325 |
+
def _registered_strict(reference: str, hypothesis: str) -> float:
|
| 326 |
+
return abbreviation_strict_score(reference, hypothesis)
|
| 327 |
+
|
| 328 |
+
|
| 329 |
+
@register_metric(
|
| 330 |
+
name="abbreviation_expansion_score",
|
| 331 |
+
input_types=(ArtifactType.TEXT, ArtifactType.TEXT),
|
| 332 |
+
description=(
|
| 333 |
+
"Taux d'abréviations dont SOIT la forme abrégée Unicode SOIT "
|
| 334 |
+
"la forme développée attendue (per, pro, et…) est présente "
|
| 335 |
+
"dans l'OCR. Score plus large que strict_score."
|
| 336 |
+
),
|
| 337 |
+
higher_is_better=True,
|
| 338 |
+
tags={"text", "abbreviation", "philology", "medieval"},
|
| 339 |
+
)
|
| 340 |
+
def _registered_expansion(reference: str, hypothesis: str) -> float:
|
| 341 |
+
return abbreviation_expansion_score(reference, hypothesis)
|
| 342 |
+
|
| 343 |
+
|
| 344 |
+
__all__ = [
|
| 345 |
+
"ABBREVIATION_EXPANSIONS",
|
| 346 |
+
"detect_abbreviations",
|
| 347 |
+
"compute_abbreviation_metrics",
|
| 348 |
+
"abbreviation_strict_score",
|
| 349 |
+
"abbreviation_expansion_score",
|
| 350 |
+
]
|
|
@@ -0,0 +1,342 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Marqueurs typographiques de l'imprimé ancien (XVIᵉ-XVIIIᵉ).
|
| 2 |
+
|
| 3 |
+
Sprint 58 — Étape 3 / extension philologique du plan d'évolution
|
| 4 |
+
2026.
|
| 5 |
+
|
| 6 |
+
Pourquoi ce module
|
| 7 |
+
------------------
|
| 8 |
+
Les Sprints 56 (abréviations Capelli) et 57 (couverture MUFI) sont
|
| 9 |
+
orientés **médiéval scribal**. Mais Picarones doit aussi servir
|
| 10 |
+
les éditeurs d'**imprimés anciens** (XVIᵉ-XVIIIᵉ siècles), pour
|
| 11 |
+
qui les marqueurs caractéristiques ne sont pas scribaux mais
|
| 12 |
+
**typographiques** : ligatures composées (fi, fl, ff, ffi, ffl, ſt),
|
| 13 |
+
s long (ſ), i sans point (ı), esperluette (&), tildes nasaux
|
| 14 |
+
indiquant une abréviation (ã = an/am, õ = on/om).
|
| 15 |
+
|
| 16 |
+
Distinction avec MUFI/abbreviations
|
| 17 |
+
------------------------------------
|
| 18 |
+
- ``mufi.py`` (Sprint 57) : caractères médiévaux scribaux
|
| 19 |
+
(Capelli + lettres þ ð ƿ + PUA MUFI).
|
| 20 |
+
- ``abbreviations.py`` (Sprint 56) : signes d'abréviation latins
|
| 21 |
+
scribaux médiévaux (ꝑ ꝓ ⁊ + tildes scribaux).
|
| 22 |
+
- ``early_modern_typography.py`` (ce module) : marqueurs
|
| 23 |
+
**typographiques** de la composition imprimée ancienne.
|
| 24 |
+
|
| 25 |
+
Les ligatures fi et fl sont communes aux deux univers (médiéval et
|
| 26 |
+
imprimé ancien) ; le choix du module à utiliser dépend du **corpus**
|
| 27 |
+
et de l'angle d'analyse éditoriale, pas du caractère pris isolément.
|
| 28 |
+
|
| 29 |
+
Catégorisation
|
| 30 |
+
--------------
|
| 31 |
+
Les marqueurs sont classés en cinq catégories pour permettre un
|
| 32 |
+
breakdown éditorial :
|
| 33 |
+
|
| 34 |
+
1. ``ligatures`` : fi fl ff ffi ffl ſt
|
| 35 |
+
2. ``long_s`` : ſ
|
| 36 |
+
3. ``dotless_i`` : ı
|
| 37 |
+
4. ``ampersand`` : & (esperluette typographique)
|
| 38 |
+
5. ``nasal_tildes`` : ã õ ũ ñ ē ī (abréviation par tilde nasal)
|
| 39 |
+
|
| 40 |
+
``compute_early_modern_metrics`` retourne le taux de préservation
|
| 41 |
+
par catégorie + global.
|
| 42 |
+
"""
|
| 43 |
+
|
| 44 |
+
from __future__ import annotations
|
| 45 |
+
|
| 46 |
+
import logging
|
| 47 |
+
from difflib import SequenceMatcher
|
| 48 |
+
from typing import Optional
|
| 49 |
+
|
| 50 |
+
from picarones.core.metric_registry import register_metric
|
| 51 |
+
from picarones.core.modules import ArtifactType
|
| 52 |
+
|
| 53 |
+
logger = logging.getLogger(__name__)
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 57 |
+
# Marqueurs typographiques imprimé ancien
|
| 58 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 59 |
+
|
| 60 |
+
# Ligatures typographiques héritées de l'incunable (XVᵉ) et toujours
|
| 61 |
+
# courantes jusqu'au XVIIIᵉ avant la normalisation typographique.
|
| 62 |
+
LIGATURES: frozenset[str] = frozenset({
|
| 63 |
+
"ff", # U+FB00 ff
|
| 64 |
+
"fi", # U+FB01 fi
|
| 65 |
+
"fl", # U+FB02 fl
|
| 66 |
+
"ffi", # U+FB03 ffi
|
| 67 |
+
"ffl", # U+FB04 ffl
|
| 68 |
+
"ſt", # U+FB05 long s + t
|
| 69 |
+
"st", # U+FB06 st
|
| 70 |
+
})
|
| 71 |
+
|
| 72 |
+
# S long : Latin Extended-A. Caractéristique de la typographie
|
| 73 |
+
# antérieure à 1800.
|
| 74 |
+
LONG_S: frozenset[str] = frozenset({"ſ"}) # U+017F
|
| 75 |
+
|
| 76 |
+
# i sans point : utilisé en typographie ancienne, parfois confondu
|
| 77 |
+
# avec un l ou un 1 par les OCR modernes.
|
| 78 |
+
DOTLESS_I: frozenset[str] = frozenset({"ı"}) # U+0131
|
| 79 |
+
|
| 80 |
+
# Esperluette typographique : "&" remplace fréquemment "et" dans
|
| 81 |
+
# les imprimés ; sa préservation discrimine un OCR diplomatique
|
| 82 |
+
# d'un OCR modernisant.
|
| 83 |
+
AMPERSAND: frozenset[str] = frozenset({"&"})
|
| 84 |
+
|
| 85 |
+
# Tildes nasaux : pré-composés (ñ ã ẽ ĩ õ ũ) ou séquences
|
| 86 |
+
# lettre + U+0303 combinant. En imprimé ancien, ã = an/am abrégé,
|
| 87 |
+
# õ = on/om, etc. Distinction avec les tildes scribaux médiévaux
|
| 88 |
+
# (Sprint 56) : ici on cible les **pré-composés** ou séquences sur
|
| 89 |
+
# des voyelles (le scribal médiéval cible plutôt p̃ q̃).
|
| 90 |
+
NASAL_TILDE_PRECOMPOSED: frozenset[str] = frozenset({
|
| 91 |
+
"ã", "Ã", # U+00E3 / U+00C3
|
| 92 |
+
"ñ", "Ñ", # U+00F1 / U+00D1
|
| 93 |
+
"õ", "Õ", # U+00F5 / U+00D5
|
| 94 |
+
"ũ", "Ũ", # U+0169 / U+0168
|
| 95 |
+
"ẽ", "Ẽ", # U+1EBD / U+1EBC
|
| 96 |
+
"ĩ", "Ĩ", # U+0129 / U+0128
|
| 97 |
+
})
|
| 98 |
+
|
| 99 |
+
# Voyelles susceptibles de porter un tilde combinant pour former
|
| 100 |
+
# un tilde nasal (couvre les écritures NFD non pré-composées).
|
| 101 |
+
_NASAL_TILDE_VOWELS: frozenset[str] = frozenset(
|
| 102 |
+
"aeiouAEIOU"
|
| 103 |
+
)
|
| 104 |
+
_COMBINING_TILDE = "̃"
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
# Catégorisation : nom → set de caractères pré-composés ou séquences.
|
| 108 |
+
_CATEGORIES: dict[str, frozenset[str]] = {
|
| 109 |
+
"ligatures": LIGATURES,
|
| 110 |
+
"long_s": LONG_S,
|
| 111 |
+
"dotless_i": DOTLESS_I,
|
| 112 |
+
"ampersand": AMPERSAND,
|
| 113 |
+
"nasal_tildes": NASAL_TILDE_PRECOMPOSED,
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 118 |
+
# Détection des marqueurs dans la GT
|
| 119 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def _detect_markers(text: str) -> list[tuple[int, str, str]]:
|
| 123 |
+
"""Retourne les positions des marqueurs typographiques dans
|
| 124 |
+
``text``.
|
| 125 |
+
|
| 126 |
+
Forme de sortie : ``[(index, marker, category), ...]`` dans
|
| 127 |
+
l'ordre d'apparition. Pour les tildes nasaux non
|
| 128 |
+
pré-composés, on détecte les séquences ``voyelle + U+0303`` et
|
| 129 |
+
on retourne l'index de la voyelle.
|
| 130 |
+
"""
|
| 131 |
+
if not text:
|
| 132 |
+
return []
|
| 133 |
+
found: list[tuple[int, str, str]] = []
|
| 134 |
+
i = 0
|
| 135 |
+
while i < len(text):
|
| 136 |
+
ch = text[i]
|
| 137 |
+
# Cas 1 : marqueur pré-composé dans une catégorie
|
| 138 |
+
category = _category_of_char(ch)
|
| 139 |
+
if category is not None:
|
| 140 |
+
found.append((i, ch, category))
|
| 141 |
+
i += 1
|
| 142 |
+
continue
|
| 143 |
+
# Cas 2 : voyelle + tilde combinant → nasal_tildes
|
| 144 |
+
if (
|
| 145 |
+
ch in _NASAL_TILDE_VOWELS
|
| 146 |
+
and i + 1 < len(text)
|
| 147 |
+
and text[i + 1] == _COMBINING_TILDE
|
| 148 |
+
):
|
| 149 |
+
seq = ch + _COMBINING_TILDE
|
| 150 |
+
found.append((i, seq, "nasal_tildes"))
|
| 151 |
+
i += 2
|
| 152 |
+
continue
|
| 153 |
+
i += 1
|
| 154 |
+
return found
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
def _category_of_char(ch: str) -> Optional[str]:
|
| 158 |
+
"""Retourne la catégorie d'un caractère typographique ou
|
| 159 |
+
``None`` s'il n'est pas reconnu."""
|
| 160 |
+
for cat, chars in _CATEGORIES.items():
|
| 161 |
+
if ch in chars:
|
| 162 |
+
return cat
|
| 163 |
+
return None
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 167 |
+
# Calcul de la préservation par catégorie
|
| 168 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
def compute_early_modern_metrics(
|
| 172 |
+
reference: Optional[str],
|
| 173 |
+
hypothesis: Optional[str],
|
| 174 |
+
) -> dict:
|
| 175 |
+
"""Mesure la préservation des marqueurs typographiques de
|
| 176 |
+
l'imprimé ancien dans l'OCR.
|
| 177 |
+
|
| 178 |
+
Stratégie d'alignement
|
| 179 |
+
----------------------
|
| 180 |
+
Pour chaque marqueur identifié dans la GT à la position ``i``,
|
| 181 |
+
on vérifie si l'OCR l'a préservé en utilisant l'alignement
|
| 182 |
+
caractère par caractère via ``difflib.SequenceMatcher`` (même
|
| 183 |
+
méthode que les Sprints 55/57) :
|
| 184 |
+
|
| 185 |
+
- Marqueur **mono-caractère** (fi, ſ, ı, &, ã…) : la position
|
| 186 |
+
``i`` est-elle dans un opcode ``equal`` ?
|
| 187 |
+
- Marqueur **bi-caractère** (voyelle + U+0303) : les positions
|
| 188 |
+
``i`` et ``i+1`` sont-elles toutes deux dans un opcode
|
| 189 |
+
``equal`` ?
|
| 190 |
+
|
| 191 |
+
Returns
|
| 192 |
+
-------
|
| 193 |
+
dict
|
| 194 |
+
``{
|
| 195 |
+
"n_markers_reference": int,
|
| 196 |
+
"n_markers_preserved": int,
|
| 197 |
+
"global_preservation": float, # ∈ [0, 1]
|
| 198 |
+
"per_category": {
|
| 199 |
+
category: {"total", "preserved", "preservation"}
|
| 200 |
+
},
|
| 201 |
+
"missed_markers": [{"index", "marker", "category"}, ...],
|
| 202 |
+
}``
|
| 203 |
+
|
| 204 |
+
Cas dégénérés : GT vide ou sans marqueur → tous compteurs à 0,
|
| 205 |
+
``global_preservation = 0``.
|
| 206 |
+
"""
|
| 207 |
+
ref = reference or ""
|
| 208 |
+
hyp = hypothesis or ""
|
| 209 |
+
|
| 210 |
+
# Forme NFD pour reconnaître les tildes nasaux décomposés (ã =
|
| 211 |
+
# 'a' + U+0303) côté GT — on conserve toutefois la forme passée
|
| 212 |
+
# pour les indices rapportés dans missed_markers.
|
| 213 |
+
markers = _detect_markers(ref)
|
| 214 |
+
n_total = len(markers)
|
| 215 |
+
|
| 216 |
+
if n_total == 0:
|
| 217 |
+
return {
|
| 218 |
+
"n_markers_reference": 0,
|
| 219 |
+
"n_markers_preserved": 0,
|
| 220 |
+
"global_preservation": 0.0,
|
| 221 |
+
"per_category": {},
|
| 222 |
+
"missed_markers": [],
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
# Aligner GT/hyp et récupérer le set des positions GT couvertes
|
| 226 |
+
# par un opcode "equal".
|
| 227 |
+
matcher = SequenceMatcher(a=ref, b=hyp, autojunk=False)
|
| 228 |
+
correct_positions: set[int] = set()
|
| 229 |
+
for op, i1, i2, _j1, _j2 in matcher.get_opcodes():
|
| 230 |
+
if op == "equal":
|
| 231 |
+
correct_positions.update(range(i1, i2))
|
| 232 |
+
|
| 233 |
+
per_cat_total: dict[str, int] = {}
|
| 234 |
+
per_cat_preserved: dict[str, int] = {}
|
| 235 |
+
n_preserved = 0
|
| 236 |
+
missed: list[dict] = []
|
| 237 |
+
|
| 238 |
+
for index, marker, category in markers:
|
| 239 |
+
per_cat_total[category] = per_cat_total.get(category, 0) + 1
|
| 240 |
+
# Marqueur préservé si toutes ses positions GT sont dans
|
| 241 |
+
# un opcode "equal".
|
| 242 |
+
marker_len = len(marker)
|
| 243 |
+
positions_ok = all(
|
| 244 |
+
(index + k) in correct_positions for k in range(marker_len)
|
| 245 |
+
)
|
| 246 |
+
if positions_ok:
|
| 247 |
+
per_cat_preserved[category] = (
|
| 248 |
+
per_cat_preserved.get(category, 0) + 1
|
| 249 |
+
)
|
| 250 |
+
n_preserved += 1
|
| 251 |
+
else:
|
| 252 |
+
missed.append({
|
| 253 |
+
"index": index,
|
| 254 |
+
"marker": marker,
|
| 255 |
+
"category": category,
|
| 256 |
+
})
|
| 257 |
+
|
| 258 |
+
per_category = {
|
| 259 |
+
cat: {
|
| 260 |
+
"total": per_cat_total[cat],
|
| 261 |
+
"preserved": per_cat_preserved.get(cat, 0),
|
| 262 |
+
"preservation": (
|
| 263 |
+
per_cat_preserved.get(cat, 0) / per_cat_total[cat]
|
| 264 |
+
if per_cat_total[cat] > 0
|
| 265 |
+
else 0.0
|
| 266 |
+
),
|
| 267 |
+
}
|
| 268 |
+
for cat in sorted(per_cat_total)
|
| 269 |
+
}
|
| 270 |
+
|
| 271 |
+
return {
|
| 272 |
+
"n_markers_reference": n_total,
|
| 273 |
+
"n_markers_preserved": n_preserved,
|
| 274 |
+
"global_preservation": n_preserved / n_total,
|
| 275 |
+
"per_category": per_category,
|
| 276 |
+
"missed_markers": missed,
|
| 277 |
+
}
|
| 278 |
+
|
| 279 |
+
|
| 280 |
+
def early_modern_preservation(
|
| 281 |
+
reference: Optional[str], hypothesis: Optional[str],
|
| 282 |
+
) -> float:
|
| 283 |
+
"""Raccourci : taux global de préservation des marqueurs
|
| 284 |
+
typographiques de l'imprimé ancien."""
|
| 285 |
+
return compute_early_modern_metrics(
|
| 286 |
+
reference, hypothesis,
|
| 287 |
+
)["global_preservation"]
|
| 288 |
+
|
| 289 |
+
|
| 290 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 291 |
+
# Helpers exposés
|
| 292 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 293 |
+
|
| 294 |
+
|
| 295 |
+
def detect_markers(text: Optional[str]) -> list[tuple[int, str, str]]:
|
| 296 |
+
"""Wrapper public sur ``_detect_markers`` (acceptant ``None``)."""
|
| 297 |
+
return _detect_markers(text or "")
|
| 298 |
+
|
| 299 |
+
|
| 300 |
+
def get_category(char: str) -> Optional[str]:
|
| 301 |
+
"""Retourne la catégorie typographique d'un caractère
|
| 302 |
+
(``ligatures``, ``long_s``, ``dotless_i``, ``ampersand``,
|
| 303 |
+
``nasal_tildes``) ou ``None``.
|
| 304 |
+
|
| 305 |
+
Pour un tilde combinant suivi d'une voyelle, l'utilisateur doit
|
| 306 |
+
utiliser ``detect_markers`` qui gère les séquences.
|
| 307 |
+
"""
|
| 308 |
+
return _category_of_char(char[0]) if char else None
|
| 309 |
+
|
| 310 |
+
|
| 311 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 312 |
+
# Enregistrement dans le registre typé (Sprint 34)
|
| 313 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 314 |
+
|
| 315 |
+
|
| 316 |
+
@register_metric(
|
| 317 |
+
name="early_modern_preservation",
|
| 318 |
+
input_types=(ArtifactType.TEXT, ArtifactType.TEXT),
|
| 319 |
+
description=(
|
| 320 |
+
"Taux de préservation des marqueurs typographiques de "
|
| 321 |
+
"l'imprimé ancien (XVIᵉ-XVIIIᵉ) : ligatures fi fl ff, s long ſ, "
|
| 322 |
+
"i sans point ı, esperluette &, tildes nasaux ã õ. Critère "
|
| 323 |
+
"éditorial pour les éditions diplomatiques d'imprimés anciens."
|
| 324 |
+
),
|
| 325 |
+
higher_is_better=True,
|
| 326 |
+
tags={"text", "typography", "early_modern", "philology"},
|
| 327 |
+
)
|
| 328 |
+
def _registered_early_modern(reference: str, hypothesis: str) -> float:
|
| 329 |
+
return early_modern_preservation(reference, hypothesis)
|
| 330 |
+
|
| 331 |
+
|
| 332 |
+
__all__ = [
|
| 333 |
+
"LIGATURES",
|
| 334 |
+
"LONG_S",
|
| 335 |
+
"DOTLESS_I",
|
| 336 |
+
"AMPERSAND",
|
| 337 |
+
"NASAL_TILDE_PRECOMPOSED",
|
| 338 |
+
"detect_markers",
|
| 339 |
+
"get_category",
|
| 340 |
+
"compute_early_modern_metrics",
|
| 341 |
+
"early_modern_preservation",
|
| 342 |
+
]
|
|
@@ -0,0 +1,263 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Détection de la sur-normalisation lexicale par les LLM/VLM —
|
| 2 |
+
Sprint 80 (A.I.7).
|
| 3 |
+
|
| 4 |
+
Sprint 80 — A.I.7 du plan d'évolution 2026.
|
| 5 |
+
|
| 6 |
+
Pourquoi ce module
|
| 7 |
+
------------------
|
| 8 |
+
Le détecteur ``llm_hallucination_flag`` (Sprint 19) signale qu'un
|
| 9 |
+
moteur sur-normalise (« 0,05 % »). Mais ce score agrégé ne dit
|
| 10 |
+
rien sur **quoi** corriger dans le prompt. Ce module produit
|
| 11 |
+
une **table de fréquences détaillée** :
|
| 12 |
+
|
| 13 |
+
+----------------------+--------------------+------+----------+
|
| 14 |
+
| Forme historique GT | Forme modernisée | n GT | % modern |
|
| 15 |
+
+======================+====================+======+==========+
|
| 16 |
+
| maistre | maître | 47 | 85 % |
|
| 17 |
+
| nostre | nostre | 92 | 8 % |
|
| 18 |
+
| veoir | voir | 23 | 100 % |
|
| 19 |
+
+----------------------+--------------------+------+----------+
|
| 20 |
+
|
| 21 |
+
Lecture immédiate : *« le LLM modernise systématiquement
|
| 22 |
+
maistre → maître ; pour préserver l'orthographe historique, ajouter
|
| 23 |
+
au prompt "ne pas moderniser maistre, nostre, veoir" »*.
|
| 24 |
+
|
| 25 |
+
Méthode
|
| 26 |
+
-------
|
| 27 |
+
Alignement mot-à-mot via ``difflib.SequenceMatcher``. Chaque
|
| 28 |
+
``replace`` ou ``equal`` produit une paire ``(gt_token,
|
| 29 |
+
hyp_token)``. On accumule pour chaque ``gt_token`` :
|
| 30 |
+
|
| 31 |
+
- ``n_total`` : nombre d'occurrences du token dans la GT
|
| 32 |
+
- ``n_modernized`` : nombre d'occurrences où ``hyp_token != gt_token``
|
| 33 |
+
- ``variants`` : dict des hyp_tokens observés avec leur count
|
| 34 |
+
|
| 35 |
+
Stop-list
|
| 36 |
+
---------
|
| 37 |
+
L'utilisateur peut passer ``stop_list`` (ensemble de tokens GT à
|
| 38 |
+
ignorer). Par défaut, vide — le module ne tente pas de deviner ce
|
| 39 |
+
qui est « moderne » ou « historique », c'est au chercheur de
|
| 40 |
+
fournir le filtre adapté à son corpus.
|
| 41 |
+
|
| 42 |
+
Sortie
|
| 43 |
+
------
|
| 44 |
+
``compute_lexical_modernization`` retourne une structure adaptée
|
| 45 |
+
au rendu HTML. ``aggregate_lexical_modernization`` agrège
|
| 46 |
+
plusieurs documents.
|
| 47 |
+
|
| 48 |
+
Limites documentées
|
| 49 |
+
-------------------
|
| 50 |
+
- Tokenisation au niveau mot (split sur espace) — cohérent avec
|
| 51 |
+
``taxonomy.py`` et autres modules. Pas de stemming ni de
|
| 52 |
+
lemmatisation.
|
| 53 |
+
- La métrique mesure la **réécriture lexicale** ; elle n'attrape
|
| 54 |
+
pas les modernisations infra-mot (perte du s long ſ qui se
|
| 55 |
+
fond dans la même forme). Pour ça, voir ``early_modern_typography``
|
| 56 |
+
(Sprint 58) et ``equivalence_profile`` (Sprint 78).
|
| 57 |
+
"""
|
| 58 |
+
|
| 59 |
+
from __future__ import annotations
|
| 60 |
+
|
| 61 |
+
import difflib
|
| 62 |
+
import logging
|
| 63 |
+
from typing import Iterable, Optional
|
| 64 |
+
|
| 65 |
+
logger = logging.getLogger(__name__)
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def _split_words(text: Optional[str]) -> list[str]:
|
| 69 |
+
"""Tokenisation simple par split sur whitespace."""
|
| 70 |
+
if not text:
|
| 71 |
+
return []
|
| 72 |
+
return text.split()
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def compute_lexical_modernization(
|
| 76 |
+
reference: Optional[str],
|
| 77 |
+
hypothesis: Optional[str],
|
| 78 |
+
*,
|
| 79 |
+
stop_list: Optional[Iterable[str]] = None,
|
| 80 |
+
case_sensitive: bool = False,
|
| 81 |
+
) -> dict:
|
| 82 |
+
"""Calcule le tableau de modernisation lexicale pour un document.
|
| 83 |
+
|
| 84 |
+
Returns
|
| 85 |
+
-------
|
| 86 |
+
dict
|
| 87 |
+
``{
|
| 88 |
+
"n_gt_tokens": int,
|
| 89 |
+
"tokens": {
|
| 90 |
+
gt_token: {
|
| 91 |
+
"n_total": int,
|
| 92 |
+
"n_modernized": int,
|
| 93 |
+
"rate_modernized": float, # ∈ [0, 1]
|
| 94 |
+
"variants": {hyp_token: count, ...},
|
| 95 |
+
},
|
| 96 |
+
...
|
| 97 |
+
},
|
| 98 |
+
}``
|
| 99 |
+
Si ``reference`` est vide → ``tokens == {}``.
|
| 100 |
+
"""
|
| 101 |
+
ref_tokens = _split_words(reference)
|
| 102 |
+
hyp_tokens = _split_words(hypothesis)
|
| 103 |
+
if not ref_tokens:
|
| 104 |
+
return {"n_gt_tokens": 0, "tokens": {}}
|
| 105 |
+
|
| 106 |
+
if not case_sensitive:
|
| 107 |
+
ref_for_match = [t.lower() for t in ref_tokens]
|
| 108 |
+
hyp_for_match = [t.lower() for t in hyp_tokens]
|
| 109 |
+
else:
|
| 110 |
+
ref_for_match = ref_tokens
|
| 111 |
+
hyp_for_match = hyp_tokens
|
| 112 |
+
|
| 113 |
+
stop = frozenset(
|
| 114 |
+
(t.lower() if not case_sensitive else t)
|
| 115 |
+
for t in (stop_list or [])
|
| 116 |
+
)
|
| 117 |
+
|
| 118 |
+
# On accumule par gt_token (forme display = forme originale,
|
| 119 |
+
# match key = forme casée selon ``case_sensitive``).
|
| 120 |
+
tokens_data: dict[str, dict] = {}
|
| 121 |
+
|
| 122 |
+
matcher = difflib.SequenceMatcher(
|
| 123 |
+
None, ref_for_match, hyp_for_match, autojunk=False,
|
| 124 |
+
)
|
| 125 |
+
for tag, i1, i2, j1, j2 in matcher.get_opcodes():
|
| 126 |
+
if tag == "equal":
|
| 127 |
+
for k in range(i2 - i1):
|
| 128 |
+
gt_orig = ref_tokens[i1 + k]
|
| 129 |
+
gt_match = ref_for_match[i1 + k]
|
| 130 |
+
if gt_match in stop:
|
| 131 |
+
continue
|
| 132 |
+
slot = tokens_data.setdefault(
|
| 133 |
+
gt_orig,
|
| 134 |
+
{"n_total": 0, "n_modernized": 0, "variants": {}},
|
| 135 |
+
)
|
| 136 |
+
slot["n_total"] += 1
|
| 137 |
+
elif tag == "replace":
|
| 138 |
+
# Apparier 1-à-1 quand possible
|
| 139 |
+
paired = min(i2 - i1, j2 - j1)
|
| 140 |
+
for k in range(paired):
|
| 141 |
+
gt_orig = ref_tokens[i1 + k]
|
| 142 |
+
gt_match = ref_for_match[i1 + k]
|
| 143 |
+
if gt_match in stop:
|
| 144 |
+
continue
|
| 145 |
+
hyp_orig = hyp_tokens[j1 + k]
|
| 146 |
+
slot = tokens_data.setdefault(
|
| 147 |
+
gt_orig,
|
| 148 |
+
{"n_total": 0, "n_modernized": 0, "variants": {}},
|
| 149 |
+
)
|
| 150 |
+
slot["n_total"] += 1
|
| 151 |
+
slot["n_modernized"] += 1
|
| 152 |
+
slot["variants"][hyp_orig] = slot["variants"].get(hyp_orig, 0) + 1
|
| 153 |
+
# Si plus de gt que de hyp, le reste des gt_tokens est
|
| 154 |
+
# « perdu » — on les compte comme totaux mais pas comme
|
| 155 |
+
# modernisés (on ne sait pas en quoi).
|
| 156 |
+
for k in range(paired, i2 - i1):
|
| 157 |
+
gt_orig = ref_tokens[i1 + k]
|
| 158 |
+
gt_match = ref_for_match[i1 + k]
|
| 159 |
+
if gt_match in stop:
|
| 160 |
+
continue
|
| 161 |
+
slot = tokens_data.setdefault(
|
| 162 |
+
gt_orig,
|
| 163 |
+
{"n_total": 0, "n_modernized": 0, "variants": {}},
|
| 164 |
+
)
|
| 165 |
+
slot["n_total"] += 1
|
| 166 |
+
slot["n_modernized"] += 1
|
| 167 |
+
slot["variants"]["∅"] = slot["variants"].get("∅", 0) + 1
|
| 168 |
+
elif tag == "delete":
|
| 169 |
+
# gt présent, pas en hyp → modernisation par
|
| 170 |
+
# suppression (ou perte pure)
|
| 171 |
+
for k in range(i2 - i1):
|
| 172 |
+
gt_orig = ref_tokens[i1 + k]
|
| 173 |
+
gt_match = ref_for_match[i1 + k]
|
| 174 |
+
if gt_match in stop:
|
| 175 |
+
continue
|
| 176 |
+
slot = tokens_data.setdefault(
|
| 177 |
+
gt_orig,
|
| 178 |
+
{"n_total": 0, "n_modernized": 0, "variants": {}},
|
| 179 |
+
)
|
| 180 |
+
slot["n_total"] += 1
|
| 181 |
+
slot["n_modernized"] += 1
|
| 182 |
+
slot["variants"]["∅"] = slot["variants"].get("∅", 0) + 1
|
| 183 |
+
|
| 184 |
+
# Calcul du taux par token
|
| 185 |
+
for slot in tokens_data.values():
|
| 186 |
+
total = slot["n_total"]
|
| 187 |
+
slot["rate_modernized"] = (
|
| 188 |
+
slot["n_modernized"] / total if total > 0 else 0.0
|
| 189 |
+
)
|
| 190 |
+
|
| 191 |
+
return {
|
| 192 |
+
"n_gt_tokens": len(ref_tokens),
|
| 193 |
+
"tokens": tokens_data,
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
def aggregate_lexical_modernization(
|
| 198 |
+
per_doc_results: Iterable[dict],
|
| 199 |
+
) -> dict:
|
| 200 |
+
"""Agrège des ``compute_lexical_modernization`` per-doc.
|
| 201 |
+
|
| 202 |
+
Renvoie la structure agrégée corpus-wide avec la même forme
|
| 203 |
+
que ``compute_lexical_modernization``.
|
| 204 |
+
"""
|
| 205 |
+
agg_tokens: dict[str, dict] = {}
|
| 206 |
+
n_gt_total = 0
|
| 207 |
+
for doc_result in per_doc_results:
|
| 208 |
+
if not doc_result:
|
| 209 |
+
continue
|
| 210 |
+
n_gt_total += doc_result.get("n_gt_tokens", 0)
|
| 211 |
+
for gt, data in (doc_result.get("tokens") or {}).items():
|
| 212 |
+
slot = agg_tokens.setdefault(
|
| 213 |
+
gt, {"n_total": 0, "n_modernized": 0, "variants": {}},
|
| 214 |
+
)
|
| 215 |
+
slot["n_total"] += data.get("n_total", 0)
|
| 216 |
+
slot["n_modernized"] += data.get("n_modernized", 0)
|
| 217 |
+
for hyp_t, count in (data.get("variants") or {}).items():
|
| 218 |
+
slot["variants"][hyp_t] = slot["variants"].get(hyp_t, 0) + count
|
| 219 |
+
|
| 220 |
+
for slot in agg_tokens.values():
|
| 221 |
+
total = slot["n_total"]
|
| 222 |
+
slot["rate_modernized"] = (
|
| 223 |
+
slot["n_modernized"] / total if total > 0 else 0.0
|
| 224 |
+
)
|
| 225 |
+
return {
|
| 226 |
+
"n_gt_tokens": n_gt_total,
|
| 227 |
+
"tokens": agg_tokens,
|
| 228 |
+
}
|
| 229 |
+
|
| 230 |
+
|
| 231 |
+
def top_modernized_tokens(
|
| 232 |
+
data: dict,
|
| 233 |
+
*,
|
| 234 |
+
n: int = 20,
|
| 235 |
+
min_total: int = 1,
|
| 236 |
+
) -> list[tuple[str, dict]]:
|
| 237 |
+
"""Top-N tokens GT par taux de modernisation.
|
| 238 |
+
|
| 239 |
+
Filtre les tokens dont ``n_total < min_total`` (anecdotiques).
|
| 240 |
+
Tri par ``rate_modernized`` décroissant, tie-break par
|
| 241 |
+
``n_total`` décroissant.
|
| 242 |
+
"""
|
| 243 |
+
tokens = data.get("tokens") or {}
|
| 244 |
+
candidates = [
|
| 245 |
+
(gt, slot) for gt, slot in tokens.items()
|
| 246 |
+
if slot.get("n_total", 0) >= min_total
|
| 247 |
+
and slot.get("n_modernized", 0) > 0
|
| 248 |
+
]
|
| 249 |
+
candidates.sort(
|
| 250 |
+
key=lambda pair: (
|
| 251 |
+
-pair[1].get("rate_modernized", 0.0),
|
| 252 |
+
-pair[1].get("n_total", 0),
|
| 253 |
+
pair[0],
|
| 254 |
+
),
|
| 255 |
+
)
|
| 256 |
+
return candidates[:n]
|
| 257 |
+
|
| 258 |
+
|
| 259 |
+
__all__ = [
|
| 260 |
+
"compute_lexical_modernization",
|
| 261 |
+
"aggregate_lexical_modernization",
|
| 262 |
+
"top_modernized_tokens",
|
| 263 |
+
]
|
|
@@ -0,0 +1,600 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Marqueurs typographiques et abréviations des archives modernes
|
| 2 |
+
(XIXᵉ-XXᵉ siècles) — Sprint 59.
|
| 3 |
+
|
| 4 |
+
Sprint 59 — Étape 3 / extension philologique du plan d'évolution
|
| 5 |
+
2026.
|
| 6 |
+
|
| 7 |
+
Pourquoi ce module
|
| 8 |
+
------------------
|
| 9 |
+
Les Sprints 56-57 sont orientés **médiéval scribal** (Capelli, MUFI),
|
| 10 |
+
le Sprint 58 cible l'**imprimé ancien** XVIᵉ-XVIIIᵉ. Ce sprint étend
|
| 11 |
+
la couverture aux **archives modernes** (XIXᵉ-XXᵉ), période où la
|
| 12 |
+
typographie historique a disparu mais où subsistent des conventions
|
| 13 |
+
d'abréviation propres aux corpus institutionnels (état civil,
|
| 14 |
+
recensements, presse, monographies, archives militaires).
|
| 15 |
+
|
| 16 |
+
Distinction avec les modules précédents
|
| 17 |
+
---------------------------------------
|
| 18 |
+
- ``mufi.py`` (Sprint 57) : caractères médiévaux scribaux.
|
| 19 |
+
- ``abbreviations.py`` (Sprint 56) : signes scribaux médiévaux.
|
| 20 |
+
- ``early_modern_typography.py`` (Sprint 58) : marqueurs
|
| 21 |
+
typographiques imprimé ancien (fi ſ ı &…).
|
| 22 |
+
- ``modern_archives.py`` (ce module) : abréviations et conventions
|
| 23 |
+
de l'archive moderne XIXᵉ-XXᵉ.
|
| 24 |
+
|
| 25 |
+
Catégories
|
| 26 |
+
----------
|
| 27 |
+
1. ``civility_titles`` : Mme, M., Mlle, Mgr, Dr, Pr, Me, R.P., S.M.,
|
| 28 |
+
S.A.R., S.E., S.S.
|
| 29 |
+
2. ``ordinals`` : 1ᵉʳ, 1ʳᵉ, 2ᵉ, 2ᵈ, Vᵉ (avec exposants Unicode)
|
| 30 |
+
3. ``currency`` : ₶ (livre tournois), ₣ ƒ (franc), £, l. s. d.
|
| 31 |
+
(livre/sol/denier d'Ancien Régime)
|
| 32 |
+
4. ``administrative`` : arr., dép., cant., com., reg., prov.
|
| 33 |
+
5. ``civil_status`` : °, †, ✶, ⚭, ép., vve
|
| 34 |
+
6. ``typographic_punctuation`` : « », –, —, …, ’
|
| 35 |
+
7. ``latin_abbr_modern`` : e.g., i.e., etc., cf., ibid., op. cit.,
|
| 36 |
+
ad lib.
|
| 37 |
+
8. ``bibliographic`` : vol., t., p., pp., n°, fasc., éd., ms.,
|
| 38 |
+
r°, v°
|
| 39 |
+
9. ``address`` : bd, av., r., pl., imp., fbg
|
| 40 |
+
|
| 41 |
+
Sortie
|
| 42 |
+
------
|
| 43 |
+
``compute_modern_archives_metrics(ref, hyp)`` retourne deux scores
|
| 44 |
+
par catégorie (pattern Sprint 56) :
|
| 45 |
+
|
| 46 |
+
- ``strict_score`` : forme abrégée préservée telle quelle ;
|
| 47 |
+
- ``expansion_score`` : forme abrégée OU forme développée présente.
|
| 48 |
+
|
| 49 |
+
Le **ratio strict/expansion** par catégorie permet au chercheur de
|
| 50 |
+
juger lui-même la convention adoptée par chaque moteur, sans
|
| 51 |
+
classification automatique imposée par le module.
|
| 52 |
+
|
| 53 |
+
Stratégie de découpage
|
| 54 |
+
----------------------
|
| 55 |
+
Cohérente avec NER (38), Flesch (52), Reading order F1 (53),
|
| 56 |
+
Layout F1 (54), Bloc Unicode (55), Abréviations (56), MUFI (57),
|
| 57 |
+
Imprimé ancien (58) : couche de calcul pure d'abord ; câblage
|
| 58 |
+
runner et HTML dans des sprints dédiés.
|
| 59 |
+
"""
|
| 60 |
+
|
| 61 |
+
from __future__ import annotations
|
| 62 |
+
|
| 63 |
+
import logging
|
| 64 |
+
import re
|
| 65 |
+
from typing import Optional
|
| 66 |
+
|
| 67 |
+
from picarones.core.metric_registry import register_metric
|
| 68 |
+
from picarones.core.modules import ArtifactType
|
| 69 |
+
|
| 70 |
+
logger = logging.getLogger(__name__)
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 74 |
+
# Tables d'abréviations par catégorie
|
| 75 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 76 |
+
#
|
| 77 |
+
# Format : tuple ``(marker, expansions, regex_strict_pattern_or_None)``
|
| 78 |
+
# où :
|
| 79 |
+
# - ``marker`` : forme abrégée canonique (str)
|
| 80 |
+
# - ``expansions`` : tuple de formes développées
|
| 81 |
+
# acceptées (insensible à la casse)
|
| 82 |
+
# - ``regex_strict_pattern`` : pattern Python regex pour la
|
| 83 |
+
# détection dans la GT. ``None``
|
| 84 |
+
# = on dérive automatiquement
|
| 85 |
+
# ``\b<marker_escaped>\b`` (avec
|
| 86 |
+
# garde-fou sur les abréviations
|
| 87 |
+
# contenant un point).
|
| 88 |
+
#
|
| 89 |
+
# Détection : pour les abréviations contenant un ``.`` (« M. »),
|
| 90 |
+
# on n'utilise pas ``\b`` standard car « M.\b » match dans
|
| 91 |
+
# « M.A. » (le ``.`` étant non-mot, ``\b`` est satisfait). On
|
| 92 |
+
# exige donc explicitement une frontière espace/début/fin/
|
| 93 |
+
# ponctuation après le point.
|
| 94 |
+
|
| 95 |
+
CIVILITY_TITLES: tuple[tuple[str, tuple[str, ...]], ...] = (
|
| 96 |
+
("Mme", ("Madame",)),
|
| 97 |
+
("Mlle", ("Mademoiselle",)),
|
| 98 |
+
("Mgr", ("Monseigneur",)),
|
| 99 |
+
("Dr", ("Docteur",)),
|
| 100 |
+
("Pr", ("Professeur",)),
|
| 101 |
+
("Me", ("Maître",)),
|
| 102 |
+
("M.", ("Monsieur",)),
|
| 103 |
+
("R.P.", ("Révérend Père",)),
|
| 104 |
+
("S.M.", ("Sa Majesté",)),
|
| 105 |
+
("S.A.R.", ("Son Altesse Royale",)),
|
| 106 |
+
("S.E.", ("Son Excellence",)),
|
| 107 |
+
("S.S.", ("Sa Sainteté",)),
|
| 108 |
+
)
|
| 109 |
+
|
| 110 |
+
# Ordinaux : la forme **strict** porte l'exposant Unicode
|
| 111 |
+
# (1ᵉʳ U+1D49 U+02B3, 1ʳᵉ, 2ᵈ, 2ᵉ, 3ᵉ…) ; la forme **expansion**
|
| 112 |
+
# accepte la version plate (« 1er », « 1re », « 2nd ») ou la forme
|
| 113 |
+
# textuelle (« premier », « première »).
|
| 114 |
+
#
|
| 115 |
+
# On définit chaque ordinal explicitement (1-12 + Vᵉ pour les
|
| 116 |
+
# numéraux romains de siècle). Au-delà, l'exposant ᵉ seul couvre
|
| 117 |
+
# les usages courants (3ᵉ, 4ᵉ, 5ᵉ, 6ᵉ, 7ᵉ, 8ᵉ, 9ᵉ, 10ᵉ).
|
| 118 |
+
|
| 119 |
+
ORDINALS: tuple[tuple[str, tuple[str, ...]], ...] = (
|
| 120 |
+
("1ᵉʳ", ("1er", "premier")),
|
| 121 |
+
("1ʳᵉ", ("1re", "première", "premiere")),
|
| 122 |
+
("2ᵈ", ("2d", "second")),
|
| 123 |
+
("2ᵈᵉ", ("2de", "seconde")),
|
| 124 |
+
("2ᵉ", ("2e", "deuxième", "deuxieme")),
|
| 125 |
+
("3ᵉ", ("3e", "troisième", "troisieme")),
|
| 126 |
+
("Iᵉʳ", ("Ier", "premier")),
|
| 127 |
+
("Vᵉ", ("Ve", "cinquième", "cinquieme")),
|
| 128 |
+
("XIᵉ", ("XIe", "onzième", "onzieme")),
|
| 129 |
+
("XIIᵉ", ("XIIe", "douzième", "douzieme")),
|
| 130 |
+
("XVIᵉ", ("XVIe", "seizième", "seizieme")),
|
| 131 |
+
("XVIIᵉ", ("XVIIe", "dix-septième", "dix-septieme")),
|
| 132 |
+
("XVIIIᵉ", ("XVIIIe", "dix-huitième", "dix-huitieme")),
|
| 133 |
+
("XIXᵉ", ("XIXe", "dix-neuvième", "dix-neuvieme")),
|
| 134 |
+
("XXᵉ", ("XXe", "vingtième", "vingtieme")),
|
| 135 |
+
)
|
| 136 |
+
|
| 137 |
+
CURRENCY: tuple[tuple[str, tuple[str, ...]], ...] = (
|
| 138 |
+
("₶", ("livre tournois", "livres tournois")),
|
| 139 |
+
("₣", ("franc", "francs")),
|
| 140 |
+
("ƒ", ("florin", "florins")),
|
| 141 |
+
("£", ("livre", "livres", "pound", "pounds")),
|
| 142 |
+
("l.", ("livre", "livres")),
|
| 143 |
+
("s.", ("sol", "sols", "sou", "sous")),
|
| 144 |
+
("d.", ("denier", "deniers")),
|
| 145 |
+
)
|
| 146 |
+
|
| 147 |
+
ADMINISTRATIVE: tuple[tuple[str, tuple[str, ...]], ...] = (
|
| 148 |
+
("arr.", ("arrondissement",)),
|
| 149 |
+
("dép.", ("département", "departement")),
|
| 150 |
+
("cant.", ("canton",)),
|
| 151 |
+
("com.", ("commune",)),
|
| 152 |
+
("reg.", ("régiment", "regiment")),
|
| 153 |
+
("prov.", ("province",)),
|
| 154 |
+
)
|
| 155 |
+
|
| 156 |
+
# État civil : signes typographiques (° = né, † = mort, ⚭ = marié)
|
| 157 |
+
# et abréviations textuelles (ép. = épouse/époux, vve = veuve).
|
| 158 |
+
CIVIL_STATUS: tuple[tuple[str, tuple[str, ...]], ...] = (
|
| 159 |
+
("°", ("né", "née")),
|
| 160 |
+
("†", ("mort", "morte", "décédé", "décédée")),
|
| 161 |
+
("✶", ("naissance",)),
|
| 162 |
+
("⚭", ("marié", "mariée", "épousa", "epousa")),
|
| 163 |
+
("ép.", ("épouse", "époux", "epouse", "epoux")),
|
| 164 |
+
("vve", ("veuve",)),
|
| 165 |
+
)
|
| 166 |
+
|
| 167 |
+
# Ponctuation typographique : ces marqueurs sont préservés en
|
| 168 |
+
# diplomatique et remplacés par leur équivalent ASCII en
|
| 169 |
+
# modernisant. L'expansion n'est pas une « expansion » au sens
|
| 170 |
+
# linguistique mais un substitut typographique.
|
| 171 |
+
TYPOGRAPHIC_PUNCTUATION: tuple[tuple[str, tuple[str, ...]], ...] = (
|
| 172 |
+
("«", ('"',)),
|
| 173 |
+
("»", ('"',)),
|
| 174 |
+
("—", ("-", "--")),
|
| 175 |
+
("–", ("-",)),
|
| 176 |
+
("…", ("...",)),
|
| 177 |
+
("’", ("'",)),
|
| 178 |
+
("‘", ("'",)),
|
| 179 |
+
)
|
| 180 |
+
|
| 181 |
+
LATIN_ABBR_MODERN: tuple[tuple[str, tuple[str, ...]], ...] = (
|
| 182 |
+
("e.g.", ("for example", "par exemple", "exempli gratia")),
|
| 183 |
+
("i.e.", ("c'est-à-dire", "id est", "that is")),
|
| 184 |
+
("etc.", ("et cetera", "et caetera")),
|
| 185 |
+
("cf.", ("confer", "voir")),
|
| 186 |
+
("ibid.", ("ibidem",)),
|
| 187 |
+
("op. cit.", ("opere citato", "opus citatum")),
|
| 188 |
+
("ad lib.", ("ad libitum",)),
|
| 189 |
+
("N.B.", ("nota bene",)),
|
| 190 |
+
)
|
| 191 |
+
|
| 192 |
+
BIBLIOGRAPHIC: tuple[tuple[str, tuple[str, ...]], ...] = (
|
| 193 |
+
("vol.", ("volume",)),
|
| 194 |
+
("t.", ("tome",)),
|
| 195 |
+
("p.", ("page",)),
|
| 196 |
+
("pp.", ("pages",)),
|
| 197 |
+
("n°", ("numéro", "numero", "no")),
|
| 198 |
+
("fasc.", ("fascicule",)),
|
| 199 |
+
("éd.", ("édition", "edition")),
|
| 200 |
+
("ms.", ("manuscrit",)),
|
| 201 |
+
("f.", ("folio",)),
|
| 202 |
+
("r°", ("recto",)),
|
| 203 |
+
("v°", ("verso",)),
|
| 204 |
+
)
|
| 205 |
+
|
| 206 |
+
ADDRESS: tuple[tuple[str, tuple[str, ...]], ...] = (
|
| 207 |
+
("bd", ("boulevard",)),
|
| 208 |
+
("av.", ("avenue",)),
|
| 209 |
+
("r.", ("rue",)),
|
| 210 |
+
("pl.", ("place",)),
|
| 211 |
+
("imp.", ("impasse",)),
|
| 212 |
+
("fbg", ("faubourg",)),
|
| 213 |
+
)
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 217 |
+
# Indexation par catégorie
|
| 218 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 219 |
+
|
| 220 |
+
_CATEGORIES: dict[str, tuple[tuple[str, tuple[str, ...]], ...]] = {
|
| 221 |
+
"civility_titles": CIVILITY_TITLES,
|
| 222 |
+
"ordinals": ORDINALS,
|
| 223 |
+
"currency": CURRENCY,
|
| 224 |
+
"administrative": ADMINISTRATIVE,
|
| 225 |
+
"civil_status": CIVIL_STATUS,
|
| 226 |
+
"typographic_punctuation": TYPOGRAPHIC_PUNCTUATION,
|
| 227 |
+
"latin_abbr_modern": LATIN_ABBR_MODERN,
|
| 228 |
+
"bibliographic": BIBLIOGRAPHIC,
|
| 229 |
+
"address": ADDRESS,
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
# Liste plate de tous les marqueurs avec leur catégorie. Triée par
|
| 233 |
+
# longueur décroissante pour que la détection préfère le marqueur
|
| 234 |
+
# le plus long quand plusieurs préfixes matchent (ex. « S.A.R. »
|
| 235 |
+
# avant « S.A. ").
|
| 236 |
+
_ALL_MARKERS: list[tuple[str, tuple[str, ...], str]] = sorted(
|
| 237 |
+
[
|
| 238 |
+
(marker, expansions, category)
|
| 239 |
+
for category, entries in _CATEGORIES.items()
|
| 240 |
+
for marker, expansions in entries
|
| 241 |
+
],
|
| 242 |
+
key=lambda triple: -len(triple[0]),
|
| 243 |
+
)
|
| 244 |
+
|
| 245 |
+
|
| 246 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 247 |
+
# Compilation des patterns regex
|
| 248 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 249 |
+
#
|
| 250 |
+
# Pour chaque marqueur, on compile un pattern qui exige une
|
| 251 |
+
# frontière de mot adaptée :
|
| 252 |
+
#
|
| 253 |
+
# - Marqueur alphabétique seul (« Mme », « bd ») → ``\b<marker>\b``
|
| 254 |
+
# (le ``\b`` Python gère correctement les bords).
|
| 255 |
+
# - Marqueur contenant un point (« M. », « S.A.R. », « arr. »,
|
| 256 |
+
# « r° », « n° ») → frontière espace/début/fin/ponctuation
|
| 257 |
+
# explicite (le ``.`` final étant non-mot, ``\b`` standard
|
| 258 |
+
# matcherait dans « arr.acher »).
|
| 259 |
+
# - Marqueur contenant un caractère non ASCII (exposant, monnaie,
|
| 260 |
+
# guillemet, croix d'état civil) → match littéral, pas de
|
| 261 |
+
# frontière de mot car ``\b`` ne fonctionne pas sur les
|
| 262 |
+
# caractères non-mot Unicode.
|
| 263 |
+
#
|
| 264 |
+
# La frontière de droite après un point exige soit la fin de
|
| 265 |
+
# chaîne, soit un blanc, soit une ponctuation usuelle (« , ; : ! ? )
|
| 266 |
+
# … » »).
|
| 267 |
+
|
| 268 |
+
_TRAILING_BOUNDARY = r"(?=$|[\s,;:!?\)\]\»\"\'\n\r\t…])"
|
| 269 |
+
_LEADING_BOUNDARY = r"(?:^|(?<=[\s,;:!?\(\[\«\"\'\n\r\t]))"
|
| 270 |
+
|
| 271 |
+
|
| 272 |
+
def _is_alphanumeric_only(text: str) -> bool:
|
| 273 |
+
"""Vrai si tous les caractères sont alphanumériques ASCII."""
|
| 274 |
+
return all(c.isascii() and c.isalnum() for c in text)
|
| 275 |
+
|
| 276 |
+
|
| 277 |
+
def _compile_pattern(marker: str) -> re.Pattern[str]:
|
| 278 |
+
"""Compile le pattern regex pour la détection d'un marqueur
|
| 279 |
+
dans la GT et l'hypothèse.
|
| 280 |
+
|
| 281 |
+
La logique de frontière de mot dépend de la composition du
|
| 282 |
+
marqueur (cf. commentaire principal).
|
| 283 |
+
"""
|
| 284 |
+
escaped = re.escape(marker)
|
| 285 |
+
if "." in marker:
|
| 286 |
+
# Frontière explicite après le point final.
|
| 287 |
+
return re.compile(_LEADING_BOUNDARY + escaped + _TRAILING_BOUNDARY)
|
| 288 |
+
if _is_alphanumeric_only(marker):
|
| 289 |
+
return re.compile(r"\b" + escaped + r"\b")
|
| 290 |
+
# Marqueurs Unicode (exposants, monnaies, guillemets, ponctuation
|
| 291 |
+
# typographique, croix) : match littéral, pas de \b.
|
| 292 |
+
return re.compile(escaped)
|
| 293 |
+
|
| 294 |
+
|
| 295 |
+
# Cache des patterns compilés : (marker, category) → pattern.
|
| 296 |
+
_PATTERNS: dict[tuple[str, str], re.Pattern[str]] = {
|
| 297 |
+
(marker, category): _compile_pattern(marker)
|
| 298 |
+
for marker, _expansions, category in _ALL_MARKERS
|
| 299 |
+
}
|
| 300 |
+
|
| 301 |
+
# Patterns d'expansion (insensibles à la casse, frontière de mot
|
| 302 |
+
# si la forme développée est purement alphabétique).
|
| 303 |
+
_EXPANSION_PATTERNS: dict[str, list[re.Pattern[str]]] = {}
|
| 304 |
+
for marker, expansions, _category in _ALL_MARKERS:
|
| 305 |
+
compiled: list[re.Pattern[str]] = []
|
| 306 |
+
for exp in expansions:
|
| 307 |
+
escaped = re.escape(exp)
|
| 308 |
+
if exp and _is_alphanumeric_only(exp):
|
| 309 |
+
compiled.append(re.compile(r"\b" + escaped + r"\b", re.IGNORECASE))
|
| 310 |
+
else:
|
| 311 |
+
compiled.append(re.compile(escaped, re.IGNORECASE))
|
| 312 |
+
_EXPANSION_PATTERNS[marker] = compiled
|
| 313 |
+
|
| 314 |
+
|
| 315 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 316 |
+
# API publique : catégorisation + détection
|
| 317 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 318 |
+
|
| 319 |
+
|
| 320 |
+
def get_category(marker: str) -> Optional[str]:
|
| 321 |
+
"""Retourne la catégorie d'un marqueur ou ``None`` si inconnu.
|
| 322 |
+
|
| 323 |
+
La comparaison est exacte (sensible à la casse, aux exposants
|
| 324 |
+
Unicode et aux points).
|
| 325 |
+
"""
|
| 326 |
+
if not marker:
|
| 327 |
+
return None
|
| 328 |
+
for category, entries in _CATEGORIES.items():
|
| 329 |
+
for known, _expansions in entries:
|
| 330 |
+
if known == marker:
|
| 331 |
+
return category
|
| 332 |
+
return None
|
| 333 |
+
|
| 334 |
+
|
| 335 |
+
def get_expansions(marker: str) -> tuple[str, ...]:
|
| 336 |
+
"""Retourne les formes développées connues pour un marqueur,
|
| 337 |
+
ou un tuple vide si inconnu."""
|
| 338 |
+
if not marker:
|
| 339 |
+
return ()
|
| 340 |
+
for _category, entries in _CATEGORIES.items():
|
| 341 |
+
for known, expansions in entries:
|
| 342 |
+
if known == marker:
|
| 343 |
+
return expansions
|
| 344 |
+
return ()
|
| 345 |
+
|
| 346 |
+
|
| 347 |
+
def detect_modern_markers(
|
| 348 |
+
text: Optional[str],
|
| 349 |
+
) -> list[tuple[int, str, str]]:
|
| 350 |
+
"""Retourne les marqueurs trouvés dans ``text``.
|
| 351 |
+
|
| 352 |
+
Forme de sortie : ``[(index, marker, category), ...]`` triée
|
| 353 |
+
par index croissant. Si plusieurs marqueurs se chevauchent, le
|
| 354 |
+
plus long gagne (ex. « S.A.R. » plutôt que « S. " puis « A.R. »).
|
| 355 |
+
|
| 356 |
+
Tolérance casse
|
| 357 |
+
---------------
|
| 358 |
+
Les marqueurs alphabétiques courts (« Mme », « Dr », « bd »)
|
| 359 |
+
sont matchés tels quels (sensibilité à la casse) — on n'élargit
|
| 360 |
+
pas car « me » en minuscule n'est pas une abréviation de
|
| 361 |
+
« Maître ».
|
| 362 |
+
"""
|
| 363 |
+
if not text:
|
| 364 |
+
return []
|
| 365 |
+
# Collecte tous les matches de tous les marqueurs.
|
| 366 |
+
candidates: list[tuple[int, int, str, str]] = [] # start, end, marker, cat
|
| 367 |
+
for marker, _expansions, category in _ALL_MARKERS:
|
| 368 |
+
pattern = _PATTERNS[(marker, category)]
|
| 369 |
+
for match in pattern.finditer(text):
|
| 370 |
+
candidates.append((match.start(), match.end(), marker, category))
|
| 371 |
+
# Tri par (start, -length) pour appliquer une stratégie greedy
|
| 372 |
+
# « plus long gagne » à chaque position.
|
| 373 |
+
candidates.sort(key=lambda c: (c[0], -(c[1] - c[0])))
|
| 374 |
+
chosen: list[tuple[int, str, str]] = []
|
| 375 |
+
last_end = -1
|
| 376 |
+
for start, end, marker, category in candidates:
|
| 377 |
+
if start < last_end:
|
| 378 |
+
continue
|
| 379 |
+
chosen.append((start, marker, category))
|
| 380 |
+
last_end = end
|
| 381 |
+
return chosen
|
| 382 |
+
|
| 383 |
+
|
| 384 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 385 |
+
# Calcul des scores strict / expansion
|
| 386 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 387 |
+
|
| 388 |
+
|
| 389 |
+
def _hyp_contains_marker(
|
| 390 |
+
hypothesis: str, marker: str, category: str,
|
| 391 |
+
) -> bool:
|
| 392 |
+
"""Vrai si le marqueur est présent (au moins une occurrence) dans
|
| 393 |
+
l'hypothèse, avec la même règle de frontière qu'en GT."""
|
| 394 |
+
pattern = _PATTERNS[(marker, category)]
|
| 395 |
+
return pattern.search(hypothesis) is not None
|
| 396 |
+
|
| 397 |
+
|
| 398 |
+
def _hyp_contains_expansion(hypothesis: str, marker: str) -> bool:
|
| 399 |
+
"""Vrai si une forme développée connue du marqueur est présente
|
| 400 |
+
dans l'hypothèse (insensible à la casse)."""
|
| 401 |
+
for pattern in _EXPANSION_PATTERNS.get(marker, ()):
|
| 402 |
+
if pattern.search(hypothesis) is not None:
|
| 403 |
+
return True
|
| 404 |
+
return False
|
| 405 |
+
|
| 406 |
+
|
| 407 |
+
def compute_modern_archives_metrics(
|
| 408 |
+
reference: Optional[str],
|
| 409 |
+
hypothesis: Optional[str],
|
| 410 |
+
) -> dict:
|
| 411 |
+
"""Calcule la préservation des marqueurs d'archives modernes.
|
| 412 |
+
|
| 413 |
+
Pour chaque catégorie : retourne le ``strict_score`` (forme
|
| 414 |
+
abrégée préservée) et l'``expansion_score`` (abrégée OU
|
| 415 |
+
développée présente). Le ratio des deux donne au chercheur la
|
| 416 |
+
convention adoptée (diplomatique / modernisante / mixte) sans
|
| 417 |
+
qu'aucune classification ne soit imposée.
|
| 418 |
+
|
| 419 |
+
Returns
|
| 420 |
+
-------
|
| 421 |
+
dict
|
| 422 |
+
``{
|
| 423 |
+
"n_markers_reference": int,
|
| 424 |
+
"n_strict_preserved": int,
|
| 425 |
+
"n_expansion_preserved": int,
|
| 426 |
+
"global_strict_score": float,
|
| 427 |
+
"global_expansion_score": float,
|
| 428 |
+
"per_category": {
|
| 429 |
+
category: {
|
| 430 |
+
"n_total": int,
|
| 431 |
+
"n_strict_preserved": int,
|
| 432 |
+
"n_expansion_preserved": int,
|
| 433 |
+
"strict_score": float,
|
| 434 |
+
"expansion_score": float,
|
| 435 |
+
}
|
| 436 |
+
},
|
| 437 |
+
"missed_markers": [
|
| 438 |
+
{"index": int, "marker": str, "category": str,
|
| 439 |
+
"expansion_preserved": bool}
|
| 440 |
+
],
|
| 441 |
+
}``
|
| 442 |
+
|
| 443 |
+
Cas dégénérés
|
| 444 |
+
-------------
|
| 445 |
+
- GT vide ou sans marqueur → tous les compteurs à 0, scores à
|
| 446 |
+
``0.0``, ``per_category == {}``.
|
| 447 |
+
- GT non vide avec marqueurs + hyp vide → tous les scores à
|
| 448 |
+
``0.0``, tous les marqueurs dans ``missed_markers``.
|
| 449 |
+
"""
|
| 450 |
+
ref = reference or ""
|
| 451 |
+
hyp = hypothesis or ""
|
| 452 |
+
|
| 453 |
+
detected = detect_modern_markers(ref)
|
| 454 |
+
n_total = len(detected)
|
| 455 |
+
if n_total == 0:
|
| 456 |
+
return {
|
| 457 |
+
"n_markers_reference": 0,
|
| 458 |
+
"n_strict_preserved": 0,
|
| 459 |
+
"n_expansion_preserved": 0,
|
| 460 |
+
"global_strict_score": 0.0,
|
| 461 |
+
"global_expansion_score": 0.0,
|
| 462 |
+
"per_category": {},
|
| 463 |
+
"missed_markers": [],
|
| 464 |
+
}
|
| 465 |
+
|
| 466 |
+
per_cat_total: dict[str, int] = {}
|
| 467 |
+
per_cat_strict: dict[str, int] = {}
|
| 468 |
+
per_cat_expansion: dict[str, int] = {}
|
| 469 |
+
n_strict = 0
|
| 470 |
+
n_expansion = 0
|
| 471 |
+
missed: list[dict] = []
|
| 472 |
+
|
| 473 |
+
for index, marker, category in detected:
|
| 474 |
+
per_cat_total[category] = per_cat_total.get(category, 0) + 1
|
| 475 |
+
strict_ok = _hyp_contains_marker(hyp, marker, category)
|
| 476 |
+
# Convention identique à Sprint 56 : si l'abrégé est
|
| 477 |
+
# préservé, c'est aussi un succès pour expansion (l'OCR n'a
|
| 478 |
+
# pas perdu l'information).
|
| 479 |
+
expansion_ok = strict_ok or _hyp_contains_expansion(hyp, marker)
|
| 480 |
+
if strict_ok:
|
| 481 |
+
per_cat_strict[category] = per_cat_strict.get(category, 0) + 1
|
| 482 |
+
n_strict += 1
|
| 483 |
+
if expansion_ok:
|
| 484 |
+
per_cat_expansion[category] = per_cat_expansion.get(category, 0) + 1
|
| 485 |
+
n_expansion += 1
|
| 486 |
+
if not strict_ok:
|
| 487 |
+
missed.append({
|
| 488 |
+
"index": index,
|
| 489 |
+
"marker": marker,
|
| 490 |
+
"category": category,
|
| 491 |
+
"expansion_preserved": expansion_ok,
|
| 492 |
+
})
|
| 493 |
+
|
| 494 |
+
per_category = {
|
| 495 |
+
cat: {
|
| 496 |
+
"n_total": per_cat_total[cat],
|
| 497 |
+
"n_strict_preserved": per_cat_strict.get(cat, 0),
|
| 498 |
+
"n_expansion_preserved": per_cat_expansion.get(cat, 0),
|
| 499 |
+
"strict_score": (
|
| 500 |
+
per_cat_strict.get(cat, 0) / per_cat_total[cat]
|
| 501 |
+
if per_cat_total[cat] > 0 else 0.0
|
| 502 |
+
),
|
| 503 |
+
"expansion_score": (
|
| 504 |
+
per_cat_expansion.get(cat, 0) / per_cat_total[cat]
|
| 505 |
+
if per_cat_total[cat] > 0 else 0.0
|
| 506 |
+
),
|
| 507 |
+
}
|
| 508 |
+
for cat in sorted(per_cat_total)
|
| 509 |
+
}
|
| 510 |
+
|
| 511 |
+
return {
|
| 512 |
+
"n_markers_reference": n_total,
|
| 513 |
+
"n_strict_preserved": n_strict,
|
| 514 |
+
"n_expansion_preserved": n_expansion,
|
| 515 |
+
"global_strict_score": n_strict / n_total,
|
| 516 |
+
"global_expansion_score": n_expansion / n_total,
|
| 517 |
+
"per_category": per_category,
|
| 518 |
+
"missed_markers": missed,
|
| 519 |
+
}
|
| 520 |
+
|
| 521 |
+
|
| 522 |
+
def modern_archives_strict_score(
|
| 523 |
+
reference: Optional[str], hypothesis: Optional[str],
|
| 524 |
+
) -> float:
|
| 525 |
+
"""Raccourci : taux global de préservation **stricte** des
|
| 526 |
+
marqueurs d'archives modernes ∈ [0, 1]."""
|
| 527 |
+
return compute_modern_archives_metrics(
|
| 528 |
+
reference, hypothesis,
|
| 529 |
+
)["global_strict_score"]
|
| 530 |
+
|
| 531 |
+
|
| 532 |
+
def modern_archives_expansion_score(
|
| 533 |
+
reference: Optional[str], hypothesis: Optional[str],
|
| 534 |
+
) -> float:
|
| 535 |
+
"""Raccourci : taux global de préservation **étendue** (abrégée
|
| 536 |
+
OU développée) des marqueurs d'archives modernes ∈ [0, 1]."""
|
| 537 |
+
return compute_modern_archives_metrics(
|
| 538 |
+
reference, hypothesis,
|
| 539 |
+
)["global_expansion_score"]
|
| 540 |
+
|
| 541 |
+
|
| 542 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 543 |
+
# Enregistrement dans le registre typé (Sprint 34)
|
| 544 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 545 |
+
|
| 546 |
+
|
| 547 |
+
@register_metric(
|
| 548 |
+
name="modern_archives_strict_score",
|
| 549 |
+
input_types=(ArtifactType.TEXT, ArtifactType.TEXT),
|
| 550 |
+
description=(
|
| 551 |
+
"Taux de préservation stricte des abréviations et marqueurs "
|
| 552 |
+
"typographiques caractéristiques des archives modernes "
|
| 553 |
+
"(XIXᵉ-XXᵉ) : titres de civilité, ordinaux, monnaies, "
|
| 554 |
+
"abréviations administratives, état civil, ponctuation "
|
| 555 |
+
"typographique, abréviations latines, abréviations "
|
| 556 |
+
"bibliographiques, abréviations d'adresse. Forme abrégée "
|
| 557 |
+
"préservée telle quelle (signal d'édition diplomatique)."
|
| 558 |
+
),
|
| 559 |
+
higher_is_better=True,
|
| 560 |
+
tags={"text", "modern_archives", "philology", "abbreviations"},
|
| 561 |
+
)
|
| 562 |
+
def _registered_strict(reference: str, hypothesis: str) -> float:
|
| 563 |
+
return modern_archives_strict_score(reference, hypothesis)
|
| 564 |
+
|
| 565 |
+
|
| 566 |
+
@register_metric(
|
| 567 |
+
name="modern_archives_expansion_score",
|
| 568 |
+
input_types=(ArtifactType.TEXT, ArtifactType.TEXT),
|
| 569 |
+
description=(
|
| 570 |
+
"Taux de préservation étendue (forme abrégée OU forme "
|
| 571 |
+
"développée présente) des marqueurs d'archives modernes "
|
| 572 |
+
"XIXᵉ-XXᵉ. Le ratio strict/expansion par catégorie "
|
| 573 |
+
"permet au chercheur de juger lui-même la convention "
|
| 574 |
+
"éditoriale adoptée."
|
| 575 |
+
),
|
| 576 |
+
higher_is_better=True,
|
| 577 |
+
tags={"text", "modern_archives", "philology", "abbreviations"},
|
| 578 |
+
)
|
| 579 |
+
def _registered_expansion(reference: str, hypothesis: str) -> float:
|
| 580 |
+
return modern_archives_expansion_score(reference, hypothesis)
|
| 581 |
+
|
| 582 |
+
|
| 583 |
+
__all__ = [
|
| 584 |
+
"CIVILITY_TITLES",
|
| 585 |
+
"ORDINALS",
|
| 586 |
+
"CURRENCY",
|
| 587 |
+
"ADMINISTRATIVE",
|
| 588 |
+
"CIVIL_STATUS",
|
| 589 |
+
"TYPOGRAPHIC_PUNCTUATION",
|
| 590 |
+
"LATIN_ABBR_MODERN",
|
| 591 |
+
"BIBLIOGRAPHIC",
|
| 592 |
+
"ADDRESS",
|
| 593 |
+
"compute_modern_archives_metrics",
|
| 594 |
+
"detect_modern_markers",
|
| 595 |
+
"get_category",
|
| 596 |
+
"get_expansions",
|
| 597 |
+
"modern_archives_strict_score",
|
| 598 |
+
"modern_archives_expansion_score",
|
| 599 |
+
]
|
| 600 |
+
|
|
@@ -0,0 +1,264 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Couverture MUFI — Sprint 57.
|
| 2 |
+
|
| 3 |
+
Sprint 57 — A.II.3.3 du plan d'évolution 2026 (clôture axe A.II.3
|
| 4 |
+
philologique).
|
| 5 |
+
|
| 6 |
+
Pourquoi ce module
|
| 7 |
+
------------------
|
| 8 |
+
La **Medieval Unicode Font Initiative** (MUFI v4.0) standardise les
|
| 9 |
+
caractères médiévaux que les éditeurs critiques attendent dans une
|
| 10 |
+
transcription fidèle : signes d'abréviation, ligatures, lettres
|
| 11 |
+
spéciales (ƿ wynn, þ thorn), ponctuation médiévale, marques
|
| 12 |
+
diacritiques rares, etc. Pour les médiévistes, la **couverture
|
| 13 |
+
MUFI** d'un moteur OCR/HTR est un critère éditorial central.
|
| 14 |
+
|
| 15 |
+
Ce module mesure le taux de **caractères MUFI de la GT
|
| 16 |
+
correctement restitués** dans l'OCR, après alignement caractère par
|
| 17 |
+
caractère (même approche que la précision par bloc Unicode du
|
| 18 |
+
Sprint 55).
|
| 19 |
+
|
| 20 |
+
Détection des caractères MUFI
|
| 21 |
+
-----------------------------
|
| 22 |
+
La spécification MUFI v4.0 référence ~1300 caractères dans plusieurs
|
| 23 |
+
plages Unicode. Plutôt que d'embarquer la liste exhaustive (qui
|
| 24 |
+
évolue), on utilise un **set de plages caractéristiques** suffisant
|
| 25 |
+
pour les corpus patrimoniaux européens courants :
|
| 26 |
+
|
| 27 |
+
- PUA principal (U+E000–U+F8FF) : zone usuelle des glyphes MUFI
|
| 28 |
+
qui n'ont pas (encore) de point de code Unicode standard.
|
| 29 |
+
- Latin Extended-D (U+A720–U+A7FF) : abréviations latines
|
| 30 |
+
médiévales (ꝑ, ꝓ, ꝗ, etc.).
|
| 31 |
+
- Combining Diacritical Marks Supplement (U+1DC0–U+1DFF) :
|
| 32 |
+
diacritiques médiévaux rares (macron suscript, etc.).
|
| 33 |
+
- Alphabetic Presentation Forms (U+FB00–U+FB4F) : ligatures
|
| 34 |
+
(fi, fl, ff).
|
| 35 |
+
- Une **liste explicite** de caractères médiévaux dans les blocs
|
| 36 |
+
Latin Extended-A/B/Additional (þ, ð, ƿ, ſ, æ, œ, etc.)
|
| 37 |
+
|
| 38 |
+
L'utilisateur peut personnaliser via le paramètre ``custom_chars``
|
| 39 |
+
de ``compute_mufi_coverage`` pour étendre ou restreindre.
|
| 40 |
+
|
| 41 |
+
Stratégie de découpage
|
| 42 |
+
----------------------
|
| 43 |
+
Cohérente avec NER (Sprint 38), Flesch (52), Reading order F1 (53),
|
| 44 |
+
Layout F1 (54), Bloc Unicode (55), Abréviations (56) : couche de
|
| 45 |
+
calcul pure d'abord. Le câblage runner et la vue HTML suivent dans
|
| 46 |
+
des sprints dédiés.
|
| 47 |
+
"""
|
| 48 |
+
|
| 49 |
+
from __future__ import annotations
|
| 50 |
+
|
| 51 |
+
import logging
|
| 52 |
+
from difflib import SequenceMatcher
|
| 53 |
+
from typing import Iterable, Optional
|
| 54 |
+
|
| 55 |
+
from picarones.core.metric_registry import register_metric
|
| 56 |
+
from picarones.core.modules import ArtifactType
|
| 57 |
+
|
| 58 |
+
logger = logging.getLogger(__name__)
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 62 |
+
# Plages Unicode considérées comme MUFI
|
| 63 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 64 |
+
|
| 65 |
+
# Triplets (nom, lo, hi) inclusifs. Source : MUFI v4.0 spec
|
| 66 |
+
# (https://mufi.info/) + revue manuelle des caractères patrimoniaux
|
| 67 |
+
# courants.
|
| 68 |
+
_MUFI_RANGES: tuple[tuple[str, int, int], ...] = (
|
| 69 |
+
("Private Use Area", 0xE000, 0xF8FF),
|
| 70 |
+
("Latin Extended-D", 0xA720, 0xA7FF),
|
| 71 |
+
("Combining Diacritical Marks Supplement", 0x1DC0, 0x1DFF),
|
| 72 |
+
("Alphabetic Presentation Forms", 0xFB00, 0xFB4F),
|
| 73 |
+
)
|
| 74 |
+
|
| 75 |
+
# Caractères MUFI explicites hors plages couvertes par les ranges.
|
| 76 |
+
# Surtout des glyphes médiévaux standardisés en Unicode mais qui ne
|
| 77 |
+
# sont pas dans le PUA ni dans Latin Extended-D : þ, ð, ƿ, ſ, æ, œ,
|
| 78 |
+
# ø, ƀ, ƕ, etc. Liste raisonnée pour les corpus européens médiévaux.
|
| 79 |
+
_MUFI_EXPLICIT_CHARS: frozenset[str] = frozenset(
|
| 80 |
+
[
|
| 81 |
+
# Lettres médiévales standard
|
| 82 |
+
"þ", "Þ", # thorn — vieil anglais, islandais
|
| 83 |
+
"ð", "Ð", # eth — vieil anglais, islandais
|
| 84 |
+
"ƿ", "Ƿ", # wynn — vieil anglais
|
| 85 |
+
"ſ", # s long médiéval (déjà U+017F)
|
| 86 |
+
"æ", "Æ", # ash
|
| 87 |
+
"œ", "Œ", # ethel
|
| 88 |
+
"ø", "Ø", # o barré
|
| 89 |
+
# Lettres rares avec barré (pour préfixes abréviés)
|
| 90 |
+
"ƀ", # b barré
|
| 91 |
+
"ŧ", # t barré
|
| 92 |
+
"đ", # d barré
|
| 93 |
+
"ħ", # h barré
|
| 94 |
+
# Yogh
|
| 95 |
+
"ȝ", "Ȝ",
|
| 96 |
+
# Autres signes médiévaux courants
|
| 97 |
+
"ꜿ", # con
|
| 98 |
+
# Note : la liste est volontairement courte ; pour étendre,
|
| 99 |
+
# l'utilisateur peut passer ``custom_chars`` à
|
| 100 |
+
# ``compute_mufi_coverage``.
|
| 101 |
+
]
|
| 102 |
+
)
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def is_mufi_char(char: str, custom_chars: Optional[frozenset[str]] = None) -> bool:
|
| 106 |
+
"""Retourne ``True`` si ``char`` est considéré comme MUFI.
|
| 107 |
+
|
| 108 |
+
Reconnaît :
|
| 109 |
+
|
| 110 |
+
- les caractères dans les plages Unicode MUFI (``_MUFI_RANGES``),
|
| 111 |
+
- les caractères de la liste explicite (``_MUFI_EXPLICIT_CHARS``),
|
| 112 |
+
- tout caractère supplémentaire fourni via ``custom_chars``.
|
| 113 |
+
|
| 114 |
+
Pour une chaîne multi-caractères, seul le premier code-point
|
| 115 |
+
est considéré.
|
| 116 |
+
"""
|
| 117 |
+
if not char:
|
| 118 |
+
return False
|
| 119 |
+
cp = ord(char[0])
|
| 120 |
+
for _name, lo, hi in _MUFI_RANGES:
|
| 121 |
+
if lo <= cp <= hi:
|
| 122 |
+
return True
|
| 123 |
+
if char[0] in _MUFI_EXPLICIT_CHARS:
|
| 124 |
+
return True
|
| 125 |
+
if custom_chars and char[0] in custom_chars:
|
| 126 |
+
return True
|
| 127 |
+
return False
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 131 |
+
# Calcul de couverture MUFI
|
| 132 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def compute_mufi_coverage(
|
| 136 |
+
reference: Optional[str],
|
| 137 |
+
hypothesis: Optional[str],
|
| 138 |
+
custom_chars: Optional[Iterable[str]] = None,
|
| 139 |
+
) -> dict:
|
| 140 |
+
"""Calcule la couverture MUFI : taux de caractères MUFI de la GT
|
| 141 |
+
correctement restitués dans l'hypothèse.
|
| 142 |
+
|
| 143 |
+
Parameters
|
| 144 |
+
----------
|
| 145 |
+
reference:
|
| 146 |
+
Texte GT.
|
| 147 |
+
hypothesis:
|
| 148 |
+
Texte produit par l'OCR.
|
| 149 |
+
custom_chars:
|
| 150 |
+
Itérable optionnel de caractères supplémentaires à considérer
|
| 151 |
+
comme MUFI (utile pour les éditeurs ayant une convention
|
| 152 |
+
propre). Chaque entrée doit être un caractère unique.
|
| 153 |
+
|
| 154 |
+
Returns
|
| 155 |
+
-------
|
| 156 |
+
dict
|
| 157 |
+
``{
|
| 158 |
+
"n_mufi_chars_reference": int, # caractères MUFI dans la GT
|
| 159 |
+
"n_mufi_chars_preserved": int, # MUFI restitués correctement
|
| 160 |
+
"coverage": float, # ∈ [0, 1] ou 0 si N=0
|
| 161 |
+
"per_char": {char: {"total", "preserved", "coverage"}},
|
| 162 |
+
"missed_chars": list[str], # caractères MUFI ratés
|
| 163 |
+
}``
|
| 164 |
+
|
| 165 |
+
Cas dégénérés
|
| 166 |
+
-------------
|
| 167 |
+
- GT vide ou sans caractère MUFI → ``coverage = 0`` (convention :
|
| 168 |
+
pas de récompense gratuite).
|
| 169 |
+
- Hyp vide + MUFI dans GT → ``coverage = 0``.
|
| 170 |
+
- GT et hyp identiques avec MUFI → ``coverage = 1``.
|
| 171 |
+
"""
|
| 172 |
+
ref = reference or ""
|
| 173 |
+
hyp = hypothesis or ""
|
| 174 |
+
extra: Optional[frozenset[str]] = (
|
| 175 |
+
frozenset(c for c in custom_chars if c) if custom_chars else None
|
| 176 |
+
)
|
| 177 |
+
|
| 178 |
+
# 1. Identifier les positions MUFI dans la GT
|
| 179 |
+
mufi_positions = [i for i, ch in enumerate(ref) if is_mufi_char(ch, extra)]
|
| 180 |
+
n_total = len(mufi_positions)
|
| 181 |
+
|
| 182 |
+
if n_total == 0:
|
| 183 |
+
return {
|
| 184 |
+
"n_mufi_chars_reference": 0,
|
| 185 |
+
"n_mufi_chars_preserved": 0,
|
| 186 |
+
"coverage": 0.0,
|
| 187 |
+
"per_char": {},
|
| 188 |
+
"missed_chars": [],
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
# 2. Aligner via SequenceMatcher (même méthode que Sprint 55)
|
| 192 |
+
matcher = SequenceMatcher(a=ref, b=hyp, autojunk=False)
|
| 193 |
+
correct_positions: set[int] = set()
|
| 194 |
+
for op, i1, i2, _j1, _j2 in matcher.get_opcodes():
|
| 195 |
+
if op == "equal":
|
| 196 |
+
correct_positions.update(range(i1, i2))
|
| 197 |
+
|
| 198 |
+
# 3. Compter par caractère
|
| 199 |
+
per_char_total: dict[str, int] = {}
|
| 200 |
+
per_char_preserved: dict[str, int] = {}
|
| 201 |
+
missed: list[str] = []
|
| 202 |
+
for i in mufi_positions:
|
| 203 |
+
ch = ref[i]
|
| 204 |
+
per_char_total[ch] = per_char_total.get(ch, 0) + 1
|
| 205 |
+
if i in correct_positions:
|
| 206 |
+
per_char_preserved[ch] = per_char_preserved.get(ch, 0) + 1
|
| 207 |
+
else:
|
| 208 |
+
missed.append(ch)
|
| 209 |
+
|
| 210 |
+
n_preserved = sum(per_char_preserved.values())
|
| 211 |
+
per_char = {
|
| 212 |
+
ch: {
|
| 213 |
+
"total": per_char_total[ch],
|
| 214 |
+
"preserved": per_char_preserved.get(ch, 0),
|
| 215 |
+
"coverage": (
|
| 216 |
+
per_char_preserved.get(ch, 0) / per_char_total[ch]
|
| 217 |
+
if per_char_total[ch] > 0
|
| 218 |
+
else 0.0
|
| 219 |
+
),
|
| 220 |
+
}
|
| 221 |
+
for ch in sorted(per_char_total)
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
return {
|
| 225 |
+
"n_mufi_chars_reference": n_total,
|
| 226 |
+
"n_mufi_chars_preserved": n_preserved,
|
| 227 |
+
"coverage": n_preserved / n_total,
|
| 228 |
+
"per_char": per_char,
|
| 229 |
+
"missed_chars": missed,
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
|
| 233 |
+
def mufi_coverage(
|
| 234 |
+
reference: Optional[str], hypothesis: Optional[str],
|
| 235 |
+
) -> float:
|
| 236 |
+
"""Raccourci : retourne la couverture MUFI globale ∈ [0, 1]."""
|
| 237 |
+
return compute_mufi_coverage(reference, hypothesis)["coverage"]
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 241 |
+
# Enregistrement dans le registre typé (Sprint 34)
|
| 242 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 243 |
+
|
| 244 |
+
|
| 245 |
+
@register_metric(
|
| 246 |
+
name="mufi_coverage",
|
| 247 |
+
input_types=(ArtifactType.TEXT, ArtifactType.TEXT),
|
| 248 |
+
description=(
|
| 249 |
+
"Taux de caractères MUFI (Medieval Unicode Font Initiative) "
|
| 250 |
+
"de la GT correctement restitués dans l'OCR. Critère "
|
| 251 |
+
"éditorial central pour les médiévistes."
|
| 252 |
+
),
|
| 253 |
+
higher_is_better=True,
|
| 254 |
+
tags={"text", "mufi", "philology", "medieval"},
|
| 255 |
+
)
|
| 256 |
+
def _registered_mufi_coverage(reference: str, hypothesis: str) -> float:
|
| 257 |
+
return mufi_coverage(reference, hypothesis)
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
__all__ = [
|
| 261 |
+
"is_mufi_char",
|
| 262 |
+
"compute_mufi_coverage",
|
| 263 |
+
"mufi_coverage",
|
| 264 |
+
]
|
|
@@ -0,0 +1,363 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Helpers de câblage des métriques philologiques (Sprints 55-60) au runner.
|
| 2 |
+
|
| 3 |
+
Sprint 61 — câblage backend des 6 modules philologiques :
|
| 4 |
+
|
| 5 |
+
- ``unicode_blocks`` (Sprint 55)
|
| 6 |
+
- ``abbreviations`` (Sprint 56)
|
| 7 |
+
- ``mufi`` (Sprint 57)
|
| 8 |
+
- ``early_modern`` (Sprint 58)
|
| 9 |
+
- ``modern_archives`` (Sprint 59)
|
| 10 |
+
- ``roman_numerals`` (Sprint 60)
|
| 11 |
+
|
| 12 |
+
Principe « adaptive »
|
| 13 |
+
----------------------
|
| 14 |
+
Un module n'est inclus dans le résultat que si la **GT contient du
|
| 15 |
+
signal exploitable** pour ce module. Cette logique évite de polluer
|
| 16 |
+
les rapports sur les corpus sans marqueurs philologiques (typique
|
| 17 |
+
sur des données XXIᵉ ou des transcriptions modernes propres).
|
| 18 |
+
|
| 19 |
+
Coût
|
| 20 |
+
----
|
| 21 |
+
Les 6 calculs sont O(N) sur la longueur du texte ; le surcoût total
|
| 22 |
+
par document est négligeable face à un appel OCR. L'activation est
|
| 23 |
+
donc **automatique** (pas d'opt-in), contrairement aux backends NER
|
| 24 |
+
ou calibration qui exigent une dépendance externe ou des données
|
| 25 |
+
spécifiques.
|
| 26 |
+
"""
|
| 27 |
+
|
| 28 |
+
from __future__ import annotations
|
| 29 |
+
|
| 30 |
+
import logging
|
| 31 |
+
from typing import Optional
|
| 32 |
+
|
| 33 |
+
from picarones.core.abbreviations import compute_abbreviation_metrics
|
| 34 |
+
from picarones.core.early_modern_typography import compute_early_modern_metrics
|
| 35 |
+
from picarones.core.modern_archives import compute_modern_archives_metrics
|
| 36 |
+
from picarones.core.mufi import compute_mufi_coverage
|
| 37 |
+
from picarones.core.roman_numerals import compute_roman_numeral_metrics
|
| 38 |
+
from picarones.core.unicode_blocks import compute_unicode_block_accuracy
|
| 39 |
+
|
| 40 |
+
logger = logging.getLogger(__name__)
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 44 |
+
# Critères « le module a-t-il du signal sur ce document ? »
|
| 45 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 46 |
+
#
|
| 47 |
+
# Pour chaque module, on définit un prédicat sur le résultat : si vrai,
|
| 48 |
+
# le module est inclus ; sinon, il est omis pour ne pas alourdir le
|
| 49 |
+
# rapport.
|
| 50 |
+
|
| 51 |
+
def _has_unicode_signal(result: dict) -> bool:
|
| 52 |
+
# Le module retourne toujours du signal dès que GT non-vide ; on
|
| 53 |
+
# n'inclut que si la GT a au moins un caractère **hors Basic
|
| 54 |
+
# Latin** (sinon le breakdown se réduit à 100 % Basic Latin et
|
| 55 |
+
# n'apporte rien au lecteur).
|
| 56 |
+
per_block = result.get("per_block", {})
|
| 57 |
+
for block, stats in per_block.items():
|
| 58 |
+
if block == "Basic Latin":
|
| 59 |
+
continue
|
| 60 |
+
if stats.get("total", 0) > 0:
|
| 61 |
+
return True
|
| 62 |
+
return False
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def _has_abbreviation_signal(result: dict) -> bool:
|
| 66 |
+
return result.get("n_abbreviations_in_reference", 0) > 0
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def _has_mufi_signal(result: dict) -> bool:
|
| 70 |
+
return result.get("n_mufi_chars_reference", 0) > 0
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def _has_early_modern_signal(result: dict) -> bool:
|
| 74 |
+
return result.get("n_markers_reference", 0) > 0
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def _has_modern_archives_signal(result: dict) -> bool:
|
| 78 |
+
return result.get("n_markers_reference", 0) > 0
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def _has_roman_numeral_signal(result: dict) -> bool:
|
| 82 |
+
return result.get("n_numerals_reference", 0) > 0
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
# Ordre fixé pour la reproductibilité des sorties.
|
| 86 |
+
_PHILOLOGICAL_MODULES: tuple[
|
| 87 |
+
tuple[str, callable, callable], ...
|
| 88 |
+
] = (
|
| 89 |
+
("unicode_blocks", compute_unicode_block_accuracy, _has_unicode_signal),
|
| 90 |
+
("abbreviations", compute_abbreviation_metrics, _has_abbreviation_signal),
|
| 91 |
+
("mufi", compute_mufi_coverage, _has_mufi_signal),
|
| 92 |
+
("early_modern", compute_early_modern_metrics, _has_early_modern_signal),
|
| 93 |
+
("modern_archives", compute_modern_archives_metrics, _has_modern_archives_signal),
|
| 94 |
+
("roman_numerals", compute_roman_numeral_metrics, _has_roman_numeral_signal),
|
| 95 |
+
)
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 99 |
+
# Calcul par document
|
| 100 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def compute_philological_metrics(
|
| 104 |
+
reference: Optional[str],
|
| 105 |
+
hypothesis: Optional[str],
|
| 106 |
+
) -> Optional[dict]:
|
| 107 |
+
"""Calcule les 6 métriques philologiques pour un document.
|
| 108 |
+
|
| 109 |
+
Retourne un dict avec une clé par module ayant du signal, ou
|
| 110 |
+
``None`` si aucun module n'en a (corpus sans marqueur
|
| 111 |
+
philologique pertinent).
|
| 112 |
+
|
| 113 |
+
En cas d'erreur dans un module individuel, le module est
|
| 114 |
+
silencieusement omis et un warning est émis (les autres modules
|
| 115 |
+
restent calculés).
|
| 116 |
+
"""
|
| 117 |
+
ref = reference or ""
|
| 118 |
+
if not ref:
|
| 119 |
+
return None
|
| 120 |
+
out: dict = {}
|
| 121 |
+
for name, compute_fn, has_signal_fn in _PHILOLOGICAL_MODULES:
|
| 122 |
+
try:
|
| 123 |
+
result = compute_fn(ref, hypothesis or "")
|
| 124 |
+
except Exception as exc: # pragma: no cover — défense en profondeur
|
| 125 |
+
logger.warning(
|
| 126 |
+
"[philological_runner] module %s a échoué : %s", name, exc,
|
| 127 |
+
)
|
| 128 |
+
continue
|
| 129 |
+
if has_signal_fn(result):
|
| 130 |
+
out[name] = result
|
| 131 |
+
return out if out else None
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 135 |
+
# Agrégation corpus-wide par moteur
|
| 136 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def _aggregate_unicode(per_doc: list[dict]) -> dict:
|
| 140 |
+
total_correct = 0
|
| 141 |
+
total_chars = 0
|
| 142 |
+
per_block: dict[str, dict[str, int]] = {}
|
| 143 |
+
for d in per_doc:
|
| 144 |
+
for block, stats in d.get("per_block", {}).items():
|
| 145 |
+
slot = per_block.setdefault(block, {"correct": 0, "total": 0})
|
| 146 |
+
slot["correct"] += stats.get("correct", 0)
|
| 147 |
+
slot["total"] += stats.get("total", 0)
|
| 148 |
+
total_correct += stats.get("correct", 0)
|
| 149 |
+
total_chars += stats.get("total", 0)
|
| 150 |
+
out_per_block = {
|
| 151 |
+
block: {
|
| 152 |
+
"correct": slot["correct"],
|
| 153 |
+
"total": slot["total"],
|
| 154 |
+
"accuracy": (
|
| 155 |
+
slot["correct"] / slot["total"] if slot["total"] > 0 else 0.0
|
| 156 |
+
),
|
| 157 |
+
}
|
| 158 |
+
for block, slot in sorted(per_block.items())
|
| 159 |
+
}
|
| 160 |
+
return {
|
| 161 |
+
"global_accuracy": total_correct / total_chars if total_chars > 0 else 0.0,
|
| 162 |
+
"n_chars_total": total_chars,
|
| 163 |
+
"n_chars_correct": total_correct,
|
| 164 |
+
"per_block": out_per_block,
|
| 165 |
+
"doc_count": len(per_doc),
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
def _aggregate_abbreviations(per_doc: list[dict]) -> dict:
|
| 170 |
+
n_total = 0
|
| 171 |
+
n_strict = 0
|
| 172 |
+
n_expansion = 0
|
| 173 |
+
per_abbr: dict[str, dict[str, int]] = {}
|
| 174 |
+
for d in per_doc:
|
| 175 |
+
n_total += d.get("n_abbreviations_in_reference", 0)
|
| 176 |
+
n_strict += d.get("n_strict_preserved", 0)
|
| 177 |
+
n_expansion += d.get("n_expansion_preserved", 0)
|
| 178 |
+
for entry in d.get("per_abbreviation", []):
|
| 179 |
+
slot = per_abbr.setdefault(
|
| 180 |
+
entry["abbr"],
|
| 181 |
+
{"total": 0, "strict": 0, "expansion": 0},
|
| 182 |
+
)
|
| 183 |
+
slot["total"] += 1
|
| 184 |
+
if entry.get("strict_preserved"):
|
| 185 |
+
slot["strict"] += 1
|
| 186 |
+
if entry.get("expansion_preserved"):
|
| 187 |
+
slot["expansion"] += 1
|
| 188 |
+
return {
|
| 189 |
+
"n_abbreviations_in_reference": n_total,
|
| 190 |
+
"n_strict_preserved": n_strict,
|
| 191 |
+
"n_expansion_preserved": n_expansion,
|
| 192 |
+
"global_strict_score": n_strict / n_total if n_total > 0 else 0.0,
|
| 193 |
+
"global_expansion_score": n_expansion / n_total if n_total > 0 else 0.0,
|
| 194 |
+
"per_abbreviation": {
|
| 195 |
+
abbr: {
|
| 196 |
+
"n_total": slot["total"],
|
| 197 |
+
"n_strict": slot["strict"],
|
| 198 |
+
"n_expansion": slot["expansion"],
|
| 199 |
+
"strict_score": slot["strict"] / slot["total"],
|
| 200 |
+
"expansion_score": slot["expansion"] / slot["total"],
|
| 201 |
+
}
|
| 202 |
+
for abbr, slot in sorted(per_abbr.items())
|
| 203 |
+
},
|
| 204 |
+
"doc_count": len(per_doc),
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
def _aggregate_mufi(per_doc: list[dict]) -> dict:
|
| 209 |
+
n_total = 0
|
| 210 |
+
n_preserved = 0
|
| 211 |
+
per_char: dict[str, dict[str, int]] = {}
|
| 212 |
+
for d in per_doc:
|
| 213 |
+
n_total += d.get("n_mufi_chars_reference", 0)
|
| 214 |
+
n_preserved += d.get("n_mufi_chars_preserved", 0)
|
| 215 |
+
for ch, stats in d.get("per_char", {}).items():
|
| 216 |
+
slot = per_char.setdefault(ch, {"total": 0, "preserved": 0})
|
| 217 |
+
slot["total"] += stats.get("total", 0)
|
| 218 |
+
slot["preserved"] += stats.get("preserved", 0)
|
| 219 |
+
return {
|
| 220 |
+
"n_mufi_chars_reference": n_total,
|
| 221 |
+
"n_mufi_chars_preserved": n_preserved,
|
| 222 |
+
"coverage": n_preserved / n_total if n_total > 0 else 0.0,
|
| 223 |
+
"per_char": {
|
| 224 |
+
ch: {
|
| 225 |
+
"total": slot["total"],
|
| 226 |
+
"preserved": slot["preserved"],
|
| 227 |
+
"coverage": slot["preserved"] / slot["total"],
|
| 228 |
+
}
|
| 229 |
+
for ch, slot in sorted(per_char.items())
|
| 230 |
+
},
|
| 231 |
+
"doc_count": len(per_doc),
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
def _aggregate_early_modern(per_doc: list[dict]) -> dict:
|
| 236 |
+
n_total = 0
|
| 237 |
+
n_preserved = 0
|
| 238 |
+
per_cat: dict[str, dict[str, int]] = {}
|
| 239 |
+
for d in per_doc:
|
| 240 |
+
n_total += d.get("n_markers_reference", 0)
|
| 241 |
+
n_preserved += d.get("n_markers_preserved", 0)
|
| 242 |
+
for cat, stats in d.get("per_category", {}).items():
|
| 243 |
+
slot = per_cat.setdefault(cat, {"total": 0, "preserved": 0})
|
| 244 |
+
slot["total"] += stats.get("total", 0)
|
| 245 |
+
slot["preserved"] += stats.get("preserved", 0)
|
| 246 |
+
return {
|
| 247 |
+
"n_markers_reference": n_total,
|
| 248 |
+
"n_markers_preserved": n_preserved,
|
| 249 |
+
"global_preservation": n_preserved / n_total if n_total > 0 else 0.0,
|
| 250 |
+
"per_category": {
|
| 251 |
+
cat: {
|
| 252 |
+
"total": slot["total"],
|
| 253 |
+
"preserved": slot["preserved"],
|
| 254 |
+
"preservation": slot["preserved"] / slot["total"],
|
| 255 |
+
}
|
| 256 |
+
for cat, slot in sorted(per_cat.items())
|
| 257 |
+
},
|
| 258 |
+
"doc_count": len(per_doc),
|
| 259 |
+
}
|
| 260 |
+
|
| 261 |
+
|
| 262 |
+
def _aggregate_modern_archives(per_doc: list[dict]) -> dict:
|
| 263 |
+
n_total = 0
|
| 264 |
+
n_strict = 0
|
| 265 |
+
n_expansion = 0
|
| 266 |
+
per_cat: dict[str, dict[str, int]] = {}
|
| 267 |
+
for d in per_doc:
|
| 268 |
+
n_total += d.get("n_markers_reference", 0)
|
| 269 |
+
n_strict += d.get("n_strict_preserved", 0)
|
| 270 |
+
n_expansion += d.get("n_expansion_preserved", 0)
|
| 271 |
+
for cat, stats in d.get("per_category", {}).items():
|
| 272 |
+
slot = per_cat.setdefault(
|
| 273 |
+
cat, {"total": 0, "strict": 0, "expansion": 0},
|
| 274 |
+
)
|
| 275 |
+
slot["total"] += stats.get("n_total", 0)
|
| 276 |
+
slot["strict"] += stats.get("n_strict_preserved", 0)
|
| 277 |
+
slot["expansion"] += stats.get("n_expansion_preserved", 0)
|
| 278 |
+
return {
|
| 279 |
+
"n_markers_reference": n_total,
|
| 280 |
+
"n_strict_preserved": n_strict,
|
| 281 |
+
"n_expansion_preserved": n_expansion,
|
| 282 |
+
"global_strict_score": n_strict / n_total if n_total > 0 else 0.0,
|
| 283 |
+
"global_expansion_score": n_expansion / n_total if n_total > 0 else 0.0,
|
| 284 |
+
"per_category": {
|
| 285 |
+
cat: {
|
| 286 |
+
"n_total": slot["total"],
|
| 287 |
+
"n_strict_preserved": slot["strict"],
|
| 288 |
+
"n_expansion_preserved": slot["expansion"],
|
| 289 |
+
"strict_score": slot["strict"] / slot["total"],
|
| 290 |
+
"expansion_score": slot["expansion"] / slot["total"],
|
| 291 |
+
}
|
| 292 |
+
for cat, slot in sorted(per_cat.items())
|
| 293 |
+
},
|
| 294 |
+
"doc_count": len(per_doc),
|
| 295 |
+
}
|
| 296 |
+
|
| 297 |
+
|
| 298 |
+
def _aggregate_roman_numerals(per_doc: list[dict]) -> dict:
|
| 299 |
+
from picarones.core.roman_numerals import ALL_STATUSES, VALUE_PRESERVING_STATUSES
|
| 300 |
+
|
| 301 |
+
n_total = 0
|
| 302 |
+
per_status: dict[str, int] = {s: 0 for s in ALL_STATUSES}
|
| 303 |
+
for d in per_doc:
|
| 304 |
+
n_total += d.get("n_numerals_reference", 0)
|
| 305 |
+
for status, count in d.get("per_status", {}).items():
|
| 306 |
+
per_status[status] = per_status.get(status, 0) + count
|
| 307 |
+
n_strict = per_status.get("strict_preserved", 0)
|
| 308 |
+
n_value = sum(per_status.get(s, 0) for s in VALUE_PRESERVING_STATUSES)
|
| 309 |
+
return {
|
| 310 |
+
"n_numerals_reference": n_total,
|
| 311 |
+
"n_strict_preserved": n_strict,
|
| 312 |
+
"n_value_preserved": n_value,
|
| 313 |
+
"global_strict_score": n_strict / n_total if n_total > 0 else 0.0,
|
| 314 |
+
"global_value_score": n_value / n_total if n_total > 0 else 0.0,
|
| 315 |
+
"per_status": per_status,
|
| 316 |
+
"doc_count": len(per_doc),
|
| 317 |
+
}
|
| 318 |
+
|
| 319 |
+
|
| 320 |
+
_AGGREGATORS = {
|
| 321 |
+
"unicode_blocks": _aggregate_unicode,
|
| 322 |
+
"abbreviations": _aggregate_abbreviations,
|
| 323 |
+
"mufi": _aggregate_mufi,
|
| 324 |
+
"early_modern": _aggregate_early_modern,
|
| 325 |
+
"modern_archives": _aggregate_modern_archives,
|
| 326 |
+
"roman_numerals": _aggregate_roman_numerals,
|
| 327 |
+
}
|
| 328 |
+
|
| 329 |
+
|
| 330 |
+
def aggregate_philological_metrics(
|
| 331 |
+
doc_metrics: list[Optional[dict]],
|
| 332 |
+
) -> Optional[dict]:
|
| 333 |
+
"""Agrège les ``philological_metrics`` per-document en un dict
|
| 334 |
+
corpus-wide par module.
|
| 335 |
+
|
| 336 |
+
Pour chaque module, on agrège uniquement les documents qui ont
|
| 337 |
+
eu du signal pour ce module. Si aucun module n'a été calculé
|
| 338 |
+
sur aucun document, retourne ``None``.
|
| 339 |
+
"""
|
| 340 |
+
by_module: dict[str, list[dict]] = {}
|
| 341 |
+
for doc in doc_metrics:
|
| 342 |
+
if not doc:
|
| 343 |
+
continue
|
| 344 |
+
for module, payload in doc.items():
|
| 345 |
+
by_module.setdefault(module, []).append(payload)
|
| 346 |
+
if not by_module:
|
| 347 |
+
return None
|
| 348 |
+
out: dict = {}
|
| 349 |
+
for module, payloads in by_module.items():
|
| 350 |
+
aggregator = _AGGREGATORS.get(module)
|
| 351 |
+
if aggregator is None: # pragma: no cover
|
| 352 |
+
logger.warning(
|
| 353 |
+
"[philological_runner] aucun agrégateur pour %s", module,
|
| 354 |
+
)
|
| 355 |
+
continue
|
| 356 |
+
out[module] = aggregator(payloads)
|
| 357 |
+
return out if out else None
|
| 358 |
+
|
| 359 |
+
|
| 360 |
+
__all__ = [
|
| 361 |
+
"compute_philological_metrics",
|
| 362 |
+
"aggregate_philological_metrics",
|
| 363 |
+
]
|
|
@@ -0,0 +1,478 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Numéraux romains — Sprint 60.
|
| 2 |
+
|
| 3 |
+
Sprint 60 — Étape 3 / extension philologique transversale du plan
|
| 4 |
+
d'évolution 2026.
|
| 5 |
+
|
| 6 |
+
Pourquoi ce module
|
| 7 |
+
------------------
|
| 8 |
+
Les numéraux romains traversent **toutes les périodes patrimoniales**
|
| 9 |
+
servies par Picarones :
|
| 10 |
+
|
| 11 |
+
- **Médiéval** : minuscules avec ``j`` final pour le dernier ``i``
|
| 12 |
+
(``ij`` = 2, ``iij`` = 3, ``viij`` = 8, ``mcclxxxij`` = 1282).
|
| 13 |
+
Convention scribale standard dans les chartes et registres.
|
| 14 |
+
- **Imprimé ancien** : majuscules (``Tome IV``, ``Chap. VII``).
|
| 15 |
+
- **Moderne** : majuscules pour les souverains (``Louis XIV``) et
|
| 16 |
+
les siècles (``XIXᵉ siècle`` — la partie exposant ᵉ est gérée
|
| 17 |
+
par le Sprint 59 ``ordinals``, ce module ne traite que la partie
|
| 18 |
+
numérale ``XIX``).
|
| 19 |
+
|
| 20 |
+
Quatre traitements possibles d'un numéral par l'OCR
|
| 21 |
+
----------------------------------------------------
|
| 22 |
+
Pour chaque numéral romain présent dans la GT, l'OCR peut :
|
| 23 |
+
|
| 24 |
+
1. **Préserver strictement** : forme exacte gardée
|
| 25 |
+
(``mcclxxxij`` → ``mcclxxxij``). Édition diplomatique idéale.
|
| 26 |
+
2. **Préserver en changeant la casse** : la valeur est intacte mais
|
| 27 |
+
la convention typographique est modifiée
|
| 28 |
+
(``xiv`` → ``XIV``). Modernisation typographique courante.
|
| 29 |
+
3. **Préserver en supprimant le ``j`` final** :
|
| 30 |
+
(``mcclxxxij`` → ``mcclxxxii``). Modernisation orthographique
|
| 31 |
+
médiévale → standard académique moderne.
|
| 32 |
+
4. **Convertir en chiffres arabes** : la valeur est préservée mais
|
| 33 |
+
le système de numération est modernisé
|
| 34 |
+
(``XIV`` → ``14``). Modernisation profonde, perte de
|
| 35 |
+
l'information typographique.
|
| 36 |
+
5. **Perdre** : aucune trace de la valeur dans l'hypothèse.
|
| 37 |
+
|
| 38 |
+
Ce module retourne un breakdown par statut pour que le chercheur
|
| 39 |
+
juge lui-même la convention adoptée par chaque moteur, **sans
|
| 40 |
+
classification automatique imposée**.
|
| 41 |
+
|
| 42 |
+
Stratégie de découpage
|
| 43 |
+
----------------------
|
| 44 |
+
Cohérente avec NER (38), Flesch (52), Reading order F1 (53),
|
| 45 |
+
Layout F1 (54), Bloc Unicode (55), Abréviations (56), MUFI (57),
|
| 46 |
+
Imprimé ancien (58), Archives modernes (59) : couche de calcul
|
| 47 |
+
pure d'abord ; câblage runner et HTML dans des sprints dédiés.
|
| 48 |
+
|
| 49 |
+
Limites documentées
|
| 50 |
+
-------------------
|
| 51 |
+
- Détection greedy par regex ``\\b[IVXLCDMivxlcdmj]+\\b`` puis
|
| 52 |
+
validation par parsing. Les faux positifs restent possibles sur
|
| 53 |
+
des mots courts (``I`` pronom anglais, ``MM`` initiales, ``LL``).
|
| 54 |
+
Le paramètre ``min_length`` permet de filtrer les single-letter.
|
| 55 |
+
- Pas de gestion des notations rares avec barre suscript pour
|
| 56 |
+
multiplier par 1000 (V̄ = 5000, X̄ = 10000) — usage très rare en
|
| 57 |
+
corpus patrimonial européen courant.
|
| 58 |
+
"""
|
| 59 |
+
|
| 60 |
+
from __future__ import annotations
|
| 61 |
+
|
| 62 |
+
import logging
|
| 63 |
+
import re
|
| 64 |
+
from typing import Optional
|
| 65 |
+
|
| 66 |
+
from picarones.core.metric_registry import register_metric
|
| 67 |
+
from picarones.core.modules import ArtifactType
|
| 68 |
+
|
| 69 |
+
logger = logging.getLogger(__name__)
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 73 |
+
# Table de conversion + parsing
|
| 74 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 75 |
+
|
| 76 |
+
ROMAN_VALUES: dict[str, int] = {
|
| 77 |
+
"I": 1, "V": 5, "X": 10,
|
| 78 |
+
"L": 50, "C": 100, "D": 500, "M": 1000,
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
# Caractères acceptés en entrée (incluant minuscules + j médiéval).
|
| 82 |
+
_ROMAN_CHARS = "IVXLCDMivxlcdmj"
|
| 83 |
+
_ROMAN_RE = re.compile(rf"\b[{_ROMAN_CHARS}]+\b")
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def _normalize_roman(s: str) -> str:
|
| 87 |
+
"""Normalise un numéral romain : majuscule + ``j`` final → ``i``.
|
| 88 |
+
|
| 89 |
+
Les manuscrits médiévaux notent traditionnellement le dernier
|
| 90 |
+
``i`` d'une suite par ``j`` (« ij », « iij », « viij »…). On
|
| 91 |
+
convertit pour pouvoir parser comme un numéral standard.
|
| 92 |
+
"""
|
| 93 |
+
if not s:
|
| 94 |
+
return ""
|
| 95 |
+
upper = s.upper()
|
| 96 |
+
if upper.endswith("J"):
|
| 97 |
+
upper = upper[:-1] + "I"
|
| 98 |
+
return upper
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def _parse_normalized_roman(s: str) -> Optional[int]:
|
| 102 |
+
"""Parse un numéral romain **après normalisation** (majuscule,
|
| 103 |
+
sans ``j`` médiéval). Retourne ``None`` si la chaîne n'est pas
|
| 104 |
+
un numéral romain valide.
|
| 105 |
+
|
| 106 |
+
Validation : on parse en additionnant/soustrayant selon la règle
|
| 107 |
+
classique, puis on **regénère la forme standard** et on compare
|
| 108 |
+
pour rejeter les formes non canoniques (« IIII » au lieu de
|
| 109 |
+
« IV », « VV » au lieu de « X »). Cette stricte validation
|
| 110 |
+
garantit qu'on ne compte pas des séquences absurdes comme
|
| 111 |
+
« XXXX » comme un numéral.
|
| 112 |
+
|
| 113 |
+
Note : les manuscrits médiévaux utilisent fréquemment « IIII »
|
| 114 |
+
pour 4 (notation soustractive plus tardive). On accepte donc
|
| 115 |
+
aussi cette forme via une règle relâchée : tant que les valeurs
|
| 116 |
+
sont décroissantes ou suivent la règle soustractive standard,
|
| 117 |
+
on accepte.
|
| 118 |
+
"""
|
| 119 |
+
if not s or not all(c in "IVXLCDM" for c in s):
|
| 120 |
+
return None
|
| 121 |
+
# Calcul par soustraction.
|
| 122 |
+
total = 0
|
| 123 |
+
prev_value = 0
|
| 124 |
+
for ch in reversed(s):
|
| 125 |
+
v = ROMAN_VALUES[ch]
|
| 126 |
+
if v < prev_value:
|
| 127 |
+
total -= v
|
| 128 |
+
else:
|
| 129 |
+
total += v
|
| 130 |
+
prev_value = v
|
| 131 |
+
if total <= 0:
|
| 132 |
+
return None
|
| 133 |
+
# Validation relâchée : on accepte les formes médiévales (IIII,
|
| 134 |
+
# VIIII) mais on rejette les vraiment absurdes (IIIII, VVVV).
|
| 135 |
+
if not _is_plausible_roman(s):
|
| 136 |
+
return None
|
| 137 |
+
return total
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def _is_plausible_roman(s: str) -> bool:
|
| 141 |
+
"""Validation relâchée d'un numéral romain (majuscule).
|
| 142 |
+
|
| 143 |
+
On rejette :
|
| 144 |
+
|
| 145 |
+
- 5 caractères identiques d'affilée ou plus (« IIIII », « XXXXX »).
|
| 146 |
+
- Les répétitions de V, L, D (jamais répétés en notation
|
| 147 |
+
classique : « VV », « LL », « DD »).
|
| 148 |
+
- Les paires soustractives non standard. En romain canonique,
|
| 149 |
+
seules sont valides : IV, IX, XL, XC, CD, CM. Toute autre
|
| 150 |
+
combinaison « petit avant grand » est rejetée. Cela élimine
|
| 151 |
+
les faux positifs sur des mots français comme « ici » (qui
|
| 152 |
+
formerait sinon « I + C » = 99) ou « IL » qui formerait 49.
|
| 153 |
+
"""
|
| 154 |
+
if not s:
|
| 155 |
+
return False
|
| 156 |
+
# Pas de répétitions invalides
|
| 157 |
+
for forbidden in ("VV", "LL", "DD", "IIIII", "XXXXX", "CCCCC", "MMMMMM"):
|
| 158 |
+
if forbidden in s:
|
| 159 |
+
return False
|
| 160 |
+
# Paires soustractives autorisées (toutes les autres sont rejetées)
|
| 161 |
+
legal_subtractive = {"IV", "IX", "XL", "XC", "CD", "CM"}
|
| 162 |
+
for i in range(len(s) - 1):
|
| 163 |
+
a, b = s[i], s[i + 1]
|
| 164 |
+
if ROMAN_VALUES[a] < ROMAN_VALUES[b]:
|
| 165 |
+
if (a + b) not in legal_subtractive:
|
| 166 |
+
return False
|
| 167 |
+
return True
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
def roman_to_int(s: Optional[str]) -> Optional[int]:
|
| 171 |
+
"""Convertit une chaîne en numéral romain entier. Tolère casse
|
| 172 |
+
et ``j`` médiéval final. Retourne ``None`` si invalide.
|
| 173 |
+
"""
|
| 174 |
+
if not s:
|
| 175 |
+
return None
|
| 176 |
+
return _parse_normalized_roman(_normalize_roman(s))
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
def int_to_roman(n: int) -> str:
|
| 180 |
+
"""Convertit un entier en numéral romain majuscule standard.
|
| 181 |
+
|
| 182 |
+
Utilise la notation classique (IV, IX, XL, XC, CD, CM) — pas la
|
| 183 |
+
forme médiévale relâchée.
|
| 184 |
+
"""
|
| 185 |
+
if n <= 0:
|
| 186 |
+
raise ValueError("n must be positive")
|
| 187 |
+
pairs = [
|
| 188 |
+
(1000, "M"), (900, "CM"), (500, "D"), (400, "CD"),
|
| 189 |
+
(100, "C"), (90, "XC"), (50, "L"), (40, "XL"),
|
| 190 |
+
(10, "X"), (9, "IX"), (5, "V"), (4, "IV"),
|
| 191 |
+
(1, "I"),
|
| 192 |
+
]
|
| 193 |
+
out: list[str] = []
|
| 194 |
+
for value, symbol in pairs:
|
| 195 |
+
while n >= value:
|
| 196 |
+
out.append(symbol)
|
| 197 |
+
n -= value
|
| 198 |
+
return "".join(out)
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 202 |
+
# Détection dans le texte
|
| 203 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
def detect_roman_numerals(
|
| 207 |
+
text: Optional[str],
|
| 208 |
+
*,
|
| 209 |
+
min_length: int = 1,
|
| 210 |
+
) -> list[tuple[int, str, int]]:
|
| 211 |
+
"""Retourne les numéraux romains valides dans ``text``.
|
| 212 |
+
|
| 213 |
+
Forme : ``[(start_index, numeral_string, integer_value), ...]``
|
| 214 |
+
triée par index croissant.
|
| 215 |
+
|
| 216 |
+
Parameters
|
| 217 |
+
----------
|
| 218 |
+
text:
|
| 219 |
+
Texte à analyser.
|
| 220 |
+
min_length:
|
| 221 |
+
Longueur minimale d'un numéral retenu. Par défaut ``1``.
|
| 222 |
+
Mettre à ``2`` pour filtrer les single-letter ambigus (``I``
|
| 223 |
+
pronom, ``M`` initiale).
|
| 224 |
+
|
| 225 |
+
Faux positifs connus
|
| 226 |
+
--------------------
|
| 227 |
+
- ``I`` (pronom anglais), ``M`` ou ``D`` en initiale d'une
|
| 228 |
+
personne ne peuvent pas être distingués sans NER. Le chercheur
|
| 229 |
+
qui s'inquiète de ces faux positifs peut passer
|
| 230 |
+
``min_length=2``.
|
| 231 |
+
"""
|
| 232 |
+
if not text:
|
| 233 |
+
return []
|
| 234 |
+
found: list[tuple[int, str, int]] = []
|
| 235 |
+
for match in _ROMAN_RE.finditer(text):
|
| 236 |
+
s = match.group(0)
|
| 237 |
+
if len(s) < min_length:
|
| 238 |
+
continue
|
| 239 |
+
value = roman_to_int(s)
|
| 240 |
+
if value is None:
|
| 241 |
+
continue
|
| 242 |
+
found.append((match.start(), s, value))
|
| 243 |
+
return found
|
| 244 |
+
|
| 245 |
+
|
| 246 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 247 |
+
# Classification de la restitution dans l'hypothèse
|
| 248 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 249 |
+
|
| 250 |
+
# Statuts possibles, dans l'ordre de priorité (un numéral est
|
| 251 |
+
# classé selon le premier statut qui s'applique).
|
| 252 |
+
|
| 253 |
+
STATUS_STRICT_PRESERVED = "strict_preserved"
|
| 254 |
+
STATUS_CASE_CHANGED = "case_changed"
|
| 255 |
+
STATUS_J_DROPPED = "j_dropped"
|
| 256 |
+
STATUS_CONVERTED_TO_ARABIC = "converted_to_arabic"
|
| 257 |
+
STATUS_LOST = "lost"
|
| 258 |
+
|
| 259 |
+
ALL_STATUSES = (
|
| 260 |
+
STATUS_STRICT_PRESERVED,
|
| 261 |
+
STATUS_CASE_CHANGED,
|
| 262 |
+
STATUS_J_DROPPED,
|
| 263 |
+
STATUS_CONVERTED_TO_ARABIC,
|
| 264 |
+
STATUS_LOST,
|
| 265 |
+
)
|
| 266 |
+
|
| 267 |
+
# Statuts qui indiquent une préservation de la valeur (par opposition
|
| 268 |
+
# à la perte).
|
| 269 |
+
VALUE_PRESERVING_STATUSES = frozenset({
|
| 270 |
+
STATUS_STRICT_PRESERVED,
|
| 271 |
+
STATUS_CASE_CHANGED,
|
| 272 |
+
STATUS_J_DROPPED,
|
| 273 |
+
STATUS_CONVERTED_TO_ARABIC,
|
| 274 |
+
})
|
| 275 |
+
|
| 276 |
+
|
| 277 |
+
def _classify_restitution(numeral: str, value: int, hyp: str) -> str:
|
| 278 |
+
"""Classifie comment ``numeral`` (de valeur ``value``) est
|
| 279 |
+
restitué dans ``hyp`` selon les 5 statuts définis."""
|
| 280 |
+
# 1. Forme stricte présente
|
| 281 |
+
if re.search(r"(?<![A-Za-z])" + re.escape(numeral) + r"(?![A-Za-z])", hyp):
|
| 282 |
+
return STATUS_STRICT_PRESERVED
|
| 283 |
+
# 2. Variante de casse seule
|
| 284 |
+
swapped = numeral.swapcase()
|
| 285 |
+
if swapped != numeral and re.search(
|
| 286 |
+
r"(?<![A-Za-z])" + re.escape(swapped) + r"(?![A-Za-z])", hyp,
|
| 287 |
+
):
|
| 288 |
+
return STATUS_CASE_CHANGED
|
| 289 |
+
# 3. ``j`` final remplacé par ``i`` (ou inverse)
|
| 290 |
+
if numeral.lower().endswith("j"):
|
| 291 |
+
no_j = numeral[:-1] + ("I" if numeral[-1] == "J" else "i")
|
| 292 |
+
elif numeral.lower().endswith("i"):
|
| 293 |
+
no_j = numeral[:-1] + ("J" if numeral[-1] == "I" else "j")
|
| 294 |
+
else:
|
| 295 |
+
no_j = numeral
|
| 296 |
+
if no_j != numeral and re.search(
|
| 297 |
+
r"(?<![A-Za-z])" + re.escape(no_j) + r"(?![A-Za-z])", hyp,
|
| 298 |
+
):
|
| 299 |
+
return STATUS_J_DROPPED
|
| 300 |
+
# Variante de casse + j-flip combinés
|
| 301 |
+
no_j_swapped = no_j.swapcase()
|
| 302 |
+
if no_j_swapped != numeral and re.search(
|
| 303 |
+
r"(?<![A-Za-z])" + re.escape(no_j_swapped) + r"(?![A-Za-z])", hyp,
|
| 304 |
+
):
|
| 305 |
+
return STATUS_J_DROPPED
|
| 306 |
+
# 4. Conversion en chiffres arabes
|
| 307 |
+
if re.search(r"(?<!\d)" + str(value) + r"(?!\d)", hyp):
|
| 308 |
+
return STATUS_CONVERTED_TO_ARABIC
|
| 309 |
+
# 5. Perdu
|
| 310 |
+
return STATUS_LOST
|
| 311 |
+
|
| 312 |
+
|
| 313 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 314 |
+
# Calcul de la métrique
|
| 315 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 316 |
+
|
| 317 |
+
|
| 318 |
+
def compute_roman_numeral_metrics(
|
| 319 |
+
reference: Optional[str],
|
| 320 |
+
hypothesis: Optional[str],
|
| 321 |
+
*,
|
| 322 |
+
min_length: int = 1,
|
| 323 |
+
) -> dict:
|
| 324 |
+
"""Calcule la préservation des numéraux romains.
|
| 325 |
+
|
| 326 |
+
Pour chaque numéral romain dans la GT, on classifie sa
|
| 327 |
+
restitution dans l'hypothèse selon l'un des 5 statuts (forme
|
| 328 |
+
stricte / casse modifiée / j supprimé / conversion arabe / perdu).
|
| 329 |
+
|
| 330 |
+
Returns
|
| 331 |
+
-------
|
| 332 |
+
dict
|
| 333 |
+
``{
|
| 334 |
+
"n_numerals_reference": int,
|
| 335 |
+
"n_strict_preserved": int,
|
| 336 |
+
"n_value_preserved": int, # tous statuts sauf LOST
|
| 337 |
+
"global_strict_score": float,
|
| 338 |
+
"global_value_score": float,
|
| 339 |
+
"per_status": {status: count for status in ALL_STATUSES},
|
| 340 |
+
"per_numeral": [
|
| 341 |
+
{"index", "numeral", "value", "status"}
|
| 342 |
+
],
|
| 343 |
+
"lost_numerals": [
|
| 344 |
+
{"index", "numeral", "value"}
|
| 345 |
+
],
|
| 346 |
+
}``
|
| 347 |
+
|
| 348 |
+
Cas dégénérés
|
| 349 |
+
-------------
|
| 350 |
+
- GT vide ou sans numéral → tous compteurs à 0, scores à 0.0,
|
| 351 |
+
``per_status`` initialisé à 0 sur tous les statuts.
|
| 352 |
+
- GT avec numéraux + hyp vide → tous classés ``lost``,
|
| 353 |
+
strict_score = value_score = 0.0.
|
| 354 |
+
"""
|
| 355 |
+
ref = reference or ""
|
| 356 |
+
hyp = hypothesis or ""
|
| 357 |
+
|
| 358 |
+
detected = detect_roman_numerals(ref, min_length=min_length)
|
| 359 |
+
n_total = len(detected)
|
| 360 |
+
per_status_init = {status: 0 for status in ALL_STATUSES}
|
| 361 |
+
|
| 362 |
+
if n_total == 0:
|
| 363 |
+
return {
|
| 364 |
+
"n_numerals_reference": 0,
|
| 365 |
+
"n_strict_preserved": 0,
|
| 366 |
+
"n_value_preserved": 0,
|
| 367 |
+
"global_strict_score": 0.0,
|
| 368 |
+
"global_value_score": 0.0,
|
| 369 |
+
"per_status": per_status_init,
|
| 370 |
+
"per_numeral": [],
|
| 371 |
+
"lost_numerals": [],
|
| 372 |
+
}
|
| 373 |
+
|
| 374 |
+
per_status: dict[str, int] = dict(per_status_init)
|
| 375 |
+
per_numeral: list[dict] = []
|
| 376 |
+
lost: list[dict] = []
|
| 377 |
+
for index, numeral, value in detected:
|
| 378 |
+
status = _classify_restitution(numeral, value, hyp)
|
| 379 |
+
per_status[status] = per_status.get(status, 0) + 1
|
| 380 |
+
per_numeral.append({
|
| 381 |
+
"index": index,
|
| 382 |
+
"numeral": numeral,
|
| 383 |
+
"value": value,
|
| 384 |
+
"status": status,
|
| 385 |
+
})
|
| 386 |
+
if status == STATUS_LOST:
|
| 387 |
+
lost.append({"index": index, "numeral": numeral, "value": value})
|
| 388 |
+
|
| 389 |
+
n_strict = per_status[STATUS_STRICT_PRESERVED]
|
| 390 |
+
n_value = sum(per_status[s] for s in VALUE_PRESERVING_STATUSES)
|
| 391 |
+
|
| 392 |
+
return {
|
| 393 |
+
"n_numerals_reference": n_total,
|
| 394 |
+
"n_strict_preserved": n_strict,
|
| 395 |
+
"n_value_preserved": n_value,
|
| 396 |
+
"global_strict_score": n_strict / n_total,
|
| 397 |
+
"global_value_score": n_value / n_total,
|
| 398 |
+
"per_status": per_status,
|
| 399 |
+
"per_numeral": per_numeral,
|
| 400 |
+
"lost_numerals": lost,
|
| 401 |
+
}
|
| 402 |
+
|
| 403 |
+
|
| 404 |
+
def roman_numeral_strict_score(
|
| 405 |
+
reference: Optional[str], hypothesis: Optional[str],
|
| 406 |
+
) -> float:
|
| 407 |
+
"""Raccourci : taux global de préservation **stricte** des
|
| 408 |
+
numéraux romains ∈ [0, 1]."""
|
| 409 |
+
return compute_roman_numeral_metrics(
|
| 410 |
+
reference, hypothesis,
|
| 411 |
+
)["global_strict_score"]
|
| 412 |
+
|
| 413 |
+
|
| 414 |
+
def roman_numeral_value_score(
|
| 415 |
+
reference: Optional[str], hypothesis: Optional[str],
|
| 416 |
+
) -> float:
|
| 417 |
+
"""Raccourci : taux global de préservation de la **valeur** des
|
| 418 |
+
numéraux romains (toute forme confondue : strict, case_changed,
|
| 419 |
+
j_dropped, arabe) ∈ [0, 1]."""
|
| 420 |
+
return compute_roman_numeral_metrics(
|
| 421 |
+
reference, hypothesis,
|
| 422 |
+
)["global_value_score"]
|
| 423 |
+
|
| 424 |
+
|
| 425 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 426 |
+
# Enregistrement dans le registre typé (Sprint 34)
|
| 427 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 428 |
+
|
| 429 |
+
|
| 430 |
+
@register_metric(
|
| 431 |
+
name="roman_numeral_strict_score",
|
| 432 |
+
input_types=(ArtifactType.TEXT, ArtifactType.TEXT),
|
| 433 |
+
description=(
|
| 434 |
+
"Taux de préservation stricte des numéraux romains "
|
| 435 |
+
"(forme exacte gardée : casse, j médiéval final). "
|
| 436 |
+
"Métrique transversale aux périodes médiévale, imprimé "
|
| 437 |
+
"ancien et moderne."
|
| 438 |
+
),
|
| 439 |
+
higher_is_better=True,
|
| 440 |
+
tags={"text", "roman_numerals", "philology"},
|
| 441 |
+
)
|
| 442 |
+
def _registered_strict(reference: str, hypothesis: str) -> float:
|
| 443 |
+
return roman_numeral_strict_score(reference, hypothesis)
|
| 444 |
+
|
| 445 |
+
|
| 446 |
+
@register_metric(
|
| 447 |
+
name="roman_numeral_value_score",
|
| 448 |
+
input_types=(ArtifactType.TEXT, ArtifactType.TEXT),
|
| 449 |
+
description=(
|
| 450 |
+
"Taux de préservation de la valeur numérique des numéraux "
|
| 451 |
+
"romains, indépendamment de la forme (strict, casse "
|
| 452 |
+
"changée, j supprimé, conversion en chiffres arabes). "
|
| 453 |
+
"Le breakdown per_status permet au chercheur de juger la "
|
| 454 |
+
"convention adoptée."
|
| 455 |
+
),
|
| 456 |
+
higher_is_better=True,
|
| 457 |
+
tags={"text", "roman_numerals", "philology"},
|
| 458 |
+
)
|
| 459 |
+
def _registered_value(reference: str, hypothesis: str) -> float:
|
| 460 |
+
return roman_numeral_value_score(reference, hypothesis)
|
| 461 |
+
|
| 462 |
+
|
| 463 |
+
__all__ = [
|
| 464 |
+
"ROMAN_VALUES",
|
| 465 |
+
"ALL_STATUSES",
|
| 466 |
+
"STATUS_STRICT_PRESERVED",
|
| 467 |
+
"STATUS_CASE_CHANGED",
|
| 468 |
+
"STATUS_J_DROPPED",
|
| 469 |
+
"STATUS_CONVERTED_TO_ARABIC",
|
| 470 |
+
"STATUS_LOST",
|
| 471 |
+
"VALUE_PRESERVING_STATUSES",
|
| 472 |
+
"compute_roman_numeral_metrics",
|
| 473 |
+
"detect_roman_numerals",
|
| 474 |
+
"int_to_roman",
|
| 475 |
+
"roman_numeral_strict_score",
|
| 476 |
+
"roman_numeral_value_score",
|
| 477 |
+
"roman_to_int",
|
| 478 |
+
]
|
|
@@ -0,0 +1,233 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Précision par bloc Unicode — Sprint 55.
|
| 2 |
+
|
| 3 |
+
Sprint 55 — A.II.3.1 du plan d'évolution 2026 (métriques philologiques).
|
| 4 |
+
|
| 5 |
+
Pourquoi ce module
|
| 6 |
+
------------------
|
| 7 |
+
Pour un éditeur d'imprimés anciens ou un médiéviste, la question
|
| 8 |
+
n'est pas seulement *« quel CER global ? »* mais *« quels caractères
|
| 9 |
+
historiques ce moteur restitue-t-il fidèlement ? »*. Une phrase de
|
| 10 |
+
synthèse actionnable en un coup d'œil :
|
| 11 |
+
|
| 12 |
+
> *« GPT-4o restitue 95 % du Latin de Base mais seulement 12 % des
|
| 13 |
+
> formes de présentation latine (fi, fl, ſ…). »*
|
| 14 |
+
|
| 15 |
+
Ce module agrège la précision par **bloc Unicode standard** (Latin de
|
| 16 |
+
Base, Latin Étendu A/B, Diacritiques combinants, Présentation latine,
|
| 17 |
+
etc.). Le résultat permet directement de choisir un moteur selon le
|
| 18 |
+
type de glyphes attendus dans le corpus.
|
| 19 |
+
|
| 20 |
+
Stratégie de découpage
|
| 21 |
+
----------------------
|
| 22 |
+
Cohérente avec NER (Sprint 38), Flesch (Sprint 52), Reading order F1
|
| 23 |
+
(Sprint 53), Layout F1 (Sprint 54) : couche de calcul pure d'abord.
|
| 24 |
+
Le câblage runner et la vue HTML suivent dans des sprints dédiés.
|
| 25 |
+
|
| 26 |
+
Convention d'alignement
|
| 27 |
+
-----------------------
|
| 28 |
+
Alignement caractère par caractère via ``difflib.SequenceMatcher`` :
|
| 29 |
+
|
| 30 |
+
- chaque caractère de la GT est classé dans son bloc Unicode,
|
| 31 |
+
- pour chaque position GT couverte par un opcode ``equal`` →
|
| 32 |
+
+1 dans ``correct[bloc]``,
|
| 33 |
+
- pour chaque position GT non couverte (replace, delete) → +0,
|
| 34 |
+
- les insertions côté hypothèse (caractères absents de la GT) ne
|
| 35 |
+
contribuent à aucun bloc — elles sont visibles uniquement via le
|
| 36 |
+
CER global.
|
| 37 |
+
|
| 38 |
+
Précision par bloc = ``correct[bloc] / total[bloc]``.
|
| 39 |
+
|
| 40 |
+
Liste des blocs reconnus
|
| 41 |
+
------------------------
|
| 42 |
+
Centrée sur les glyphes courants des corpus patrimoniaux européens.
|
| 43 |
+
Tout caractère hors de cette table est classé dans ``"Other"``
|
| 44 |
+
(garantit une couverture exhaustive : ``sum(total[bloc]) ==
|
| 45 |
+
len(GT)``).
|
| 46 |
+
"""
|
| 47 |
+
|
| 48 |
+
from __future__ import annotations
|
| 49 |
+
|
| 50 |
+
import logging
|
| 51 |
+
from difflib import SequenceMatcher
|
| 52 |
+
from typing import Optional
|
| 53 |
+
|
| 54 |
+
from picarones.core.metric_registry import register_metric
|
| 55 |
+
from picarones.core.modules import ArtifactType
|
| 56 |
+
|
| 57 |
+
logger = logging.getLogger(__name__)
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 61 |
+
# Table des blocs Unicode reconnus
|
| 62 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 63 |
+
|
| 64 |
+
# Triplets (nom, code_point_min, code_point_max) — bornes inclusives.
|
| 65 |
+
# Centré sur les blocs pertinents pour les corpus patrimoniaux
|
| 66 |
+
# européens (manuscrits médiévaux, imprimés anciens, archives).
|
| 67 |
+
# Source : https://www.unicode.org/charts/
|
| 68 |
+
_UNICODE_BLOCKS: tuple[tuple[str, int, int], ...] = (
|
| 69 |
+
("Basic Latin", 0x0000, 0x007F),
|
| 70 |
+
("Latin-1 Supplement", 0x0080, 0x00FF),
|
| 71 |
+
("Latin Extended-A", 0x0100, 0x017F),
|
| 72 |
+
("Latin Extended-B", 0x0180, 0x024F),
|
| 73 |
+
("IPA Extensions", 0x0250, 0x02AF),
|
| 74 |
+
("Spacing Modifier Letters", 0x02B0, 0x02FF),
|
| 75 |
+
("Combining Diacritical Marks", 0x0300, 0x036F),
|
| 76 |
+
("Greek and Coptic", 0x0370, 0x03FF),
|
| 77 |
+
("Cyrillic", 0x0400, 0x04FF),
|
| 78 |
+
("Hebrew", 0x0590, 0x05FF),
|
| 79 |
+
("Arabic", 0x0600, 0x06FF),
|
| 80 |
+
("General Punctuation", 0x2000, 0x206F),
|
| 81 |
+
("Superscripts and Subscripts", 0x2070, 0x209F),
|
| 82 |
+
("Currency Symbols", 0x20A0, 0x20CF),
|
| 83 |
+
("Combining Diacritical Marks Supplement", 0x1DC0, 0x1DFF),
|
| 84 |
+
("Latin Extended Additional", 0x1E00, 0x1EFF),
|
| 85 |
+
("Latin Extended-C", 0x2C60, 0x2C7F),
|
| 86 |
+
("Latin Extended-D", 0xA720, 0xA7FF), # médiéval
|
| 87 |
+
("Latin Extended-E", 0xAB30, 0xAB6F),
|
| 88 |
+
("Alphabetic Presentation Forms", 0xFB00, 0xFB4F), # fi, fl, ff…
|
| 89 |
+
("Mathematical Alphanumeric Symbols", 0x1D400, 0x1D7FF),
|
| 90 |
+
("Medieval Unicode Font Initiative (MUFI)", 0xE000, 0xF8FF), # PUA
|
| 91 |
+
)
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def get_block(char: str) -> str:
|
| 95 |
+
"""Retourne le nom du bloc Unicode contenant ``char``.
|
| 96 |
+
|
| 97 |
+
Pour un caractère hors des blocs listés (ex. CJK, emoji, etc.),
|
| 98 |
+
retourne ``"Other"``. Pour une chaîne multi-caractères, on
|
| 99 |
+
considère uniquement le premier code-point.
|
| 100 |
+
"""
|
| 101 |
+
if not char:
|
| 102 |
+
return "Other"
|
| 103 |
+
cp = ord(char[0])
|
| 104 |
+
for name, lo, hi in _UNICODE_BLOCKS:
|
| 105 |
+
if lo <= cp <= hi:
|
| 106 |
+
return name
|
| 107 |
+
return "Other"
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
# ─────────────────────────────────────���────────────────────────────────────
|
| 111 |
+
# Calcul d'accuracy par bloc
|
| 112 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def compute_unicode_block_accuracy(
|
| 116 |
+
reference: Optional[str],
|
| 117 |
+
hypothesis: Optional[str],
|
| 118 |
+
) -> dict:
|
| 119 |
+
"""Calcule la précision (recall caractère) par bloc Unicode.
|
| 120 |
+
|
| 121 |
+
Parameters
|
| 122 |
+
----------
|
| 123 |
+
reference:
|
| 124 |
+
Texte GT. Chaque caractère est classé dans son bloc Unicode.
|
| 125 |
+
hypothesis:
|
| 126 |
+
Texte produit par le moteur OCR.
|
| 127 |
+
|
| 128 |
+
Returns
|
| 129 |
+
-------
|
| 130 |
+
dict
|
| 131 |
+
``{
|
| 132 |
+
"per_block": {
|
| 133 |
+
bloc_name: {
|
| 134 |
+
"correct": int, # caractères GT correctement restitués
|
| 135 |
+
"total": int, # caractères GT du bloc
|
| 136 |
+
"accuracy": float, # correct / total ∈ [0, 1]
|
| 137 |
+
},
|
| 138 |
+
...
|
| 139 |
+
},
|
| 140 |
+
"global_accuracy": float, # somme(correct) / somme(total)
|
| 141 |
+
"n_chars_reference": int,
|
| 142 |
+
}``
|
| 143 |
+
|
| 144 |
+
Cas dégénérés
|
| 145 |
+
-------------
|
| 146 |
+
- GT vide → ``per_block`` vide, ``global_accuracy = 0.0``,
|
| 147 |
+
``n_chars_reference = 0``.
|
| 148 |
+
- hypothèse vide + GT non-vide → tous les blocs à
|
| 149 |
+
``accuracy = 0``.
|
| 150 |
+
- GT et hyp identiques → tous les blocs à ``accuracy = 1``.
|
| 151 |
+
"""
|
| 152 |
+
ref = reference or ""
|
| 153 |
+
hyp = hypothesis or ""
|
| 154 |
+
n_ref = len(ref)
|
| 155 |
+
|
| 156 |
+
if n_ref == 0:
|
| 157 |
+
return {
|
| 158 |
+
"per_block": {},
|
| 159 |
+
"global_accuracy": 0.0,
|
| 160 |
+
"n_chars_reference": 0,
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
# 1. Compter le total par bloc
|
| 164 |
+
total: dict[str, int] = {}
|
| 165 |
+
for ch in ref:
|
| 166 |
+
b = get_block(ch)
|
| 167 |
+
total[b] = total.get(b, 0) + 1
|
| 168 |
+
|
| 169 |
+
# 2. Aligner par opcodes de SequenceMatcher
|
| 170 |
+
# Pour chaque opcode ``equal``, les positions ``i1..i2-1`` du GT
|
| 171 |
+
# sont correctement restituées → +1 par caractère dans son bloc.
|
| 172 |
+
correct: dict[str, int] = {b: 0 for b in total}
|
| 173 |
+
matcher = SequenceMatcher(a=ref, b=hyp, autojunk=False)
|
| 174 |
+
for op, i1, i2, _j1, _j2 in matcher.get_opcodes():
|
| 175 |
+
if op != "equal":
|
| 176 |
+
continue
|
| 177 |
+
for i in range(i1, i2):
|
| 178 |
+
b = get_block(ref[i])
|
| 179 |
+
correct[b] = correct.get(b, 0) + 1
|
| 180 |
+
|
| 181 |
+
per_block: dict[str, dict] = {}
|
| 182 |
+
for b in sorted(total):
|
| 183 |
+
n = total[b]
|
| 184 |
+
c = correct.get(b, 0)
|
| 185 |
+
per_block[b] = {
|
| 186 |
+
"correct": c,
|
| 187 |
+
"total": n,
|
| 188 |
+
"accuracy": c / n if n > 0 else 0.0,
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
n_correct_total = sum(d["correct"] for d in per_block.values())
|
| 192 |
+
return {
|
| 193 |
+
"per_block": per_block,
|
| 194 |
+
"global_accuracy": n_correct_total / n_ref,
|
| 195 |
+
"n_chars_reference": n_ref,
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
def unicode_block_global_accuracy(
|
| 200 |
+
reference: Optional[str],
|
| 201 |
+
hypothesis: Optional[str],
|
| 202 |
+
) -> float:
|
| 203 |
+
"""Raccourci : retourne ``global_accuracy`` (fraction de
|
| 204 |
+
caractères GT correctement restitués)."""
|
| 205 |
+
return compute_unicode_block_accuracy(reference, hypothesis)["global_accuracy"]
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 209 |
+
# Enregistrement dans le registre typé (Sprint 34)
|
| 210 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
@register_metric(
|
| 214 |
+
name="unicode_block_global_accuracy",
|
| 215 |
+
input_types=(ArtifactType.TEXT, ArtifactType.TEXT),
|
| 216 |
+
description=(
|
| 217 |
+
"Fraction de caractères GT correctement restitués par "
|
| 218 |
+
"l'OCR (alignement caractère par caractère via difflib). "
|
| 219 |
+
"Pour le détail par bloc Unicode (Latin de Base, Présentation "
|
| 220 |
+
"latine, etc.), utiliser compute_unicode_block_accuracy."
|
| 221 |
+
),
|
| 222 |
+
higher_is_better=True,
|
| 223 |
+
tags={"text", "unicode", "philology"},
|
| 224 |
+
)
|
| 225 |
+
def _registered_global_accuracy(reference: str, hypothesis: str) -> float:
|
| 226 |
+
return unicode_block_global_accuracy(reference, hypothesis)
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
__all__ = [
|
| 230 |
+
"get_block",
|
| 231 |
+
"compute_unicode_block_accuracy",
|
| 232 |
+
"unicode_block_global_accuracy",
|
| 233 |
+
]
|
|
@@ -0,0 +1,119 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Rendu HTML de la vue « Modernisation lexicale » — Sprint 80.
|
| 2 |
+
|
| 3 |
+
A.I.7 du plan d'évolution 2026.
|
| 4 |
+
|
| 5 |
+
Suite directe ``picarones/core/lexical_modernization.py``.
|
| 6 |
+
Pattern identique aux autres rendus (Sprints 41/43/62/67/72/74/75/76/77) :
|
| 7 |
+
**server-side**, pas de JavaScript, anti-injection systématique.
|
| 8 |
+
|
| 9 |
+
Vue
|
| 10 |
+
---
|
| 11 |
+
Tableau trié par taux de modernisation décroissant : forme
|
| 12 |
+
historique GT → forme(s) modernisée(s), occurrences GT, %.
|
| 13 |
+
Couleur de cellule pour le %.
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
from html import escape as _e
|
| 19 |
+
from typing import Optional
|
| 20 |
+
|
| 21 |
+
from picarones.core.lexical_modernization import top_modernized_tokens
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def _color_for_rate(rate: float) -> str:
|
| 25 |
+
"""Gradient blanc → orange profond pour rate ∈ [0, 1]."""
|
| 26 |
+
f = max(0.0, min(1.0, rate))
|
| 27 |
+
r = int(255 + (194 - 255) * f)
|
| 28 |
+
g = int(255 + (65 - 255) * f)
|
| 29 |
+
b = int(255 + (12 - 255) * f)
|
| 30 |
+
return f"#{r:02x}{g:02x}{b:02x}"
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def _format_variants(variants: dict, max_show: int = 3) -> str:
|
| 34 |
+
"""Liste compacte des variants modernisés."""
|
| 35 |
+
items = sorted(variants.items(), key=lambda kv: -kv[1])
|
| 36 |
+
shown = items[:max_show]
|
| 37 |
+
rest = len(items) - max_show
|
| 38 |
+
parts = [
|
| 39 |
+
f"{_e(form)} ({count})"
|
| 40 |
+
for form, count in shown
|
| 41 |
+
]
|
| 42 |
+
if rest > 0:
|
| 43 |
+
parts.append(f"+{rest}")
|
| 44 |
+
return ", ".join(parts)
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def build_lexical_modernization_html(
|
| 48 |
+
data: Optional[dict],
|
| 49 |
+
labels: Optional[dict[str, str]] = None,
|
| 50 |
+
*,
|
| 51 |
+
top_n: int = 20,
|
| 52 |
+
min_total: int = 1,
|
| 53 |
+
) -> str:
|
| 54 |
+
"""Construit la table HTML de modernisation lexicale.
|
| 55 |
+
|
| 56 |
+
Retourne ``""`` si ``data is None`` ou si aucun token modernisé.
|
| 57 |
+
"""
|
| 58 |
+
if not data:
|
| 59 |
+
return ""
|
| 60 |
+
rows = top_modernized_tokens(data, n=top_n, min_total=min_total)
|
| 61 |
+
if not rows:
|
| 62 |
+
return ""
|
| 63 |
+
labels = labels or {}
|
| 64 |
+
title = labels.get(
|
| 65 |
+
"lexmod_title", "Modernisation lexicale (top tokens)",
|
| 66 |
+
)
|
| 67 |
+
note = labels.get(
|
| 68 |
+
"lexmod_note",
|
| 69 |
+
"Tokens GT que le moteur réécrit le plus souvent. "
|
| 70 |
+
"Lecture : « maistre → maître modernisé dans 85 % des cas » "
|
| 71 |
+
"indique de quoi corriger dans le prompt pour préserver "
|
| 72 |
+
"l'orthographe historique.",
|
| 73 |
+
)
|
| 74 |
+
gt_label = labels.get("lexmod_gt_label", "Forme historique GT")
|
| 75 |
+
hyp_label = labels.get("lexmod_hyp_label", "Variantes OCR")
|
| 76 |
+
n_label = labels.get("lexmod_n_label", "n GT")
|
| 77 |
+
rate_label = labels.get("lexmod_rate_label", "% modernisé")
|
| 78 |
+
|
| 79 |
+
parts = [
|
| 80 |
+
'<div class="lexmod" style="margin:1rem 0">',
|
| 81 |
+
f'<div style="font-weight:600;margin-bottom:.4rem">{_e(title)}</div>',
|
| 82 |
+
f'<div style="font-size:.85rem;opacity:.75;margin-bottom:.5rem">'
|
| 83 |
+
f'{_e(note)}</div>',
|
| 84 |
+
'<table style="border-collapse:collapse;width:100%;'
|
| 85 |
+
'font-size:.85rem">',
|
| 86 |
+
'<thead><tr>',
|
| 87 |
+
]
|
| 88 |
+
for col in (gt_label, hyp_label, n_label, rate_label):
|
| 89 |
+
parts.append(
|
| 90 |
+
f'<th style="padding:.3rem .5rem;text-align:left;'
|
| 91 |
+
f'border-bottom:1px solid #ccc;font-weight:600">'
|
| 92 |
+
f'{_e(col)}</th>'
|
| 93 |
+
)
|
| 94 |
+
parts.append("</tr></thead><tbody>")
|
| 95 |
+
for gt_token, slot in rows:
|
| 96 |
+
rate = slot.get("rate_modernized", 0.0)
|
| 97 |
+
n_total = slot.get("n_total", 0)
|
| 98 |
+
variants_str = _format_variants(slot.get("variants") or {})
|
| 99 |
+
rate_color = _color_for_rate(rate)
|
| 100 |
+
parts.append(
|
| 101 |
+
f'<tr>'
|
| 102 |
+
f'<td style="padding:.3rem .5rem;font-family:monospace">'
|
| 103 |
+
f'{_e(gt_token)}</td>'
|
| 104 |
+
f'<td style="padding:.3rem .5rem;font-size:.85rem">'
|
| 105 |
+
f'{variants_str}</td>'
|
| 106 |
+
f'<td style="padding:.3rem .5rem;text-align:right;'
|
| 107 |
+
f'font-family:monospace">{n_total}</td>'
|
| 108 |
+
f'<td style="padding:.3rem .5rem;text-align:right;'
|
| 109 |
+
f'background:{rate_color};font-family:monospace">'
|
| 110 |
+
f'{rate * 100:.0f}%</td>'
|
| 111 |
+
f'</tr>'
|
| 112 |
+
)
|
| 113 |
+
parts.append("</tbody></table></div>")
|
| 114 |
+
return "".join(parts)
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
__all__ = [
|
| 118 |
+
"build_lexical_modernization_html",
|
| 119 |
+
]
|
|
@@ -0,0 +1,615 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Rendu HTML server-side du profil philologique (Sprint 62).
|
| 2 |
+
|
| 3 |
+
Suite directe Sprint 61 (câblage backend) — produit les blocs HTML
|
| 4 |
+
qui exposent les six modules philologiques (Sprints 55-60) dans le
|
| 5 |
+
rapport :
|
| 6 |
+
|
| 7 |
+
- ``unicode_blocks`` (Sprint 55) — précision par bloc Unicode
|
| 8 |
+
- ``abbreviations`` (Sprint 56) — score strict + expansion par
|
| 9 |
+
abréviation médiévale Capelli
|
| 10 |
+
- ``mufi`` (Sprint 57) — couverture MUFI globale + par
|
| 11 |
+
caractère
|
| 12 |
+
- ``early_modern`` (Sprint 58) — préservation des marqueurs
|
| 13 |
+
typographiques imprimé ancien
|
| 14 |
+
- ``modern_archives`` (Sprint 59) — strict + expansion par
|
| 15 |
+
catégorie d'archive moderne
|
| 16 |
+
- ``roman_numerals`` (Sprint 60) — breakdown 5 statuts de
|
| 17 |
+
restitution
|
| 18 |
+
|
| 19 |
+
Principe identique aux Sprints 41 (NER) et 43 (calibration) :
|
| 20 |
+
|
| 21 |
+
- Rendu **server-side**, pas de JavaScript, déterministe.
|
| 22 |
+
- Section adaptive : si aucun moteur n'a de signal pour un module
|
| 23 |
+
donné, la sous-section est silencieusement omise.
|
| 24 |
+
- Si **aucun module** n'a de signal sur l'ensemble des moteurs,
|
| 25 |
+
``build_philological_profile_html`` retourne une chaîne vide et
|
| 26 |
+
le bloc complet n'apparaît pas dans la vue analyses.
|
| 27 |
+
- **Aucune classification automatique** : on affiche les chiffres
|
| 28 |
+
bruts par catégorie/bloc/statut, le chercheur juge lui-même la
|
| 29 |
+
convention adoptée.
|
| 30 |
+
- Anti-injection : tous les noms de moteurs, catégories, statuts,
|
| 31 |
+
caractères passent par ``html.escape`` avant insertion.
|
| 32 |
+
"""
|
| 33 |
+
|
| 34 |
+
from __future__ import annotations
|
| 35 |
+
|
| 36 |
+
from html import escape as _e
|
| 37 |
+
from typing import Optional
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 41 |
+
# Helpers de coloration
|
| 42 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def _color_for_score(score: float) -> str:
|
| 46 |
+
"""Gradient rouge → jaune → vert proportionnel à ``score`` ∈ [0, 1].
|
| 47 |
+
|
| 48 |
+
Identique à ``ner_render._color_for_f1``. Les scores
|
| 49 |
+
philologiques (preservation, coverage, accuracy) suivent la même
|
| 50 |
+
sémantique « plus c'est haut, mieux c'est » donc le gradient
|
| 51 |
+
est valide.
|
| 52 |
+
"""
|
| 53 |
+
f = max(0.0, min(1.0, score))
|
| 54 |
+
if f <= 0.5:
|
| 55 |
+
ratio = f / 0.5
|
| 56 |
+
r = int(220 + (240 - 220) * ratio)
|
| 57 |
+
g = int(100 + (220 - 100) * ratio)
|
| 58 |
+
b = int(100 + (130 - 100) * ratio)
|
| 59 |
+
else:
|
| 60 |
+
ratio = (f - 0.5) / 0.5
|
| 61 |
+
r = int(240 + (130 - 240) * ratio)
|
| 62 |
+
g = int(220 + (200 - 220) * ratio)
|
| 63 |
+
b = int(130 + (130 - 130) * ratio)
|
| 64 |
+
return f"#{r:02x}{g:02x}{b:02x}"
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def _engines_with_module(
|
| 68 |
+
engines_summary: list[dict], module: str,
|
| 69 |
+
) -> list[dict]:
|
| 70 |
+
"""Filtre les moteurs ayant des données pour le module donné."""
|
| 71 |
+
out: list[dict] = []
|
| 72 |
+
for eng in engines_summary:
|
| 73 |
+
agg = eng.get("aggregated_philological") or {}
|
| 74 |
+
if module in agg and agg[module]:
|
| 75 |
+
out.append(eng)
|
| 76 |
+
return out
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def _score_cell(score: Optional[float], extra: str = "") -> str:
|
| 80 |
+
"""Rend une cellule colorée. ``None`` → cellule grise « — »."""
|
| 81 |
+
if score is None:
|
| 82 |
+
return (
|
| 83 |
+
'<td style="padding:.3rem .5rem;text-align:center;'
|
| 84 |
+
'background:#f0f0f0;color:#999">—</td>'
|
| 85 |
+
)
|
| 86 |
+
color = _color_for_score(score)
|
| 87 |
+
text = f"{score * 100:.1f}%"
|
| 88 |
+
if extra:
|
| 89 |
+
text += f" <span style=\"opacity:.6;font-size:.85em\">({_e(extra)})</span>"
|
| 90 |
+
return (
|
| 91 |
+
f'<td style="padding:.3rem .5rem;text-align:center;'
|
| 92 |
+
f'background:{color}">{text}</td>'
|
| 93 |
+
)
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def _table_header(
|
| 97 |
+
columns: list[str], engine_label: str,
|
| 98 |
+
) -> str:
|
| 99 |
+
"""Construit l'entête d'un tableau moteur × colonnes."""
|
| 100 |
+
parts = [
|
| 101 |
+
'<thead><tr>',
|
| 102 |
+
f'<th style="padding:.3rem .5rem;text-align:left;'
|
| 103 |
+
f'border-bottom:1px solid var(--border);font-weight:600">'
|
| 104 |
+
f'{_e(engine_label)}</th>',
|
| 105 |
+
]
|
| 106 |
+
for col in columns:
|
| 107 |
+
parts.append(
|
| 108 |
+
f'<th style="padding:.3rem .5rem;text-align:center;'
|
| 109 |
+
f'border-bottom:1px solid var(--border);font-weight:600">'
|
| 110 |
+
f'{_e(col)}</th>'
|
| 111 |
+
)
|
| 112 |
+
parts.append('</tr></thead>')
|
| 113 |
+
return "".join(parts)
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def _engine_label_cell(name: str) -> str:
|
| 117 |
+
return (
|
| 118 |
+
f'<td style="padding:.3rem .5rem;font-weight:500;'
|
| 119 |
+
f'border-bottom:1px solid var(--border-light)">{_e(name)}</td>'
|
| 120 |
+
)
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
def _section_open(title: str, note: str = "") -> str:
|
| 124 |
+
parts = [
|
| 125 |
+
'<div class="philological-section" '
|
| 126 |
+
'style="margin:1rem 0;padding:.75rem;'
|
| 127 |
+
'background:var(--bg-secondary);border-radius:6px">',
|
| 128 |
+
f'<div style="font-weight:600;margin-bottom:.4rem">{_e(title)}</div>',
|
| 129 |
+
]
|
| 130 |
+
if note:
|
| 131 |
+
parts.append(
|
| 132 |
+
f'<div style="font-size:.8rem;opacity:.75;margin-bottom:.5rem">'
|
| 133 |
+
f'{_e(note)}</div>'
|
| 134 |
+
)
|
| 135 |
+
return "".join(parts)
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def _section_close() -> str:
|
| 139 |
+
return "</div>"
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def _table_open() -> str:
|
| 143 |
+
return (
|
| 144 |
+
'<table style="border-collapse:collapse;width:100%;'
|
| 145 |
+
'font-size:.85rem">'
|
| 146 |
+
)
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
def _table_close() -> str:
|
| 150 |
+
return "</table>"
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 154 |
+
# Sprint 55 — Précision par bloc Unicode
|
| 155 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
def build_unicode_blocks_section(
|
| 159 |
+
engines_summary: list[dict],
|
| 160 |
+
labels: Optional[dict[str, str]] = None,
|
| 161 |
+
) -> str:
|
| 162 |
+
relevant = _engines_with_module(engines_summary, "unicode_blocks")
|
| 163 |
+
if not relevant:
|
| 164 |
+
return ""
|
| 165 |
+
labels = labels or {}
|
| 166 |
+
title = labels.get(
|
| 167 |
+
"philo_unicode_blocks_title", "Précision par bloc Unicode",
|
| 168 |
+
)
|
| 169 |
+
note = labels.get(
|
| 170 |
+
"philo_unicode_blocks_note",
|
| 171 |
+
"Pourcentage de caractères correctement restitués par bloc "
|
| 172 |
+
"Unicode rencontré dans la GT (hors Basic Latin).",
|
| 173 |
+
)
|
| 174 |
+
engine_label = labels.get("philo_engine_label", "Moteur")
|
| 175 |
+
global_label = labels.get("philo_global_label", "Global")
|
| 176 |
+
|
| 177 |
+
# Collecte tous les blocs présents (hors Basic Latin déjà filtré
|
| 178 |
+
# par adaptive masking, mais on défilte ici si Basic Latin
|
| 179 |
+
# apparaît malgré tout chez certains moteurs).
|
| 180 |
+
all_blocks: set[str] = set()
|
| 181 |
+
for eng in relevant:
|
| 182 |
+
per_block = eng["aggregated_philological"]["unicode_blocks"].get(
|
| 183 |
+
"per_block", {},
|
| 184 |
+
)
|
| 185 |
+
for block in per_block:
|
| 186 |
+
if block != "Basic Latin":
|
| 187 |
+
all_blocks.add(block)
|
| 188 |
+
blocks = sorted(all_blocks)
|
| 189 |
+
if not blocks:
|
| 190 |
+
return ""
|
| 191 |
+
|
| 192 |
+
parts = [_section_open(title, note), _table_open()]
|
| 193 |
+
parts.append(_table_header([global_label] + blocks, engine_label))
|
| 194 |
+
parts.append("<tbody>")
|
| 195 |
+
for eng in relevant:
|
| 196 |
+
agg = eng["aggregated_philological"]["unicode_blocks"]
|
| 197 |
+
global_acc = agg.get("global_accuracy", 0.0)
|
| 198 |
+
n_chars = agg.get("n_chars_total", 0)
|
| 199 |
+
parts.append("<tr>")
|
| 200 |
+
parts.append(_engine_label_cell(eng["name"]))
|
| 201 |
+
parts.append(_score_cell(global_acc, extra=f"n={n_chars}"))
|
| 202 |
+
per_block = agg.get("per_block", {})
|
| 203 |
+
for block in blocks:
|
| 204 |
+
stats = per_block.get(block)
|
| 205 |
+
if stats and stats.get("total", 0) > 0:
|
| 206 |
+
parts.append(_score_cell(
|
| 207 |
+
stats["accuracy"], extra=f"n={stats['total']}",
|
| 208 |
+
))
|
| 209 |
+
else:
|
| 210 |
+
parts.append(_score_cell(None))
|
| 211 |
+
parts.append("</tr>")
|
| 212 |
+
parts.append("</tbody>")
|
| 213 |
+
parts.append(_table_close())
|
| 214 |
+
parts.append(_section_close())
|
| 215 |
+
return "".join(parts)
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
# (sections suivantes définies plus loin)
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 222 |
+
# Sprint 56 — Abréviations Capelli médiévales
|
| 223 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
def build_abbreviations_section(
|
| 227 |
+
engines_summary: list[dict],
|
| 228 |
+
labels: Optional[dict[str, str]] = None,
|
| 229 |
+
) -> str:
|
| 230 |
+
relevant = _engines_with_module(engines_summary, "abbreviations")
|
| 231 |
+
if not relevant:
|
| 232 |
+
return ""
|
| 233 |
+
labels = labels or {}
|
| 234 |
+
title = labels.get(
|
| 235 |
+
"philo_abbreviations_title",
|
| 236 |
+
"Abréviations médiévales (Capelli)",
|
| 237 |
+
)
|
| 238 |
+
note = labels.get(
|
| 239 |
+
"philo_abbreviations_note",
|
| 240 |
+
"Strict = forme abrégée (ꝑ, ꝓ, ⁊…) préservée telle quelle ; "
|
| 241 |
+
"Expansion = abrégée OU forme développée (per, pro, et…) "
|
| 242 |
+
"présente. Le ratio strict/expansion par moteur indique la "
|
| 243 |
+
"convention adoptée (diplomatique / modernisante).",
|
| 244 |
+
)
|
| 245 |
+
engine_label = labels.get("philo_engine_label", "Moteur")
|
| 246 |
+
strict_label = labels.get("philo_strict_label", "Strict")
|
| 247 |
+
expansion_label = labels.get("philo_expansion_label", "Expansion")
|
| 248 |
+
n_label = labels.get("philo_n_total_label", "n total")
|
| 249 |
+
|
| 250 |
+
parts = [_section_open(title, note), _table_open()]
|
| 251 |
+
parts.append(_table_header(
|
| 252 |
+
[strict_label, expansion_label, n_label], engine_label,
|
| 253 |
+
))
|
| 254 |
+
parts.append("<tbody>")
|
| 255 |
+
for eng in relevant:
|
| 256 |
+
agg = eng["aggregated_philological"]["abbreviations"]
|
| 257 |
+
parts.append("<tr>")
|
| 258 |
+
parts.append(_engine_label_cell(eng["name"]))
|
| 259 |
+
parts.append(_score_cell(agg.get("global_strict_score", 0.0)))
|
| 260 |
+
parts.append(_score_cell(agg.get("global_expansion_score", 0.0)))
|
| 261 |
+
parts.append(
|
| 262 |
+
f'<td style="padding:.3rem .5rem;text-align:center">'
|
| 263 |
+
f'{agg.get("n_abbreviations_in_reference", 0)}</td>'
|
| 264 |
+
)
|
| 265 |
+
parts.append("</tr>")
|
| 266 |
+
parts.append("</tbody>")
|
| 267 |
+
parts.append(_table_close())
|
| 268 |
+
parts.append(_section_close())
|
| 269 |
+
return "".join(parts)
|
| 270 |
+
|
| 271 |
+
|
| 272 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 273 |
+
# Sprint 57 — Couverture MUFI
|
| 274 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 275 |
+
|
| 276 |
+
|
| 277 |
+
def build_mufi_section(
|
| 278 |
+
engines_summary: list[dict],
|
| 279 |
+
labels: Optional[dict[str, str]] = None,
|
| 280 |
+
) -> str:
|
| 281 |
+
relevant = _engines_with_module(engines_summary, "mufi")
|
| 282 |
+
if not relevant:
|
| 283 |
+
return ""
|
| 284 |
+
labels = labels or {}
|
| 285 |
+
title = labels.get(
|
| 286 |
+
"philo_mufi_title",
|
| 287 |
+
"Couverture MUFI (Medieval Unicode Font Initiative)",
|
| 288 |
+
)
|
| 289 |
+
note = labels.get(
|
| 290 |
+
"philo_mufi_note",
|
| 291 |
+
"Taux de caractères MUFI de la GT (þ, ð, ƿ, ſ, æ, lettres "
|
| 292 |
+
"PUA…) correctement restitués dans l'OCR. Critère éditorial "
|
| 293 |
+
"central pour les médiévistes.",
|
| 294 |
+
)
|
| 295 |
+
engine_label = labels.get("philo_engine_label", "Moteur")
|
| 296 |
+
coverage_label = labels.get("philo_mufi_coverage_label", "Couverture")
|
| 297 |
+
n_label = labels.get("philo_n_total_label", "n total")
|
| 298 |
+
|
| 299 |
+
parts = [_section_open(title, note), _table_open()]
|
| 300 |
+
parts.append(_table_header(
|
| 301 |
+
[coverage_label, n_label], engine_label,
|
| 302 |
+
))
|
| 303 |
+
parts.append("<tbody>")
|
| 304 |
+
for eng in relevant:
|
| 305 |
+
agg = eng["aggregated_philological"]["mufi"]
|
| 306 |
+
parts.append("<tr>")
|
| 307 |
+
parts.append(_engine_label_cell(eng["name"]))
|
| 308 |
+
parts.append(_score_cell(agg.get("coverage", 0.0)))
|
| 309 |
+
parts.append(
|
| 310 |
+
f'<td style="padding:.3rem .5rem;text-align:center">'
|
| 311 |
+
f'{agg.get("n_mufi_chars_reference", 0)}</td>'
|
| 312 |
+
)
|
| 313 |
+
parts.append("</tr>")
|
| 314 |
+
parts.append("</tbody>")
|
| 315 |
+
parts.append(_table_close())
|
| 316 |
+
parts.append(_section_close())
|
| 317 |
+
return "".join(parts)
|
| 318 |
+
|
| 319 |
+
|
| 320 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 321 |
+
# Sprint 58 — Marqueurs typographiques imprimé ancien (heatmap)
|
| 322 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 323 |
+
|
| 324 |
+
|
| 325 |
+
def build_early_modern_section(
|
| 326 |
+
engines_summary: list[dict],
|
| 327 |
+
labels: Optional[dict[str, str]] = None,
|
| 328 |
+
) -> str:
|
| 329 |
+
relevant = _engines_with_module(engines_summary, "early_modern")
|
| 330 |
+
if not relevant:
|
| 331 |
+
return ""
|
| 332 |
+
labels = labels or {}
|
| 333 |
+
title = labels.get(
|
| 334 |
+
"philo_early_modern_title",
|
| 335 |
+
"Marqueurs typographiques imprimé ancien (XVIᵉ-XVIIIᵉ)",
|
| 336 |
+
)
|
| 337 |
+
note = labels.get(
|
| 338 |
+
"philo_early_modern_note",
|
| 339 |
+
"Préservation des ligatures (fi fl ff), s long (ſ), i sans "
|
| 340 |
+
"point (ı), esperluette (&) et tildes nasaux (ã õ ñ). "
|
| 341 |
+
"Une ligne par moteur, une colonne par catégorie.",
|
| 342 |
+
)
|
| 343 |
+
engine_label = labels.get("philo_engine_label", "Moteur")
|
| 344 |
+
global_label = labels.get("philo_global_label", "Global")
|
| 345 |
+
|
| 346 |
+
all_cats: set[str] = set()
|
| 347 |
+
for eng in relevant:
|
| 348 |
+
all_cats.update(
|
| 349 |
+
eng["aggregated_philological"]["early_modern"]
|
| 350 |
+
.get("per_category", {}).keys(),
|
| 351 |
+
)
|
| 352 |
+
cats = sorted(all_cats)
|
| 353 |
+
if not cats:
|
| 354 |
+
return ""
|
| 355 |
+
|
| 356 |
+
parts = [_section_open(title, note), _table_open()]
|
| 357 |
+
parts.append(_table_header([global_label] + cats, engine_label))
|
| 358 |
+
parts.append("<tbody>")
|
| 359 |
+
for eng in relevant:
|
| 360 |
+
agg = eng["aggregated_philological"]["early_modern"]
|
| 361 |
+
n_total = agg.get("n_markers_reference", 0)
|
| 362 |
+
parts.append("<tr>")
|
| 363 |
+
parts.append(_engine_label_cell(eng["name"]))
|
| 364 |
+
parts.append(_score_cell(
|
| 365 |
+
agg.get("global_preservation", 0.0), extra=f"n={n_total}",
|
| 366 |
+
))
|
| 367 |
+
per_cat = agg.get("per_category", {})
|
| 368 |
+
for cat in cats:
|
| 369 |
+
stats = per_cat.get(cat)
|
| 370 |
+
if stats and stats.get("total", 0) > 0:
|
| 371 |
+
parts.append(_score_cell(
|
| 372 |
+
stats["preservation"], extra=f"n={stats['total']}",
|
| 373 |
+
))
|
| 374 |
+
else:
|
| 375 |
+
parts.append(_score_cell(None))
|
| 376 |
+
parts.append("</tr>")
|
| 377 |
+
parts.append("</tbody>")
|
| 378 |
+
parts.append(_table_close())
|
| 379 |
+
parts.append(_section_close())
|
| 380 |
+
return "".join(parts)
|
| 381 |
+
|
| 382 |
+
|
| 383 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 384 |
+
# Sprint 59 — Archives modernes : strict + expansion par catégorie
|
| 385 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 386 |
+
|
| 387 |
+
|
| 388 |
+
def build_modern_archives_section(
|
| 389 |
+
engines_summary: list[dict],
|
| 390 |
+
labels: Optional[dict[str, str]] = None,
|
| 391 |
+
) -> str:
|
| 392 |
+
relevant = _engines_with_module(engines_summary, "modern_archives")
|
| 393 |
+
if not relevant:
|
| 394 |
+
return ""
|
| 395 |
+
labels = labels or {}
|
| 396 |
+
title = labels.get(
|
| 397 |
+
"philo_modern_archives_title",
|
| 398 |
+
"Abréviations des archives modernes (XIXᵉ-XXᵉ)",
|
| 399 |
+
)
|
| 400 |
+
note = labels.get(
|
| 401 |
+
"philo_modern_archives_note",
|
| 402 |
+
"Strict = abrégé préservé (Mme, S.A.R., bd, vol., …) ; "
|
| 403 |
+
"Expansion = abrégé OU forme développée. Affiché par "
|
| 404 |
+
"catégorie : civilité, ordinaux, monnaie, administratif, "
|
| 405 |
+
"état civil, ponctuation typo, latin, biblio, adresse.",
|
| 406 |
+
)
|
| 407 |
+
engine_label = labels.get("philo_engine_label", "Moteur")
|
| 408 |
+
global_label = labels.get("philo_global_label", "Global")
|
| 409 |
+
strict_label = labels.get("philo_strict_label", "Strict")
|
| 410 |
+
expansion_label = labels.get("philo_expansion_label", "Expansion")
|
| 411 |
+
|
| 412 |
+
all_cats: set[str] = set()
|
| 413 |
+
for eng in relevant:
|
| 414 |
+
all_cats.update(
|
| 415 |
+
eng["aggregated_philological"]["modern_archives"]
|
| 416 |
+
.get("per_category", {}).keys(),
|
| 417 |
+
)
|
| 418 |
+
cats = sorted(all_cats)
|
| 419 |
+
|
| 420 |
+
parts = [_section_open(title, note)]
|
| 421 |
+
parts.append(
|
| 422 |
+
'<table style="border-collapse:collapse;width:100%;'
|
| 423 |
+
'font-size:.85rem">'
|
| 424 |
+
)
|
| 425 |
+
parts.append("<thead><tr>")
|
| 426 |
+
parts.append(
|
| 427 |
+
f'<th rowspan="2" style="padding:.3rem .5rem;text-align:left;'
|
| 428 |
+
f'border-bottom:1px solid var(--border);font-weight:600">'
|
| 429 |
+
f'{_e(engine_label)}</th>'
|
| 430 |
+
)
|
| 431 |
+
parts.append(
|
| 432 |
+
f'<th colspan="2" style="padding:.3rem .5rem;text-align:center;'
|
| 433 |
+
f'border-bottom:1px solid var(--border);font-weight:600">'
|
| 434 |
+
f'{_e(global_label)}</th>'
|
| 435 |
+
)
|
| 436 |
+
for cat in cats:
|
| 437 |
+
parts.append(
|
| 438 |
+
f'<th colspan="2" style="padding:.3rem .5rem;text-align:center;'
|
| 439 |
+
f'border-bottom:1px solid var(--border);font-weight:600">'
|
| 440 |
+
f'{_e(cat)}</th>'
|
| 441 |
+
)
|
| 442 |
+
parts.append("</tr><tr>")
|
| 443 |
+
for _ in range(1 + len(cats)):
|
| 444 |
+
parts.append(
|
| 445 |
+
f'<th style="padding:.2rem .4rem;text-align:center;'
|
| 446 |
+
f'font-size:.75rem;font-weight:500;opacity:.7">'
|
| 447 |
+
f'{_e(strict_label)}</th>'
|
| 448 |
+
)
|
| 449 |
+
parts.append(
|
| 450 |
+
f'<th style="padding:.2rem .4rem;text-align:center;'
|
| 451 |
+
f'font-size:.75rem;font-weight:500;opacity:.7">'
|
| 452 |
+
f'{_e(expansion_label)}</th>'
|
| 453 |
+
)
|
| 454 |
+
parts.append("</tr></thead>")
|
| 455 |
+
parts.append("<tbody>")
|
| 456 |
+
for eng in relevant:
|
| 457 |
+
agg = eng["aggregated_philological"]["modern_archives"]
|
| 458 |
+
parts.append("<tr>")
|
| 459 |
+
parts.append(_engine_label_cell(eng["name"]))
|
| 460 |
+
parts.append(_score_cell(agg.get("global_strict_score", 0.0)))
|
| 461 |
+
parts.append(_score_cell(agg.get("global_expansion_score", 0.0)))
|
| 462 |
+
per_cat = agg.get("per_category", {})
|
| 463 |
+
for cat in cats:
|
| 464 |
+
stats = per_cat.get(cat)
|
| 465 |
+
if stats and stats.get("n_total", 0) > 0:
|
| 466 |
+
parts.append(_score_cell(
|
| 467 |
+
stats["strict_score"],
|
| 468 |
+
extra=f"n={stats['n_total']}",
|
| 469 |
+
))
|
| 470 |
+
parts.append(_score_cell(stats["expansion_score"]))
|
| 471 |
+
else:
|
| 472 |
+
parts.append(_score_cell(None))
|
| 473 |
+
parts.append(_score_cell(None))
|
| 474 |
+
parts.append("</tr>")
|
| 475 |
+
parts.append("</tbody>")
|
| 476 |
+
parts.append(_table_close())
|
| 477 |
+
parts.append(_section_close())
|
| 478 |
+
return "".join(parts)
|
| 479 |
+
|
| 480 |
+
|
| 481 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 482 |
+
# Sprint 60 — Numéraux romains : breakdown 5 statuts
|
| 483 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 484 |
+
|
| 485 |
+
|
| 486 |
+
def build_roman_numerals_section(
|
| 487 |
+
engines_summary: list[dict],
|
| 488 |
+
labels: Optional[dict[str, str]] = None,
|
| 489 |
+
) -> str:
|
| 490 |
+
relevant = _engines_with_module(engines_summary, "roman_numerals")
|
| 491 |
+
if not relevant:
|
| 492 |
+
return ""
|
| 493 |
+
labels = labels or {}
|
| 494 |
+
title = labels.get(
|
| 495 |
+
"philo_roman_numerals_title",
|
| 496 |
+
"Numéraux romains : restitution par statut",
|
| 497 |
+
)
|
| 498 |
+
note = labels.get(
|
| 499 |
+
"philo_roman_numerals_note",
|
| 500 |
+
"Pour chaque numéral romain de la GT, statut de restitution : "
|
| 501 |
+
"strict (forme exacte), case_changed (casse modifiée), "
|
| 502 |
+
"j_dropped (j médiéval normalisé), converted_to_arabic, lost. "
|
| 503 |
+
"Le breakdown indique la convention : majoritaire strict → "
|
| 504 |
+
"diplomatique ; majoritaire arabic → modernisation profonde.",
|
| 505 |
+
)
|
| 506 |
+
engine_label = labels.get("philo_engine_label", "Moteur")
|
| 507 |
+
n_label = labels.get("philo_n_total_label", "n total")
|
| 508 |
+
|
| 509 |
+
statuses = (
|
| 510 |
+
"strict_preserved", "case_changed", "j_dropped",
|
| 511 |
+
"converted_to_arabic", "lost",
|
| 512 |
+
)
|
| 513 |
+
status_labels = {
|
| 514 |
+
s: labels.get(f"philo_roman_status_{s}", s) for s in statuses
|
| 515 |
+
}
|
| 516 |
+
|
| 517 |
+
parts = [_section_open(title, note), _table_open()]
|
| 518 |
+
parts.append(_table_header(
|
| 519 |
+
[n_label] + [status_labels[s] for s in statuses],
|
| 520 |
+
engine_label,
|
| 521 |
+
))
|
| 522 |
+
parts.append("<tbody>")
|
| 523 |
+
for eng in relevant:
|
| 524 |
+
agg = eng["aggregated_philological"]["roman_numerals"]
|
| 525 |
+
n_total = agg.get("n_numerals_reference", 0)
|
| 526 |
+
per_status = agg.get("per_status", {})
|
| 527 |
+
parts.append("<tr>")
|
| 528 |
+
parts.append(_engine_label_cell(eng["name"]))
|
| 529 |
+
parts.append(
|
| 530 |
+
f'<td style="padding:.3rem .5rem;text-align:center">'
|
| 531 |
+
f'{n_total}</td>'
|
| 532 |
+
)
|
| 533 |
+
for status in statuses:
|
| 534 |
+
count = per_status.get(status, 0)
|
| 535 |
+
if n_total > 0:
|
| 536 |
+
ratio = count / n_total
|
| 537 |
+
# Pour « lost » on inverse la couleur (un haut taux
|
| 538 |
+
# de perte est mauvais). Pour les autres on garde
|
| 539 |
+
# la sémantique « plus c'est haut, plus l'OCR a
|
| 540 |
+
# adopté ce statut ».
|
| 541 |
+
color = (
|
| 542 |
+
_color_for_score(1.0 - ratio) if status == "lost"
|
| 543 |
+
else _color_for_score(ratio)
|
| 544 |
+
)
|
| 545 |
+
parts.append(
|
| 546 |
+
f'<td style="padding:.3rem .5rem;text-align:center;'
|
| 547 |
+
f'background:{color}">{count} '
|
| 548 |
+
f'<span style="opacity:.6;font-size:.85em">'
|
| 549 |
+
f'({ratio * 100:.0f}%)</span></td>'
|
| 550 |
+
)
|
| 551 |
+
else:
|
| 552 |
+
parts.append(_score_cell(None))
|
| 553 |
+
parts.append("</tr>")
|
| 554 |
+
parts.append("</tbody>")
|
| 555 |
+
parts.append(_table_close())
|
| 556 |
+
parts.append(_section_close())
|
| 557 |
+
return "".join(parts)
|
| 558 |
+
|
| 559 |
+
|
| 560 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 561 |
+
# Agrégateur principal
|
| 562 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 563 |
+
|
| 564 |
+
|
| 565 |
+
def build_philological_profile_html(
|
| 566 |
+
engines_summary: list[dict],
|
| 567 |
+
labels: Optional[dict[str, str]] = None,
|
| 568 |
+
) -> str:
|
| 569 |
+
"""Assemble les six sections en un bloc unique.
|
| 570 |
+
|
| 571 |
+
Retourne ``""`` si aucune section n'a de contenu (c.-à-d.
|
| 572 |
+
aucun moteur n'a de signal philologique sur le corpus).
|
| 573 |
+
"""
|
| 574 |
+
sections = [
|
| 575 |
+
build_unicode_blocks_section(engines_summary, labels),
|
| 576 |
+
build_abbreviations_section(engines_summary, labels),
|
| 577 |
+
build_mufi_section(engines_summary, labels),
|
| 578 |
+
build_early_modern_section(engines_summary, labels),
|
| 579 |
+
build_modern_archives_section(engines_summary, labels),
|
| 580 |
+
build_roman_numerals_section(engines_summary, labels),
|
| 581 |
+
]
|
| 582 |
+
non_empty = [s for s in sections if s]
|
| 583 |
+
if not non_empty:
|
| 584 |
+
return ""
|
| 585 |
+
labels = labels or {}
|
| 586 |
+
main_title = labels.get(
|
| 587 |
+
"philo_profile_title", "Profil philologique",
|
| 588 |
+
)
|
| 589 |
+
main_note = labels.get(
|
| 590 |
+
"philo_profile_note",
|
| 591 |
+
"Données brutes par catégorie de marqueur philologique. "
|
| 592 |
+
"L'outil ne classifie pas la convention adoptée par chaque "
|
| 593 |
+
"moteur — c'est au chercheur de lire les chiffres et de "
|
| 594 |
+
"conclure selon ses critères éditoriaux.",
|
| 595 |
+
)
|
| 596 |
+
parts = [
|
| 597 |
+
'<div class="philological-profile">',
|
| 598 |
+
f'<h3 style="margin-top:0">{_e(main_title)}</h3>',
|
| 599 |
+
f'<p style="font-size:.85rem;opacity:.8;margin-bottom:.5rem">'
|
| 600 |
+
f'{_e(main_note)}</p>',
|
| 601 |
+
]
|
| 602 |
+
parts.extend(non_empty)
|
| 603 |
+
parts.append("</div>")
|
| 604 |
+
return "".join(parts)
|
| 605 |
+
|
| 606 |
+
|
| 607 |
+
__all__ = [
|
| 608 |
+
"build_philological_profile_html",
|
| 609 |
+
"build_unicode_blocks_section",
|
| 610 |
+
"build_abbreviations_section",
|
| 611 |
+
"build_mufi_section",
|
| 612 |
+
"build_early_modern_section",
|
| 613 |
+
"build_modern_archives_section",
|
| 614 |
+
"build_roman_numerals_section",
|
| 615 |
+
]
|
|
@@ -1,119 +1,17 @@
|
|
| 1 |
-
"""
|
| 2 |
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
| 4 |
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
**server-side**, pas de JavaScript, anti-injection systématique.
|
| 8 |
-
|
| 9 |
-
Vue
|
| 10 |
-
---
|
| 11 |
-
Tableau trié par taux de modernisation décroissant : forme
|
| 12 |
-
historique GT → forme(s) modernisée(s), occurrences GT, %.
|
| 13 |
-
Couleur de cellule pour le %.
|
| 14 |
"""
|
| 15 |
|
| 16 |
-
from
|
| 17 |
-
|
| 18 |
-
from html import escape as _e
|
| 19 |
-
from typing import Optional
|
| 20 |
-
|
| 21 |
-
from picarones.core.lexical_modernization import top_modernized_tokens
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
def _color_for_rate(rate: float) -> str:
|
| 25 |
-
"""Gradient blanc → orange profond pour rate ∈ [0, 1]."""
|
| 26 |
-
f = max(0.0, min(1.0, rate))
|
| 27 |
-
r = int(255 + (194 - 255) * f)
|
| 28 |
-
g = int(255 + (65 - 255) * f)
|
| 29 |
-
b = int(255 + (12 - 255) * f)
|
| 30 |
-
return f"#{r:02x}{g:02x}{b:02x}"
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
def _format_variants(variants: dict, max_show: int = 3) -> str:
|
| 34 |
-
"""Liste compacte des variants modernisés."""
|
| 35 |
-
items = sorted(variants.items(), key=lambda kv: -kv[1])
|
| 36 |
-
shown = items[:max_show]
|
| 37 |
-
rest = len(items) - max_show
|
| 38 |
-
parts = [
|
| 39 |
-
f"{_e(form)} ({count})"
|
| 40 |
-
for form, count in shown
|
| 41 |
-
]
|
| 42 |
-
if rest > 0:
|
| 43 |
-
parts.append(f"+{rest}")
|
| 44 |
-
return ", ".join(parts)
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
def build_lexical_modernization_html(
|
| 48 |
-
data: Optional[dict],
|
| 49 |
-
labels: Optional[dict[str, str]] = None,
|
| 50 |
-
*,
|
| 51 |
-
top_n: int = 20,
|
| 52 |
-
min_total: int = 1,
|
| 53 |
-
) -> str:
|
| 54 |
-
"""Construit la table HTML de modernisation lexicale.
|
| 55 |
-
|
| 56 |
-
Retourne ``""`` si ``data is None`` ou si aucun token modernisé.
|
| 57 |
-
"""
|
| 58 |
-
if not data:
|
| 59 |
-
return ""
|
| 60 |
-
rows = top_modernized_tokens(data, n=top_n, min_total=min_total)
|
| 61 |
-
if not rows:
|
| 62 |
-
return ""
|
| 63 |
-
labels = labels or {}
|
| 64 |
-
title = labels.get(
|
| 65 |
-
"lexmod_title", "Modernisation lexicale (top tokens)",
|
| 66 |
-
)
|
| 67 |
-
note = labels.get(
|
| 68 |
-
"lexmod_note",
|
| 69 |
-
"Tokens GT que le moteur réécrit le plus souvent. "
|
| 70 |
-
"Lecture : « maistre → maître modernisé dans 85 % des cas » "
|
| 71 |
-
"indique de quoi corriger dans le prompt pour préserver "
|
| 72 |
-
"l'orthographe historique.",
|
| 73 |
-
)
|
| 74 |
-
gt_label = labels.get("lexmod_gt_label", "Forme historique GT")
|
| 75 |
-
hyp_label = labels.get("lexmod_hyp_label", "Variantes OCR")
|
| 76 |
-
n_label = labels.get("lexmod_n_label", "n GT")
|
| 77 |
-
rate_label = labels.get("lexmod_rate_label", "% modernisé")
|
| 78 |
-
|
| 79 |
-
parts = [
|
| 80 |
-
'<div class="lexmod" style="margin:1rem 0">',
|
| 81 |
-
f'<div style="font-weight:600;margin-bottom:.4rem">{_e(title)}</div>',
|
| 82 |
-
f'<div style="font-size:.85rem;opacity:.75;margin-bottom:.5rem">'
|
| 83 |
-
f'{_e(note)}</div>',
|
| 84 |
-
'<table style="border-collapse:collapse;width:100%;'
|
| 85 |
-
'font-size:.85rem">',
|
| 86 |
-
'<thead><tr>',
|
| 87 |
-
]
|
| 88 |
-
for col in (gt_label, hyp_label, n_label, rate_label):
|
| 89 |
-
parts.append(
|
| 90 |
-
f'<th style="padding:.3rem .5rem;text-align:left;'
|
| 91 |
-
f'border-bottom:1px solid #ccc;font-weight:600">'
|
| 92 |
-
f'{_e(col)}</th>'
|
| 93 |
-
)
|
| 94 |
-
parts.append("</tr></thead><tbody>")
|
| 95 |
-
for gt_token, slot in rows:
|
| 96 |
-
rate = slot.get("rate_modernized", 0.0)
|
| 97 |
-
n_total = slot.get("n_total", 0)
|
| 98 |
-
variants_str = _format_variants(slot.get("variants") or {})
|
| 99 |
-
rate_color = _color_for_rate(rate)
|
| 100 |
-
parts.append(
|
| 101 |
-
f'<tr>'
|
| 102 |
-
f'<td style="padding:.3rem .5rem;font-family:monospace">'
|
| 103 |
-
f'{_e(gt_token)}</td>'
|
| 104 |
-
f'<td style="padding:.3rem .5rem;font-size:.85rem">'
|
| 105 |
-
f'{variants_str}</td>'
|
| 106 |
-
f'<td style="padding:.3rem .5rem;text-align:right;'
|
| 107 |
-
f'font-family:monospace">{n_total}</td>'
|
| 108 |
-
f'<td style="padding:.3rem .5rem;text-align:right;'
|
| 109 |
-
f'background:{rate_color};font-family:monospace">'
|
| 110 |
-
f'{rate * 100:.0f}%</td>'
|
| 111 |
-
f'</tr>'
|
| 112 |
-
)
|
| 113 |
-
parts.append("</tbody></table></div>")
|
| 114 |
-
return "".join(parts)
|
| 115 |
-
|
| 116 |
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
|
|
|
|
|
| 1 |
+
"""Alias rétrocompat — module déplacé dans :mod:`picarones.extras.render.lexical_modernization_render`.
|
| 2 |
|
| 3 |
+
Phase B du chantier de refonte en 3 cercles (architecture-cercles.md).
|
| 4 |
+
Ce module philologique est désormais en Cercle 3 (``extras/``). L'alias
|
| 5 |
+
ici permet aux imports historiques (``from picarones.report.lexical_modernization_render
|
| 6 |
+
import ...``) de continuer à fonctionner sans modification.
|
| 7 |
|
| 8 |
+
Voir :doc:`docs/architecture-cercles.md` et l'extra
|
| 9 |
+
``picarones[historical]`` du ``pyproject.toml``.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
"""
|
| 11 |
|
| 12 |
+
from picarones.extras.render.lexical_modernization_render import * # noqa: F401, F403
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
+
import picarones.extras.render.lexical_modernization_render as _module
|
| 15 |
+
__all__ = getattr(_module, "__all__", [
|
| 16 |
+
name for name in dir(_module) if not name.startswith("_")
|
| 17 |
+
])
|
|
@@ -1,615 +1,17 @@
|
|
| 1 |
-
"""
|
| 2 |
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
|
|
|
| 6 |
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
abréviation médiévale Capelli
|
| 10 |
-
- ``mufi`` (Sprint 57) — couverture MUFI globale + par
|
| 11 |
-
caractère
|
| 12 |
-
- ``early_modern`` (Sprint 58) — préservation des marqueurs
|
| 13 |
-
typographiques imprimé ancien
|
| 14 |
-
- ``modern_archives`` (Sprint 59) — strict + expansion par
|
| 15 |
-
catégorie d'archive moderne
|
| 16 |
-
- ``roman_numerals`` (Sprint 60) — breakdown 5 statuts de
|
| 17 |
-
restitution
|
| 18 |
-
|
| 19 |
-
Principe identique aux Sprints 41 (NER) et 43 (calibration) :
|
| 20 |
-
|
| 21 |
-
- Rendu **server-side**, pas de JavaScript, déterministe.
|
| 22 |
-
- Section adaptive : si aucun moteur n'a de signal pour un module
|
| 23 |
-
donné, la sous-section est silencieusement omise.
|
| 24 |
-
- Si **aucun module** n'a de signal sur l'ensemble des moteurs,
|
| 25 |
-
``build_philological_profile_html`` retourne une chaîne vide et
|
| 26 |
-
le bloc complet n'apparaît pas dans la vue analyses.
|
| 27 |
-
- **Aucune classification automatique** : on affiche les chiffres
|
| 28 |
-
bruts par catégorie/bloc/statut, le chercheur juge lui-même la
|
| 29 |
-
convention adoptée.
|
| 30 |
-
- Anti-injection : tous les noms de moteurs, catégories, statuts,
|
| 31 |
-
caractères passent par ``html.escape`` avant insertion.
|
| 32 |
"""
|
| 33 |
|
| 34 |
-
from
|
| 35 |
-
|
| 36 |
-
from html import escape as _e
|
| 37 |
-
from typing import Optional
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 41 |
-
# Helpers de coloration
|
| 42 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
def _color_for_score(score: float) -> str:
|
| 46 |
-
"""Gradient rouge → jaune → vert proportionnel à ``score`` ∈ [0, 1].
|
| 47 |
-
|
| 48 |
-
Identique à ``ner_render._color_for_f1``. Les scores
|
| 49 |
-
philologiques (preservation, coverage, accuracy) suivent la même
|
| 50 |
-
sémantique « plus c'est haut, mieux c'est » donc le gradient
|
| 51 |
-
est valide.
|
| 52 |
-
"""
|
| 53 |
-
f = max(0.0, min(1.0, score))
|
| 54 |
-
if f <= 0.5:
|
| 55 |
-
ratio = f / 0.5
|
| 56 |
-
r = int(220 + (240 - 220) * ratio)
|
| 57 |
-
g = int(100 + (220 - 100) * ratio)
|
| 58 |
-
b = int(100 + (130 - 100) * ratio)
|
| 59 |
-
else:
|
| 60 |
-
ratio = (f - 0.5) / 0.5
|
| 61 |
-
r = int(240 + (130 - 240) * ratio)
|
| 62 |
-
g = int(220 + (200 - 220) * ratio)
|
| 63 |
-
b = int(130 + (130 - 130) * ratio)
|
| 64 |
-
return f"#{r:02x}{g:02x}{b:02x}"
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
def _engines_with_module(
|
| 68 |
-
engines_summary: list[dict], module: str,
|
| 69 |
-
) -> list[dict]:
|
| 70 |
-
"""Filtre les moteurs ayant des données pour le module donné."""
|
| 71 |
-
out: list[dict] = []
|
| 72 |
-
for eng in engines_summary:
|
| 73 |
-
agg = eng.get("aggregated_philological") or {}
|
| 74 |
-
if module in agg and agg[module]:
|
| 75 |
-
out.append(eng)
|
| 76 |
-
return out
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
def _score_cell(score: Optional[float], extra: str = "") -> str:
|
| 80 |
-
"""Rend une cellule colorée. ``None`` → cellule grise « — »."""
|
| 81 |
-
if score is None:
|
| 82 |
-
return (
|
| 83 |
-
'<td style="padding:.3rem .5rem;text-align:center;'
|
| 84 |
-
'background:#f0f0f0;color:#999">—</td>'
|
| 85 |
-
)
|
| 86 |
-
color = _color_for_score(score)
|
| 87 |
-
text = f"{score * 100:.1f}%"
|
| 88 |
-
if extra:
|
| 89 |
-
text += f" <span style=\"opacity:.6;font-size:.85em\">({_e(extra)})</span>"
|
| 90 |
-
return (
|
| 91 |
-
f'<td style="padding:.3rem .5rem;text-align:center;'
|
| 92 |
-
f'background:{color}">{text}</td>'
|
| 93 |
-
)
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
def _table_header(
|
| 97 |
-
columns: list[str], engine_label: str,
|
| 98 |
-
) -> str:
|
| 99 |
-
"""Construit l'entête d'un tableau moteur × colonnes."""
|
| 100 |
-
parts = [
|
| 101 |
-
'<thead><tr>',
|
| 102 |
-
f'<th style="padding:.3rem .5rem;text-align:left;'
|
| 103 |
-
f'border-bottom:1px solid var(--border);font-weight:600">'
|
| 104 |
-
f'{_e(engine_label)}</th>',
|
| 105 |
-
]
|
| 106 |
-
for col in columns:
|
| 107 |
-
parts.append(
|
| 108 |
-
f'<th style="padding:.3rem .5rem;text-align:center;'
|
| 109 |
-
f'border-bottom:1px solid var(--border);font-weight:600">'
|
| 110 |
-
f'{_e(col)}</th>'
|
| 111 |
-
)
|
| 112 |
-
parts.append('</tr></thead>')
|
| 113 |
-
return "".join(parts)
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
def _engine_label_cell(name: str) -> str:
|
| 117 |
-
return (
|
| 118 |
-
f'<td style="padding:.3rem .5rem;font-weight:500;'
|
| 119 |
-
f'border-bottom:1px solid var(--border-light)">{_e(name)}</td>'
|
| 120 |
-
)
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
def _section_open(title: str, note: str = "") -> str:
|
| 124 |
-
parts = [
|
| 125 |
-
'<div class="philological-section" '
|
| 126 |
-
'style="margin:1rem 0;padding:.75rem;'
|
| 127 |
-
'background:var(--bg-secondary);border-radius:6px">',
|
| 128 |
-
f'<div style="font-weight:600;margin-bottom:.4rem">{_e(title)}</div>',
|
| 129 |
-
]
|
| 130 |
-
if note:
|
| 131 |
-
parts.append(
|
| 132 |
-
f'<div style="font-size:.8rem;opacity:.75;margin-bottom:.5rem">'
|
| 133 |
-
f'{_e(note)}</div>'
|
| 134 |
-
)
|
| 135 |
-
return "".join(parts)
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
def _section_close() -> str:
|
| 139 |
-
return "</div>"
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
def _table_open() -> str:
|
| 143 |
-
return (
|
| 144 |
-
'<table style="border-collapse:collapse;width:100%;'
|
| 145 |
-
'font-size:.85rem">'
|
| 146 |
-
)
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
def _table_close() -> str:
|
| 150 |
-
return "</table>"
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 154 |
-
# Sprint 55 — Précision par bloc Unicode
|
| 155 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
def build_unicode_blocks_section(
|
| 159 |
-
engines_summary: list[dict],
|
| 160 |
-
labels: Optional[dict[str, str]] = None,
|
| 161 |
-
) -> str:
|
| 162 |
-
relevant = _engines_with_module(engines_summary, "unicode_blocks")
|
| 163 |
-
if not relevant:
|
| 164 |
-
return ""
|
| 165 |
-
labels = labels or {}
|
| 166 |
-
title = labels.get(
|
| 167 |
-
"philo_unicode_blocks_title", "Précision par bloc Unicode",
|
| 168 |
-
)
|
| 169 |
-
note = labels.get(
|
| 170 |
-
"philo_unicode_blocks_note",
|
| 171 |
-
"Pourcentage de caractères correctement restitués par bloc "
|
| 172 |
-
"Unicode rencontré dans la GT (hors Basic Latin).",
|
| 173 |
-
)
|
| 174 |
-
engine_label = labels.get("philo_engine_label", "Moteur")
|
| 175 |
-
global_label = labels.get("philo_global_label", "Global")
|
| 176 |
-
|
| 177 |
-
# Collecte tous les blocs présents (hors Basic Latin déjà filtré
|
| 178 |
-
# par adaptive masking, mais on défilte ici si Basic Latin
|
| 179 |
-
# apparaît malgré tout chez certains moteurs).
|
| 180 |
-
all_blocks: set[str] = set()
|
| 181 |
-
for eng in relevant:
|
| 182 |
-
per_block = eng["aggregated_philological"]["unicode_blocks"].get(
|
| 183 |
-
"per_block", {},
|
| 184 |
-
)
|
| 185 |
-
for block in per_block:
|
| 186 |
-
if block != "Basic Latin":
|
| 187 |
-
all_blocks.add(block)
|
| 188 |
-
blocks = sorted(all_blocks)
|
| 189 |
-
if not blocks:
|
| 190 |
-
return ""
|
| 191 |
-
|
| 192 |
-
parts = [_section_open(title, note), _table_open()]
|
| 193 |
-
parts.append(_table_header([global_label] + blocks, engine_label))
|
| 194 |
-
parts.append("<tbody>")
|
| 195 |
-
for eng in relevant:
|
| 196 |
-
agg = eng["aggregated_philological"]["unicode_blocks"]
|
| 197 |
-
global_acc = agg.get("global_accuracy", 0.0)
|
| 198 |
-
n_chars = agg.get("n_chars_total", 0)
|
| 199 |
-
parts.append("<tr>")
|
| 200 |
-
parts.append(_engine_label_cell(eng["name"]))
|
| 201 |
-
parts.append(_score_cell(global_acc, extra=f"n={n_chars}"))
|
| 202 |
-
per_block = agg.get("per_block", {})
|
| 203 |
-
for block in blocks:
|
| 204 |
-
stats = per_block.get(block)
|
| 205 |
-
if stats and stats.get("total", 0) > 0:
|
| 206 |
-
parts.append(_score_cell(
|
| 207 |
-
stats["accuracy"], extra=f"n={stats['total']}",
|
| 208 |
-
))
|
| 209 |
-
else:
|
| 210 |
-
parts.append(_score_cell(None))
|
| 211 |
-
parts.append("</tr>")
|
| 212 |
-
parts.append("</tbody>")
|
| 213 |
-
parts.append(_table_close())
|
| 214 |
-
parts.append(_section_close())
|
| 215 |
-
return "".join(parts)
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
# (sections suivantes définies plus loin)
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 222 |
-
# Sprint 56 — Abréviations Capelli médiévales
|
| 223 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
def build_abbreviations_section(
|
| 227 |
-
engines_summary: list[dict],
|
| 228 |
-
labels: Optional[dict[str, str]] = None,
|
| 229 |
-
) -> str:
|
| 230 |
-
relevant = _engines_with_module(engines_summary, "abbreviations")
|
| 231 |
-
if not relevant:
|
| 232 |
-
return ""
|
| 233 |
-
labels = labels or {}
|
| 234 |
-
title = labels.get(
|
| 235 |
-
"philo_abbreviations_title",
|
| 236 |
-
"Abréviations médiévales (Capelli)",
|
| 237 |
-
)
|
| 238 |
-
note = labels.get(
|
| 239 |
-
"philo_abbreviations_note",
|
| 240 |
-
"Strict = forme abrégée (ꝑ, ꝓ, ⁊…) préservée telle quelle ; "
|
| 241 |
-
"Expansion = abrégée OU forme développée (per, pro, et…) "
|
| 242 |
-
"présente. Le ratio strict/expansion par moteur indique la "
|
| 243 |
-
"convention adoptée (diplomatique / modernisante).",
|
| 244 |
-
)
|
| 245 |
-
engine_label = labels.get("philo_engine_label", "Moteur")
|
| 246 |
-
strict_label = labels.get("philo_strict_label", "Strict")
|
| 247 |
-
expansion_label = labels.get("philo_expansion_label", "Expansion")
|
| 248 |
-
n_label = labels.get("philo_n_total_label", "n total")
|
| 249 |
-
|
| 250 |
-
parts = [_section_open(title, note), _table_open()]
|
| 251 |
-
parts.append(_table_header(
|
| 252 |
-
[strict_label, expansion_label, n_label], engine_label,
|
| 253 |
-
))
|
| 254 |
-
parts.append("<tbody>")
|
| 255 |
-
for eng in relevant:
|
| 256 |
-
agg = eng["aggregated_philological"]["abbreviations"]
|
| 257 |
-
parts.append("<tr>")
|
| 258 |
-
parts.append(_engine_label_cell(eng["name"]))
|
| 259 |
-
parts.append(_score_cell(agg.get("global_strict_score", 0.0)))
|
| 260 |
-
parts.append(_score_cell(agg.get("global_expansion_score", 0.0)))
|
| 261 |
-
parts.append(
|
| 262 |
-
f'<td style="padding:.3rem .5rem;text-align:center">'
|
| 263 |
-
f'{agg.get("n_abbreviations_in_reference", 0)}</td>'
|
| 264 |
-
)
|
| 265 |
-
parts.append("</tr>")
|
| 266 |
-
parts.append("</tbody>")
|
| 267 |
-
parts.append(_table_close())
|
| 268 |
-
parts.append(_section_close())
|
| 269 |
-
return "".join(parts)
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 273 |
-
# Sprint 57 — Couverture MUFI
|
| 274 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
def build_mufi_section(
|
| 278 |
-
engines_summary: list[dict],
|
| 279 |
-
labels: Optional[dict[str, str]] = None,
|
| 280 |
-
) -> str:
|
| 281 |
-
relevant = _engines_with_module(engines_summary, "mufi")
|
| 282 |
-
if not relevant:
|
| 283 |
-
return ""
|
| 284 |
-
labels = labels or {}
|
| 285 |
-
title = labels.get(
|
| 286 |
-
"philo_mufi_title",
|
| 287 |
-
"Couverture MUFI (Medieval Unicode Font Initiative)",
|
| 288 |
-
)
|
| 289 |
-
note = labels.get(
|
| 290 |
-
"philo_mufi_note",
|
| 291 |
-
"Taux de caractères MUFI de la GT (þ, ð, ƿ, ſ, æ, lettres "
|
| 292 |
-
"PUA…) correctement restitués dans l'OCR. Critère éditorial "
|
| 293 |
-
"central pour les médiévistes.",
|
| 294 |
-
)
|
| 295 |
-
engine_label = labels.get("philo_engine_label", "Moteur")
|
| 296 |
-
coverage_label = labels.get("philo_mufi_coverage_label", "Couverture")
|
| 297 |
-
n_label = labels.get("philo_n_total_label", "n total")
|
| 298 |
-
|
| 299 |
-
parts = [_section_open(title, note), _table_open()]
|
| 300 |
-
parts.append(_table_header(
|
| 301 |
-
[coverage_label, n_label], engine_label,
|
| 302 |
-
))
|
| 303 |
-
parts.append("<tbody>")
|
| 304 |
-
for eng in relevant:
|
| 305 |
-
agg = eng["aggregated_philological"]["mufi"]
|
| 306 |
-
parts.append("<tr>")
|
| 307 |
-
parts.append(_engine_label_cell(eng["name"]))
|
| 308 |
-
parts.append(_score_cell(agg.get("coverage", 0.0)))
|
| 309 |
-
parts.append(
|
| 310 |
-
f'<td style="padding:.3rem .5rem;text-align:center">'
|
| 311 |
-
f'{agg.get("n_mufi_chars_reference", 0)}</td>'
|
| 312 |
-
)
|
| 313 |
-
parts.append("</tr>")
|
| 314 |
-
parts.append("</tbody>")
|
| 315 |
-
parts.append(_table_close())
|
| 316 |
-
parts.append(_section_close())
|
| 317 |
-
return "".join(parts)
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 321 |
-
# Sprint 58 — Marqueurs typographiques imprimé ancien (heatmap)
|
| 322 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
def build_early_modern_section(
|
| 326 |
-
engines_summary: list[dict],
|
| 327 |
-
labels: Optional[dict[str, str]] = None,
|
| 328 |
-
) -> str:
|
| 329 |
-
relevant = _engines_with_module(engines_summary, "early_modern")
|
| 330 |
-
if not relevant:
|
| 331 |
-
return ""
|
| 332 |
-
labels = labels or {}
|
| 333 |
-
title = labels.get(
|
| 334 |
-
"philo_early_modern_title",
|
| 335 |
-
"Marqueurs typographiques imprimé ancien (XVIᵉ-XVIIIᵉ)",
|
| 336 |
-
)
|
| 337 |
-
note = labels.get(
|
| 338 |
-
"philo_early_modern_note",
|
| 339 |
-
"Préservation des ligatures (fi fl ff), s long (ſ), i sans "
|
| 340 |
-
"point (ı), esperluette (&) et tildes nasaux (ã õ ñ). "
|
| 341 |
-
"Une ligne par moteur, une colonne par catégorie.",
|
| 342 |
-
)
|
| 343 |
-
engine_label = labels.get("philo_engine_label", "Moteur")
|
| 344 |
-
global_label = labels.get("philo_global_label", "Global")
|
| 345 |
-
|
| 346 |
-
all_cats: set[str] = set()
|
| 347 |
-
for eng in relevant:
|
| 348 |
-
all_cats.update(
|
| 349 |
-
eng["aggregated_philological"]["early_modern"]
|
| 350 |
-
.get("per_category", {}).keys(),
|
| 351 |
-
)
|
| 352 |
-
cats = sorted(all_cats)
|
| 353 |
-
if not cats:
|
| 354 |
-
return ""
|
| 355 |
-
|
| 356 |
-
parts = [_section_open(title, note), _table_open()]
|
| 357 |
-
parts.append(_table_header([global_label] + cats, engine_label))
|
| 358 |
-
parts.append("<tbody>")
|
| 359 |
-
for eng in relevant:
|
| 360 |
-
agg = eng["aggregated_philological"]["early_modern"]
|
| 361 |
-
n_total = agg.get("n_markers_reference", 0)
|
| 362 |
-
parts.append("<tr>")
|
| 363 |
-
parts.append(_engine_label_cell(eng["name"]))
|
| 364 |
-
parts.append(_score_cell(
|
| 365 |
-
agg.get("global_preservation", 0.0), extra=f"n={n_total}",
|
| 366 |
-
))
|
| 367 |
-
per_cat = agg.get("per_category", {})
|
| 368 |
-
for cat in cats:
|
| 369 |
-
stats = per_cat.get(cat)
|
| 370 |
-
if stats and stats.get("total", 0) > 0:
|
| 371 |
-
parts.append(_score_cell(
|
| 372 |
-
stats["preservation"], extra=f"n={stats['total']}",
|
| 373 |
-
))
|
| 374 |
-
else:
|
| 375 |
-
parts.append(_score_cell(None))
|
| 376 |
-
parts.append("</tr>")
|
| 377 |
-
parts.append("</tbody>")
|
| 378 |
-
parts.append(_table_close())
|
| 379 |
-
parts.append(_section_close())
|
| 380 |
-
return "".join(parts)
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 384 |
-
# Sprint 59 — Archives modernes : strict + expansion par catégorie
|
| 385 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
def build_modern_archives_section(
|
| 389 |
-
engines_summary: list[dict],
|
| 390 |
-
labels: Optional[dict[str, str]] = None,
|
| 391 |
-
) -> str:
|
| 392 |
-
relevant = _engines_with_module(engines_summary, "modern_archives")
|
| 393 |
-
if not relevant:
|
| 394 |
-
return ""
|
| 395 |
-
labels = labels or {}
|
| 396 |
-
title = labels.get(
|
| 397 |
-
"philo_modern_archives_title",
|
| 398 |
-
"Abréviations des archives modernes (XIXᵉ-XXᵉ)",
|
| 399 |
-
)
|
| 400 |
-
note = labels.get(
|
| 401 |
-
"philo_modern_archives_note",
|
| 402 |
-
"Strict = abrégé préservé (Mme, S.A.R., bd, vol., …) ; "
|
| 403 |
-
"Expansion = abrégé OU forme développée. Affiché par "
|
| 404 |
-
"catégorie : civilité, ordinaux, monnaie, administratif, "
|
| 405 |
-
"état civil, ponctuation typo, latin, biblio, adresse.",
|
| 406 |
-
)
|
| 407 |
-
engine_label = labels.get("philo_engine_label", "Moteur")
|
| 408 |
-
global_label = labels.get("philo_global_label", "Global")
|
| 409 |
-
strict_label = labels.get("philo_strict_label", "Strict")
|
| 410 |
-
expansion_label = labels.get("philo_expansion_label", "Expansion")
|
| 411 |
-
|
| 412 |
-
all_cats: set[str] = set()
|
| 413 |
-
for eng in relevant:
|
| 414 |
-
all_cats.update(
|
| 415 |
-
eng["aggregated_philological"]["modern_archives"]
|
| 416 |
-
.get("per_category", {}).keys(),
|
| 417 |
-
)
|
| 418 |
-
cats = sorted(all_cats)
|
| 419 |
-
|
| 420 |
-
parts = [_section_open(title, note)]
|
| 421 |
-
parts.append(
|
| 422 |
-
'<table style="border-collapse:collapse;width:100%;'
|
| 423 |
-
'font-size:.85rem">'
|
| 424 |
-
)
|
| 425 |
-
parts.append("<thead><tr>")
|
| 426 |
-
parts.append(
|
| 427 |
-
f'<th rowspan="2" style="padding:.3rem .5rem;text-align:left;'
|
| 428 |
-
f'border-bottom:1px solid var(--border);font-weight:600">'
|
| 429 |
-
f'{_e(engine_label)}</th>'
|
| 430 |
-
)
|
| 431 |
-
parts.append(
|
| 432 |
-
f'<th colspan="2" style="padding:.3rem .5rem;text-align:center;'
|
| 433 |
-
f'border-bottom:1px solid var(--border);font-weight:600">'
|
| 434 |
-
f'{_e(global_label)}</th>'
|
| 435 |
-
)
|
| 436 |
-
for cat in cats:
|
| 437 |
-
parts.append(
|
| 438 |
-
f'<th colspan="2" style="padding:.3rem .5rem;text-align:center;'
|
| 439 |
-
f'border-bottom:1px solid var(--border);font-weight:600">'
|
| 440 |
-
f'{_e(cat)}</th>'
|
| 441 |
-
)
|
| 442 |
-
parts.append("</tr><tr>")
|
| 443 |
-
for _ in range(1 + len(cats)):
|
| 444 |
-
parts.append(
|
| 445 |
-
f'<th style="padding:.2rem .4rem;text-align:center;'
|
| 446 |
-
f'font-size:.75rem;font-weight:500;opacity:.7">'
|
| 447 |
-
f'{_e(strict_label)}</th>'
|
| 448 |
-
)
|
| 449 |
-
parts.append(
|
| 450 |
-
f'<th style="padding:.2rem .4rem;text-align:center;'
|
| 451 |
-
f'font-size:.75rem;font-weight:500;opacity:.7">'
|
| 452 |
-
f'{_e(expansion_label)}</th>'
|
| 453 |
-
)
|
| 454 |
-
parts.append("</tr></thead>")
|
| 455 |
-
parts.append("<tbody>")
|
| 456 |
-
for eng in relevant:
|
| 457 |
-
agg = eng["aggregated_philological"]["modern_archives"]
|
| 458 |
-
parts.append("<tr>")
|
| 459 |
-
parts.append(_engine_label_cell(eng["name"]))
|
| 460 |
-
parts.append(_score_cell(agg.get("global_strict_score", 0.0)))
|
| 461 |
-
parts.append(_score_cell(agg.get("global_expansion_score", 0.0)))
|
| 462 |
-
per_cat = agg.get("per_category", {})
|
| 463 |
-
for cat in cats:
|
| 464 |
-
stats = per_cat.get(cat)
|
| 465 |
-
if stats and stats.get("n_total", 0) > 0:
|
| 466 |
-
parts.append(_score_cell(
|
| 467 |
-
stats["strict_score"],
|
| 468 |
-
extra=f"n={stats['n_total']}",
|
| 469 |
-
))
|
| 470 |
-
parts.append(_score_cell(stats["expansion_score"]))
|
| 471 |
-
else:
|
| 472 |
-
parts.append(_score_cell(None))
|
| 473 |
-
parts.append(_score_cell(None))
|
| 474 |
-
parts.append("</tr>")
|
| 475 |
-
parts.append("</tbody>")
|
| 476 |
-
parts.append(_table_close())
|
| 477 |
-
parts.append(_section_close())
|
| 478 |
-
return "".join(parts)
|
| 479 |
-
|
| 480 |
-
|
| 481 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 482 |
-
# Sprint 60 — Numéraux romains : breakdown 5 statuts
|
| 483 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 484 |
-
|
| 485 |
-
|
| 486 |
-
def build_roman_numerals_section(
|
| 487 |
-
engines_summary: list[dict],
|
| 488 |
-
labels: Optional[dict[str, str]] = None,
|
| 489 |
-
) -> str:
|
| 490 |
-
relevant = _engines_with_module(engines_summary, "roman_numerals")
|
| 491 |
-
if not relevant:
|
| 492 |
-
return ""
|
| 493 |
-
labels = labels or {}
|
| 494 |
-
title = labels.get(
|
| 495 |
-
"philo_roman_numerals_title",
|
| 496 |
-
"Numéraux romains : restitution par statut",
|
| 497 |
-
)
|
| 498 |
-
note = labels.get(
|
| 499 |
-
"philo_roman_numerals_note",
|
| 500 |
-
"Pour chaque numéral romain de la GT, statut de restitution : "
|
| 501 |
-
"strict (forme exacte), case_changed (casse modifiée), "
|
| 502 |
-
"j_dropped (j médiéval normalisé), converted_to_arabic, lost. "
|
| 503 |
-
"Le breakdown indique la convention : majoritaire strict → "
|
| 504 |
-
"diplomatique ; majoritaire arabic → modernisation profonde.",
|
| 505 |
-
)
|
| 506 |
-
engine_label = labels.get("philo_engine_label", "Moteur")
|
| 507 |
-
n_label = labels.get("philo_n_total_label", "n total")
|
| 508 |
-
|
| 509 |
-
statuses = (
|
| 510 |
-
"strict_preserved", "case_changed", "j_dropped",
|
| 511 |
-
"converted_to_arabic", "lost",
|
| 512 |
-
)
|
| 513 |
-
status_labels = {
|
| 514 |
-
s: labels.get(f"philo_roman_status_{s}", s) for s in statuses
|
| 515 |
-
}
|
| 516 |
-
|
| 517 |
-
parts = [_section_open(title, note), _table_open()]
|
| 518 |
-
parts.append(_table_header(
|
| 519 |
-
[n_label] + [status_labels[s] for s in statuses],
|
| 520 |
-
engine_label,
|
| 521 |
-
))
|
| 522 |
-
parts.append("<tbody>")
|
| 523 |
-
for eng in relevant:
|
| 524 |
-
agg = eng["aggregated_philological"]["roman_numerals"]
|
| 525 |
-
n_total = agg.get("n_numerals_reference", 0)
|
| 526 |
-
per_status = agg.get("per_status", {})
|
| 527 |
-
parts.append("<tr>")
|
| 528 |
-
parts.append(_engine_label_cell(eng["name"]))
|
| 529 |
-
parts.append(
|
| 530 |
-
f'<td style="padding:.3rem .5rem;text-align:center">'
|
| 531 |
-
f'{n_total}</td>'
|
| 532 |
-
)
|
| 533 |
-
for status in statuses:
|
| 534 |
-
count = per_status.get(status, 0)
|
| 535 |
-
if n_total > 0:
|
| 536 |
-
ratio = count / n_total
|
| 537 |
-
# Pour « lost » on inverse la couleur (un haut taux
|
| 538 |
-
# de perte est mauvais). Pour les autres on garde
|
| 539 |
-
# la sémantique « plus c'est haut, plus l'OCR a
|
| 540 |
-
# adopté ce statut ».
|
| 541 |
-
color = (
|
| 542 |
-
_color_for_score(1.0 - ratio) if status == "lost"
|
| 543 |
-
else _color_for_score(ratio)
|
| 544 |
-
)
|
| 545 |
-
parts.append(
|
| 546 |
-
f'<td style="padding:.3rem .5rem;text-align:center;'
|
| 547 |
-
f'background:{color}">{count} '
|
| 548 |
-
f'<span style="opacity:.6;font-size:.85em">'
|
| 549 |
-
f'({ratio * 100:.0f}%)</span></td>'
|
| 550 |
-
)
|
| 551 |
-
else:
|
| 552 |
-
parts.append(_score_cell(None))
|
| 553 |
-
parts.append("</tr>")
|
| 554 |
-
parts.append("</tbody>")
|
| 555 |
-
parts.append(_table_close())
|
| 556 |
-
parts.append(_section_close())
|
| 557 |
-
return "".join(parts)
|
| 558 |
-
|
| 559 |
-
|
| 560 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 561 |
-
# Agrégateur principal
|
| 562 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 563 |
-
|
| 564 |
-
|
| 565 |
-
def build_philological_profile_html(
|
| 566 |
-
engines_summary: list[dict],
|
| 567 |
-
labels: Optional[dict[str, str]] = None,
|
| 568 |
-
) -> str:
|
| 569 |
-
"""Assemble les six sections en un bloc unique.
|
| 570 |
-
|
| 571 |
-
Retourne ``""`` si aucune section n'a de contenu (c.-à-d.
|
| 572 |
-
aucun moteur n'a de signal philologique sur le corpus).
|
| 573 |
-
"""
|
| 574 |
-
sections = [
|
| 575 |
-
build_unicode_blocks_section(engines_summary, labels),
|
| 576 |
-
build_abbreviations_section(engines_summary, labels),
|
| 577 |
-
build_mufi_section(engines_summary, labels),
|
| 578 |
-
build_early_modern_section(engines_summary, labels),
|
| 579 |
-
build_modern_archives_section(engines_summary, labels),
|
| 580 |
-
build_roman_numerals_section(engines_summary, labels),
|
| 581 |
-
]
|
| 582 |
-
non_empty = [s for s in sections if s]
|
| 583 |
-
if not non_empty:
|
| 584 |
-
return ""
|
| 585 |
-
labels = labels or {}
|
| 586 |
-
main_title = labels.get(
|
| 587 |
-
"philo_profile_title", "Profil philologique",
|
| 588 |
-
)
|
| 589 |
-
main_note = labels.get(
|
| 590 |
-
"philo_profile_note",
|
| 591 |
-
"Données brutes par catégorie de marqueur philologique. "
|
| 592 |
-
"L'outil ne classifie pas la convention adoptée par chaque "
|
| 593 |
-
"moteur — c'est au chercheur de lire les chiffres et de "
|
| 594 |
-
"conclure selon ses critères éditoriaux.",
|
| 595 |
-
)
|
| 596 |
-
parts = [
|
| 597 |
-
'<div class="philological-profile">',
|
| 598 |
-
f'<h3 style="margin-top:0">{_e(main_title)}</h3>',
|
| 599 |
-
f'<p style="font-size:.85rem;opacity:.8;margin-bottom:.5rem">'
|
| 600 |
-
f'{_e(main_note)}</p>',
|
| 601 |
-
]
|
| 602 |
-
parts.extend(non_empty)
|
| 603 |
-
parts.append("</div>")
|
| 604 |
-
return "".join(parts)
|
| 605 |
-
|
| 606 |
|
| 607 |
-
|
| 608 |
-
|
| 609 |
-
"
|
| 610 |
-
|
| 611 |
-
"build_mufi_section",
|
| 612 |
-
"build_early_modern_section",
|
| 613 |
-
"build_modern_archives_section",
|
| 614 |
-
"build_roman_numerals_section",
|
| 615 |
-
]
|
|
|
|
| 1 |
+
"""Alias rétrocompat — module déplacé dans :mod:`picarones.extras.render.philological_render`.
|
| 2 |
|
| 3 |
+
Phase B du chantier de refonte en 3 cercles (architecture-cercles.md).
|
| 4 |
+
Ce module philologique est désormais en Cercle 3 (``extras/``). L'alias
|
| 5 |
+
ici permet aux imports historiques (``from picarones.report.philological_render
|
| 6 |
+
import ...``) de continuer à fonctionner sans modification.
|
| 7 |
|
| 8 |
+
Voir :doc:`docs/architecture-cercles.md` et l'extra
|
| 9 |
+
``picarones[historical]`` du ``pyproject.toml``.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
"""
|
| 11 |
|
| 12 |
+
from picarones.extras.render.philological_render import * # noqa: F401, F403
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
+
import picarones.extras.render.philological_render as _module
|
| 15 |
+
__all__ = getattr(_module, "__all__", [
|
| 16 |
+
name for name in dir(_module) if not name.startswith("_")
|
| 17 |
+
])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -69,9 +69,19 @@ ocr-cloud = [
|
|
| 69 |
"boto3>=1.34.0",
|
| 70 |
"azure-ai-formrecognizer>=3.3.0",
|
| 71 |
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
# Installation complète (tous les extras sauf les OCR cloud)
|
| 73 |
all = [
|
| 74 |
-
"picarones[web,hf,llm,dev]",
|
| 75 |
]
|
| 76 |
|
| 77 |
[project.scripts]
|
|
|
|
| 69 |
"boto3>=1.34.0",
|
| 70 |
"azure-ai-formrecognizer>=3.3.0",
|
| 71 |
]
|
| 72 |
+
# Métriques philologiques pour documents historiques (Cercle 3, phase B
|
| 73 |
+
# du chantier de refonte post-Sprint 97). Aujourd'hui les modules
|
| 74 |
+
# philologiques (`picarones.extras.historical.*`) sont livrés dans le
|
| 75 |
+
# package principal sans dépendance externe — l'extra ``[historical]``
|
| 76 |
+
# n'ajoute donc aucun paquet à installer. Il est déclaré ici pour
|
| 77 |
+
# **documenter l'intention** : un usage purement moderne (sans cas
|
| 78 |
+
# d'usage patrimonial) peut ignorer le sous-package extras/historical/
|
| 79 |
+
# entièrement, et un futur split en package PyPI séparé
|
| 80 |
+
# ``picarones-historical`` réutilisera ce nom d'extra.
|
| 81 |
+
historical = []
|
| 82 |
# Installation complète (tous les extras sauf les OCR cloud)
|
| 83 |
all = [
|
| 84 |
+
"picarones[web,hf,llm,dev,historical]",
|
| 85 |
]
|
| 86 |
|
| 87 |
[project.scripts]
|
|
@@ -0,0 +1,249 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests de la phase B — extras/historical/ (philologique vers Cercle 3).
|
| 2 |
+
|
| 3 |
+
Couvre :
|
| 4 |
+
|
| 5 |
+
- 8 modules philologiques (Cercle 3) déplacés vers `extras/historical/`.
|
| 6 |
+
- 2 renderers correspondants déplacés vers `extras/render/`.
|
| 7 |
+
- Identité préservée à travers les shims (test ``is``).
|
| 8 |
+
- Intégration : `philological_runner` orchestre toujours les 6 modules
|
| 9 |
+
même après déplacement.
|
| 10 |
+
- Dépendance Cercle 2 → Cercle 3 (`numerical_sequences` →
|
| 11 |
+
`roman_numerals`) continue de fonctionner via shim.
|
| 12 |
+
- pyproject.toml déclare `[historical]` comme extra documentaire.
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
from pathlib import Path
|
| 18 |
+
|
| 19 |
+
import pytest
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 23 |
+
# 1. Modules historiques accessibles via shims (rétrocompat)
|
| 24 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class TestPhilologicalRetrocompat:
|
| 28 |
+
@pytest.mark.parametrize("module_path, attribute", [
|
| 29 |
+
("picarones.core.unicode_blocks", "compute_unicode_block_accuracy"),
|
| 30 |
+
("picarones.core.abbreviations", "compute_abbreviation_metrics"),
|
| 31 |
+
("picarones.core.mufi", "compute_mufi_coverage"),
|
| 32 |
+
("picarones.core.early_modern_typography", "compute_early_modern_metrics"),
|
| 33 |
+
("picarones.core.modern_archives", "compute_modern_archives_metrics"),
|
| 34 |
+
("picarones.core.roman_numerals", "compute_roman_numeral_metrics"),
|
| 35 |
+
("picarones.core.lexical_modernization", "compute_lexical_modernization"),
|
| 36 |
+
("picarones.core.philological_runner", "compute_philological_metrics"),
|
| 37 |
+
("picarones.core.philological_runner", "aggregate_philological_metrics"),
|
| 38 |
+
])
|
| 39 |
+
def test_core_alias_still_works(self, module_path: str, attribute: str):
|
| 40 |
+
import importlib
|
| 41 |
+
mod = importlib.import_module(module_path)
|
| 42 |
+
assert hasattr(mod, attribute), (
|
| 43 |
+
f"{module_path}.{attribute} a disparu après la phase B"
|
| 44 |
+
)
|
| 45 |
+
|
| 46 |
+
@pytest.mark.parametrize("module_path, attribute", [
|
| 47 |
+
("picarones.report.philological_render", "build_philological_profile_html"),
|
| 48 |
+
("picarones.report.lexical_modernization_render",
|
| 49 |
+
"build_lexical_modernization_html"),
|
| 50 |
+
])
|
| 51 |
+
def test_render_alias_still_works(self, module_path: str, attribute: str):
|
| 52 |
+
import importlib
|
| 53 |
+
mod = importlib.import_module(module_path)
|
| 54 |
+
assert hasattr(mod, attribute)
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 58 |
+
# 2. Modules accessibles via leur nouveau chemin extras/historical/
|
| 59 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
class TestNewHistoricalImports:
|
| 63 |
+
@pytest.mark.parametrize("new_path, attribute", [
|
| 64 |
+
("picarones.extras.historical.unicode_blocks",
|
| 65 |
+
"compute_unicode_block_accuracy"),
|
| 66 |
+
("picarones.extras.historical.abbreviations",
|
| 67 |
+
"compute_abbreviation_metrics"),
|
| 68 |
+
("picarones.extras.historical.mufi", "compute_mufi_coverage"),
|
| 69 |
+
("picarones.extras.historical.early_modern_typography",
|
| 70 |
+
"compute_early_modern_metrics"),
|
| 71 |
+
("picarones.extras.historical.modern_archives",
|
| 72 |
+
"compute_modern_archives_metrics"),
|
| 73 |
+
("picarones.extras.historical.roman_numerals",
|
| 74 |
+
"compute_roman_numeral_metrics"),
|
| 75 |
+
("picarones.extras.historical.lexical_modernization",
|
| 76 |
+
"compute_lexical_modernization"),
|
| 77 |
+
("picarones.extras.historical.philological_runner",
|
| 78 |
+
"compute_philological_metrics"),
|
| 79 |
+
("picarones.extras.render.philological_render",
|
| 80 |
+
"build_philological_profile_html"),
|
| 81 |
+
("picarones.extras.render.lexical_modernization_render",
|
| 82 |
+
"build_lexical_modernization_html"),
|
| 83 |
+
])
|
| 84 |
+
def test_extras_path_works(self, new_path: str, attribute: str):
|
| 85 |
+
import importlib
|
| 86 |
+
mod = importlib.import_module(new_path)
|
| 87 |
+
assert hasattr(mod, attribute)
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 91 |
+
# 3. Identité préservée (shim et nouveau chemin = même fonction)
|
| 92 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
class TestIdentityThroughShim:
|
| 96 |
+
def test_unicode_blocks_identity(self):
|
| 97 |
+
from picarones.core.unicode_blocks import (
|
| 98 |
+
compute_unicode_block_accuracy as via_old,
|
| 99 |
+
)
|
| 100 |
+
from picarones.extras.historical.unicode_blocks import (
|
| 101 |
+
compute_unicode_block_accuracy as via_new,
|
| 102 |
+
)
|
| 103 |
+
assert via_old is via_new
|
| 104 |
+
|
| 105 |
+
def test_philological_runner_identity(self):
|
| 106 |
+
from picarones.core.philological_runner import (
|
| 107 |
+
compute_philological_metrics as via_old,
|
| 108 |
+
)
|
| 109 |
+
from picarones.extras.historical.philological_runner import (
|
| 110 |
+
compute_philological_metrics as via_new,
|
| 111 |
+
)
|
| 112 |
+
assert via_old is via_new
|
| 113 |
+
|
| 114 |
+
def test_renderer_identity(self):
|
| 115 |
+
from picarones.report.philological_render import (
|
| 116 |
+
build_philological_profile_html as via_old,
|
| 117 |
+
)
|
| 118 |
+
from picarones.extras.render.philological_render import (
|
| 119 |
+
build_philological_profile_html as via_new,
|
| 120 |
+
)
|
| 121 |
+
assert via_old is via_new
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 125 |
+
# 4. Intégration : philological_runner orchestre toujours les 6 modules
|
| 126 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
class TestPhilologicalRunnerIntegration:
|
| 130 |
+
"""Le runner philologique appelle les 6 modules
|
| 131 |
+
philologiques. Vérifie que cette chaîne fonctionne après le
|
| 132 |
+
déplacement (les imports internes traversent les shims)."""
|
| 133 |
+
|
| 134 |
+
def test_runner_returns_dict_or_none(self):
|
| 135 |
+
from picarones.core.philological_runner import (
|
| 136 |
+
compute_philological_metrics,
|
| 137 |
+
)
|
| 138 |
+
# Texte sans signal philologique → None par adaptive masking
|
| 139 |
+
result = compute_philological_metrics(
|
| 140 |
+
"Bonjour le monde", "Bonjour le monde",
|
| 141 |
+
)
|
| 142 |
+
# None acceptable (texte ASCII pur sans aucun marqueur)
|
| 143 |
+
# OU dict vide (signal nul partout)
|
| 144 |
+
assert result is None or isinstance(result, dict)
|
| 145 |
+
|
| 146 |
+
def test_runner_with_medieval_text(self):
|
| 147 |
+
"""Texte médiéval avec abréviations + numéraux romains : on
|
| 148 |
+
s'attend à au moins un module qui détecte du signal."""
|
| 149 |
+
from picarones.core.philological_runner import (
|
| 150 |
+
compute_philological_metrics,
|
| 151 |
+
)
|
| 152 |
+
# ⁊ = symbole d'abréviation Capelli ; XIV = numéral romain ; ſ = long s
|
| 153 |
+
ref = "⁊ par leſ XIV. fontoyers"
|
| 154 |
+
hyp = "et par les XIV. fontoyers"
|
| 155 |
+
result = compute_philological_metrics(ref, hyp)
|
| 156 |
+
# Au moins un module doit avoir détecté du signal
|
| 157 |
+
# (abbreviations OU early_modern OU roman_numerals)
|
| 158 |
+
assert result is not None
|
| 159 |
+
assert isinstance(result, dict)
|
| 160 |
+
assert len(result) >= 1
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 164 |
+
# 5. Dépendance Cercle 2 → Cercle 3 fonctionne via shim
|
| 165 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
class TestCercle2DependsOnCercle3ViaShim:
|
| 169 |
+
"""``picarones.core.numerical_sequences`` (Cercle 2,
|
| 170 |
+
measurements/) importe ``roman_numerals`` (Cercle 3, extras/).
|
| 171 |
+
Cette dépendance traverse le shim — elle continue à fonctionner."""
|
| 172 |
+
|
| 173 |
+
def test_numerical_sequences_uses_roman_numerals(self):
|
| 174 |
+
from picarones.core.numerical_sequences import (
|
| 175 |
+
compute_numerical_sequence_metrics,
|
| 176 |
+
)
|
| 177 |
+
# Texte avec numéral romain
|
| 178 |
+
result = compute_numerical_sequence_metrics(
|
| 179 |
+
"Le roi Louis XIV régna jusqu'en 1715",
|
| 180 |
+
"Le roi Louis XIV régna jusqu'en 1715",
|
| 181 |
+
)
|
| 182 |
+
# Le score strict global doit refléter au moins la détection
|
| 183 |
+
# du romain et de la date
|
| 184 |
+
assert isinstance(result, dict)
|
| 185 |
+
assert result.get("global_strict_score") is not None
|
| 186 |
+
assert result.get("global_strict_score") >= 0.5
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 190 |
+
# 6. pyproject.toml déclare l'extra [historical]
|
| 191 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
class TestPyprojectExtra:
|
| 195 |
+
def test_historical_extra_declared(self):
|
| 196 |
+
path = Path(__file__).parent.parent / "pyproject.toml"
|
| 197 |
+
content = path.read_text(encoding="utf-8")
|
| 198 |
+
# L'extra [historical] doit être déclaré, même vide
|
| 199 |
+
assert "historical = []" in content or 'historical = [' in content
|
| 200 |
+
# Documentation de l'intention présente
|
| 201 |
+
assert "extras/historical" in content
|
| 202 |
+
assert "Cercle 3" in content
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 206 |
+
# 7. Hooks builtin enregistrés conditionnels (philological + lexical)
|
| 207 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
class TestBuiltinHooksStillRegisterPhilological:
|
| 211 |
+
"""Les hooks ``philological`` et ``lexical_modernization``
|
| 212 |
+
s'enregistrent au chargement de :mod:`picarones.core.builtin_hooks`
|
| 213 |
+
via les imports qui traversent les shims (``from
|
| 214 |
+
picarones.core.philological_runner import ...``)."""
|
| 215 |
+
|
| 216 |
+
def test_philological_hook_registered(self):
|
| 217 |
+
# L'import déclenche l'enregistrement
|
| 218 |
+
import picarones.core.builtin_hooks # noqa: F401
|
| 219 |
+
from picarones.core.metric_hooks import _all_document_hook_names
|
| 220 |
+
|
| 221 |
+
assert "philological" in _all_document_hook_names()
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 225 |
+
# 8. Modules originaux sont des shims minces
|
| 226 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
class TestOriginalsAreShims:
|
| 230 |
+
@pytest.mark.parametrize("path", [
|
| 231 |
+
"picarones/core/unicode_blocks.py",
|
| 232 |
+
"picarones/core/abbreviations.py",
|
| 233 |
+
"picarones/core/mufi.py",
|
| 234 |
+
"picarones/core/early_modern_typography.py",
|
| 235 |
+
"picarones/core/modern_archives.py",
|
| 236 |
+
"picarones/core/roman_numerals.py",
|
| 237 |
+
"picarones/core/lexical_modernization.py",
|
| 238 |
+
"picarones/core/philological_runner.py",
|
| 239 |
+
"picarones/report/philological_render.py",
|
| 240 |
+
"picarones/report/lexical_modernization_render.py",
|
| 241 |
+
])
|
| 242 |
+
def test_is_thin_shim(self, path):
|
| 243 |
+
repo_root = Path(__file__).parent.parent
|
| 244 |
+
content = (repo_root / path).read_text(encoding="utf-8")
|
| 245 |
+
n_lines = len([line for line in content.splitlines() if line.strip()])
|
| 246 |
+
assert n_lines < 30, (
|
| 247 |
+
f"{path} fait {n_lines} lignes — devrait être un shim mince"
|
| 248 |
+
)
|
| 249 |
+
assert "déplacé" in content or "extras" in content
|