File size: 8,906 Bytes
b80ac6e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Backends d'extraction d'entitΓ©s nommΓ©es (Sprint 40).

Suite directe du Sprint 38 : la couche de calcul (`compute_ner_metrics`)
prend deux listes d'entitΓ©s, ce module fournit le moyen d'**obtenir** la
liste d'entitΓ©s d'un cΓ΄tΓ© Γ  partir d'un texte (gΓ©nΓ©ralement la sortie
OCR du moteur).

Architecture
------------
- ``EntityExtractor`` : Protocol Python qui dΓ©crit l'interface ; tout
  callable ``(text: str) -> list[dict]`` est un extracteur valide.  Le
  format de sortie est compatible ``EntitiesGT`` (Sprint 32) et
  ``compute_ner_metrics`` (Sprint 38).
- ``SpacyEntityExtractor`` : implΓ©mentation par dΓ©faut, lazy-import de
  spaCy.  Si spaCy n'est pas installé OU si le modèle n'est pas
  tΓ©lΓ©chargΓ©, retourne ``[]`` avec un ``logger.warning`` explicite
  (cf. règle CLAUDE.md : pas de ``except: pass``).
- ``SPACY_PROFILES`` : dict de profils nommés vers noms de modèles
  spaCy (FR, EN, multilingue, HIPE pour les corpus historiques).
- ``get_extractor(profile)`` : factory qui retourne l'extracteur
  correspondant au profil demandΓ©.

DΓ©couplage runner ↔ backend
---------------------------
Le runner reçoit un ``EntityExtractor`` en paramètre — il n'importe
**jamais** spaCy directement.  Cela permet :

1. de **tester** sans dΓ©pendance externe (le test injecte un callable
   qui simule l'extraction) ;
2. de **brancher** des backends alternatifs (Stanza, HIPE custom,
   modèle fine-tuné maison) sans modifier le runner ;
3. de **dΓ©sactiver** la mΓ©trique en passant ``None`` β€” comportement
   par dΓ©faut, rΓ©trocompat stricte.
"""

from __future__ import annotations

import logging
from typing import Any, Protocol

logger = logging.getLogger(__name__)


# ──────────────────────────────────────────────────────────────────────────
# Interface
# ──────────────────────────────────────────────────────────────────────────


class EntityExtractor(Protocol):
    """Tout callable ``(text) -> list[dict]`` est un extracteur valide.

    Format de sortie attendu : liste de dicts
    ``{"label": str, "start": int, "end": int, "text": str}``
    compatibles avec ``compute_ner_metrics`` (Sprint 38) et
    ``EntitiesGT`` (Sprint 32).
    """

    def __call__(self, text: str) -> list[dict[str, Any]]: ...


# ──────────────────────────────────────────────────────────────────────────
# Profils spaCy nommΓ©s
# ──────────────────────────────────────────────────────────────────────────


SPACY_PROFILES: dict[str, str] = {
    "fr": "fr_core_news_sm",
    "fr_lg": "fr_core_news_lg",
    "en": "en_core_web_sm",
    "en_lg": "en_core_web_lg",
    "multilingual": "xx_ent_wiki_sm",
    # HIPE 2022 — modèle historique multilingue (Hugging Face).  Pas
    # toujours disponible via ``spacy.load`` direct ; documentΓ© pour
    # mΓ©moire, l'utilisateur peut le wrapper dans un EntityExtractor
    # custom si besoin.
    "hipe": "fr_core_news_lg",
}


# ──────────────────────────────────────────────────────────────────────────
# Backend spaCy
# ──────────────────────────────────────────────────────────────────────────


class SpacyEntityExtractor:
    """Extracteur d'entitΓ©s basΓ© sur spaCy.

    Lazy-import : ``spacy`` n'est importΓ© qu'au premier appel.  Le
    modèle est chargé une seule fois et mis en cache sur l'instance.

    Si spaCy n'est pas installé OU si le modèle demandé n'est pas
    tΓ©lΓ©chargΓ©, l'extracteur tombe en mode dΓ©gradΓ© (retourne ``[]``
    pour chaque appel) et Γ©met un ``logger.warning`` au premier
    appel.

    Parameters
    ----------
    model_name:
        Nom du modèle spaCy à charger (ex. ``"fr_core_news_sm"``).
    label_mapping:
        Dict optionnel ``{spacy_label: target_label}`` pour
        normaliser les labels (ex. spaCy utilise ``"PERSON"``,
        on veut ``"PER"``).  Si ``None``, garde les labels tels
        quels.

    Examples
    --------
    >>> extractor = SpacyEntityExtractor("fr_core_news_sm")
    >>> entities = extractor("Marie de Bourgogne, en 1477.")
    >>> # liste de dicts {label, start, end, text}, ou [] si spaCy absent
    """

    # Mapping par dΓ©faut spaCy β†’ conventions HIPE/CoNLL courtes
    DEFAULT_LABEL_MAPPING: dict[str, str] = {
        "PERSON": "PER",
        "PER": "PER",
        "LOC": "LOC",
        "GPE": "LOC",       # Geo-Political Entity β†’ LOC
        "ORG": "ORG",
        "DATE": "DATE",
        "TIME": "DATE",
        "MISC": "MISC",
    }

    def __init__(
        self,
        model_name: str = "fr_core_news_sm",
        label_mapping: dict[str, str] | None = None,
    ) -> None:
        self.model_name = model_name
        self.label_mapping = (
            dict(label_mapping)
            if label_mapping is not None
            else dict(self.DEFAULT_LABEL_MAPPING)
        )
        self._nlp: Any | None = None
        self._loaded: bool = False
        self._available: bool = False

    def _load(self) -> None:
        """Charge spaCy + modèle au premier appel.  Idempotent."""
        if self._loaded:
            return
        self._loaded = True
        try:
            import spacy  # type: ignore[import-untyped]
        except ImportError as exc:
            logger.warning(
                "[ner_backends] spaCy non installΓ© (%s) β€” extraction NER "
                "dΓ©sactivΓ©e. Installer avec `pip install picarones[ner]`.",
                exc,
            )
            return
        try:
            self._nlp = spacy.load(self.model_name)
            self._available = True
        except OSError as exc:
            logger.warning(
                "[ner_backends] Modèle spaCy %r introuvable (%s) — extraction "
                "NER dΓ©sactivΓ©e. TΓ©lΓ©charger avec `python -m spacy download %s`.",
                self.model_name, exc, self.model_name,
            )

    @property
    def available(self) -> bool:
        """``True`` si spaCy + le modèle sont chargés et utilisables."""
        if not self._loaded:
            self._load()
        return self._available

    def __call__(self, text: str) -> list[dict[str, Any]]:
        if not text:
            return []
        if not self.available or self._nlp is None:
            return []
        doc = self._nlp(text)
        results: list[dict[str, Any]] = []
        for ent in doc.ents:
            label = self.label_mapping.get(ent.label_, ent.label_)
            results.append({
                "label": label,
                "start": int(ent.start_char),
                "end": int(ent.end_char),
                "text": ent.text,
            })
        return results


# ──────────────────────────────────────────────────────────────────────────
# Factory
# ──────────────────────────────────────────────────────────────────────────


def get_extractor(profile: str = "fr") -> SpacyEntityExtractor:
    """Retourne un extracteur spaCy pour le profil demandΓ©.

    Le profil peut Γͺtre :

    - une clΓ© de ``SPACY_PROFILES`` (ex. ``"fr"``, ``"en"``,
      ``"multilingual"``)
    - un nom de modèle spaCy direct (ex. ``"fr_core_news_lg"``)

    L'extracteur est instancié paresseusement (le modèle n'est chargé
    qu'au premier appel).  Si le modèle n'est pas disponible,
    l'extracteur tombe en mode dΓ©gradΓ© silencieux (retourne ``[]``).
    """
    model_name = SPACY_PROFILES.get(profile, profile)
    return SpacyEntityExtractor(model_name=model_name)


def is_spacy_available() -> bool:
    """``True`` si la librairie ``spacy`` est importable, sans charger
    de modèle."""
    try:
        import spacy  # noqa: F401
    except ImportError:
        return False
    return True


__all__ = [
    "EntityExtractor",
    "SpacyEntityExtractor",
    "SPACY_PROFILES",
    "get_extractor",
    "is_spacy_available",
]