Spaces:
Sleeping
Sleeping
| """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, | |
| ) | |
| 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", | |
| ] | |