File size: 14,188 Bytes
f593a34
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
"""Score d'expansion d'abréviations médiévales — Sprint 56.

Sprint 56 — A.II.3.2 du plan d'évolution 2026 (axe philologique).

Pourquoi ce module
------------------
Sur les manuscrits médiévaux (chartes, registres, copies de droit
canonique), les scribes utilisent intensivement des **signes
d'abréviation** : ``ꝑ`` (per/par), ``ꝓ`` (pro), ``ꝗ`` (qui),
``ꝙ`` (quia), ``ꝯ`` (con/-us), ``⁊`` (et), tilde combinant pour
``-en/-an``, etc.

Un OCR/HTR a deux comportements possibles face à ces signes :

1. **Préservation** : la forme abrégée est gardée telle quelle
   (``ꝑ`` → ``ꝑ``).  C'est le comportement attendu d'une
   transcription **diplomatique** (édition critique).
2. **Développement** : le signe est remplacé par sa forme
   développée (``ꝑ`` → ``per``).  C'est le comportement attendu
   d'une édition **modernisée**.

Une troisième possibilité — et c'est l'erreur qu'on cherche à
détecter : le signe est **mal restitué** (remplacé par un
caractère ASCII proche, supprimé, ou mal développé).

Ce module produit deux scores complémentaires :

- ``abbreviation_strict_score`` : taux d'abréviations GT dont la
  **forme abrégée Unicode est préservée** dans l'OCR.
- ``abbreviation_expansion_score`` : taux d'abréviations GT dont
  **soit** la forme abrégée, **soit** la forme développée
  attendue, est présente dans l'OCR.

Le **ratio** des deux dit beaucoup sur la convention adoptée :

- ``strict ≈ expansion`` proche de 1 → le moteur est diplomatique
  (préserve l'abrégé) ;
- ``strict << expansion`` → le moteur est modernisant (développe
  systématiquement) ;
- les deux faibles → le moteur perd les abréviations (signal
  d'erreur OCR).

Stratégie de découpage
----------------------
Cohérente avec NER (Sprint 38), Flesch (52), Reading order F1 (53),
Layout F1 (54), Bloc Unicode (55) : couche de calcul pure d'abord.
Le câblage runner et la vue HTML suivent dans des sprints dédiés.

Limites documentées
-------------------
- L'alignement est **bag-of-occurrences** (proxy positionnel
  simple) : on compte les occurrences GT et on vérifie leur
  présence dans l'hyp.  Pas d'alignement séquentiel rigoureux.
- La table d'abréviations couvre les signes les plus courants en
  scriptura latine européenne (Capelli).  Elle est extensible via
  ``ABBREVIATION_EXPANSIONS``.
- Pour les abréviations marquées par un **tilde combinant**
  (``p̃``, ``q̃``), on détecte la séquence ``lettre + U+0303``.
  Pas de gestion fine des polices Capelli/MUFI complètes.
"""

from __future__ import annotations

import logging
import re
import unicodedata
from typing import Optional

from picarones.core.metric_registry import register_metric
from picarones.core.modules import ArtifactType

logger = logging.getLogger(__name__)


# ──────────────────────────────────────────────────────────────────────────
# Table d'expansions
# ──────────────────────────────────────────────────────────────────────────

# Signes d'abréviation latins médiévaux les plus courants.
# Source : Capelli, "Lexicon Abbreviaturarum" (1929) + MUFI.
#
# La clé est une chaîne (1 ou 2 code-points pour le cas tilde
# combinant) ; la valeur est la liste des expansions courantes
# acceptées (les détails varient selon la convention éditoriale,
# on accepte plusieurs formes).
ABBREVIATION_EXPANSIONS: dict[str, tuple[str, ...]] = {
    "ꝑ": ("per", "par"),                       # U+A751
    "ꝓ": ("pro",),                              # U+A753
    "ꝗ": ("qui",),                              # U+A757
    "ꝙ": ("quia",),                             # U+A759
    "ꝯ": ("us", "con"),                         # U+A76F
    "⁊": ("et",),                               # U+204A "et" tironien
    "ꝝ": ("rum",),                              # U+A75D
    "ꝫ": ("et",),                               # U+A76B
    "ꝭ": ("is",),                               # U+A76D
    # Tilde combinant après lettre (U+0303 = ̃) : pẽ, qũ, etc.
    "p̃": ("par", "per"),
    "q̃": ("que", "qui"),
    "ñ": ("an", "en"),                          # U+00F1 (Latin-1 Sup)
    # Note : ñ existe aussi comme caractère latin moderne (espagnol),
    # donc l'attribuer aux abréviations introduit du bruit ; on
    # laisse au benchmark le soin d'évaluer.  Pour les éditeurs
    # médiévistes qui veulent restreindre, ils peuvent passer par
    # une table custom (à venir).
}


# Set des "premiers code-points" reconnus comme début d'une
# abréviation (pour balayage rapide).
_ABBR_FIRST_CHARS: frozenset[str] = frozenset(
    abbr[0] for abbr in ABBREVIATION_EXPANSIONS
)


# Combining tilde (U+0303) — utilisé pour la détection p̃, q̃, etc.
_COMBINING_TILDE = "̃"


# ──────────────────────────────────────────────────────────────────────────
# Détection d'abréviations dans un texte
# ──────────────────────────────────────────────────────────────────────────


def detect_abbreviations(text: Optional[str]) -> list[str]:
    """Liste des abréviations médiévales détectées dans ``text``,
    dans l'ordre d'apparition.

    Reconnaît :

    - Les caractères Unicode dédiés présents dans
      ``ABBREVIATION_EXPANSIONS`` (``ꝑ``, ``ꝓ``, ``⁊``…).
    - Les séquences ``lettre + U+0303`` (tilde combinant) si la
      paire est dans la table (``p̃``, ``q̃``).

    Doublons conservés : si le texte contient deux ``ꝑ``, la liste
    en a deux.  Cohérent avec le calcul bag-of-occurrences en aval.
    """
    if not text:
        return []
    found: list[str] = []
    # Forme NFD pour reconnaître les ã, p̃, q̃ même quand l'utilisateur
    # passe la forme NFC (« ñ » = U+00F1 sera traité par le mapping
    # direct ; les séquences manuelles ``p`` + tilde combinant restent
    # détectables).
    text_nfd = unicodedata.normalize("NFD", text)
    i = 0
    while i < len(text_nfd):
        ch = text_nfd[i]
        # Cas 1 : lettre + tilde combinant
        if i + 1 < len(text_nfd) and text_nfd[i + 1] == _COMBINING_TILDE:
            seq = ch + _COMBINING_TILDE
            if seq in ABBREVIATION_EXPANSIONS:
                found.append(seq)
                i += 2
                continue
        # Cas 2 : caractère unicode dédié
        if ch in ABBREVIATION_EXPANSIONS:
            found.append(ch)
        i += 1
    return found


# ──────────────────────────────────────────────────────────────────────────
# Scores
# ──────────────────────────────────────────────────────────────────────────


def _hyp_contains_abbr(hypothesis: str, abbr: str) -> bool:
    """Vrai si la forme abrégée ``abbr`` apparaît telle quelle dans
    ``hypothesis``.  Sensible aux deux formes NFC / NFD pour les
    séquences à tilde combinant."""
    if abbr in hypothesis:
        return True
    # Pour les séquences ``lettre + tilde combinant``, l'hyp peut
    # avoir une forme NFC (ex. ``ñ`` au lieu de ``n + U+0303``).
    nfd = unicodedata.normalize("NFD", hypothesis)
    return abbr in nfd


def _hyp_contains_expansion(
    hypothesis: str, expansions: tuple[str, ...],
) -> bool:
    """Vrai si l'une des formes développées apparaît dans ``hypothesis``
    (recherche insensible à la casse, sur les frontières de mots
    pour limiter les faux positifs sur les sous-chaînes courtes
    type ``us`` ou ``et``)."""
    if not expansions:
        return False
    hyp_lower = hypothesis.lower()
    for exp in expansions:
        if not exp:
            continue
        # Recherche frontière de mot pour les expansions courtes.
        # Pour ``per`` ou ``pro`` : on accepte le développement à
        # n'importe quelle position d'un mot (tolère ``per`` dans
        # ``permettre``, c'est imprécis mais pragmatique).  Pour
        # les expansions très courtes (≤ 2 lettres), on impose un
        # mot complet pour limiter le bruit.
        if len(exp) <= 2:
            if re.search(rf"\b{re.escape(exp)}\b", hyp_lower):
                return True
        else:
            if exp.lower() in hyp_lower:
                return True
    return False


def compute_abbreviation_metrics(
    reference: Optional[str],
    hypothesis: Optional[str],
) -> dict:
    """Calcule les scores d'abréviation strict et d'expansion.

    Parameters
    ----------
    reference:
        Texte GT (avec abréviations médiévales originales).
    hypothesis:
        Texte produit par l'OCR.

    Returns
    -------
    dict
        ``{
            "n_abbreviations_in_reference": int,
            "n_strict_preserved":   int,    # forme abrégée préservée
            "n_expansion_preserved": int,    # abrégée OU développée
            "strict_score":   float,         # ∈ [0, 1]
            "expansion_score": float,        # ∈ [0, 1]
            "per_abbreviation": [
                {"abbr", "strict_preserved", "expansion_preserved",
                 "expansions"},
                ...
            ],
        }``

    Cas dégénérés
    -------------
    - GT vide ou sans abréviation détectée → tous les compteurs à 0
      et les scores à ``0.0`` (convention : on ne récompense pas
      l'absence d'abréviations).
    - GT non vide avec abréviations + hyp vide → tous les scores
      à ``0.0``.
    """
    ref = reference or ""
    hyp = hypothesis or ""

    abbreviations = detect_abbreviations(ref)
    n = len(abbreviations)
    if n == 0:
        return {
            "n_abbreviations_in_reference": 0,
            "n_strict_preserved": 0,
            "n_expansion_preserved": 0,
            "strict_score": 0.0,
            "expansion_score": 0.0,
            "per_abbreviation": [],
        }

    n_strict = 0
    n_expansion = 0
    per_abbr: list[dict] = []
    for abbr in abbreviations:
        expansions = ABBREVIATION_EXPANSIONS.get(abbr, ())
        strict_ok = _hyp_contains_abbr(hyp, abbr)
        # Expansion : on accepte la forme abrégée OU le développement.
        # Convention : si l'OCR a préservé la forme abrégée, c'est
        # aussi compté comme valide pour le score d'expansion (le
        # moteur n'a pas perdu l'information ; il a juste choisi
        # une convention diplomatique).
        expansion_ok = strict_ok or _hyp_contains_expansion(hyp, expansions)
        if strict_ok:
            n_strict += 1
        if expansion_ok:
            n_expansion += 1
        per_abbr.append({
            "abbr": abbr,
            "strict_preserved": strict_ok,
            "expansion_preserved": expansion_ok,
            "expansions": list(expansions),
        })

    return {
        "n_abbreviations_in_reference": n,
        "n_strict_preserved": n_strict,
        "n_expansion_preserved": n_expansion,
        "strict_score": n_strict / n,
        "expansion_score": n_expansion / n,
        "per_abbreviation": per_abbr,
    }


def abbreviation_strict_score(
    reference: Optional[str], hypothesis: Optional[str],
) -> float:
    """Raccourci : taux de préservation **stricte** des abréviations
    Unicode (forme abrégée gardée telle quelle)."""
    return compute_abbreviation_metrics(reference, hypothesis)["strict_score"]


def abbreviation_expansion_score(
    reference: Optional[str], hypothesis: Optional[str],
) -> float:
    """Raccourci : taux de préservation par expansion (forme abrégée
    OU forme développée présente dans l'hyp)."""
    return compute_abbreviation_metrics(reference, hypothesis)["expansion_score"]


# ──────────────────────────────────────────────────────────────────────────
# Enregistrement dans le registre typé (Sprint 34)
# ──────────────────────────────────────────────────────────────────────────


@register_metric(
    name="abbreviation_strict_score",
    input_types=(ArtifactType.TEXT, ArtifactType.TEXT),
    description=(
        "Taux d'abréviations médiévales (Unicode dédié + lettre + "
        "tilde combinant) dont la forme abrégée est préservée telle "
        "quelle dans l'OCR. Idéal pour les éditions diplomatiques."
    ),
    higher_is_better=True,
    tags={"text", "abbreviation", "philology", "medieval"},
)
def _registered_strict(reference: str, hypothesis: str) -> float:
    return abbreviation_strict_score(reference, hypothesis)


@register_metric(
    name="abbreviation_expansion_score",
    input_types=(ArtifactType.TEXT, ArtifactType.TEXT),
    description=(
        "Taux d'abréviations dont SOIT la forme abrégée Unicode SOIT "
        "la forme développée attendue (per, pro, et…) est présente "
        "dans l'OCR. Score plus large que strict_score."
    ),
    higher_is_better=True,
    tags={"text", "abbreviation", "philology", "medieval"},
)
def _registered_expansion(reference: str, hypothesis: str) -> float:
    return abbreviation_expansion_score(reference, hypothesis)


__all__ = [
    "ABBREVIATION_EXPANSIONS",
    "detect_abbreviations",
    "compute_abbreviation_metrics",
    "abbreviation_strict_score",
    "abbreviation_expansion_score",
]