#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ GLiNER Manager — NER zero-shot pour validation croisée des entités. ------------------------------------------------------------------- Utilise GLiNER (< 500M params, CPU) comme 3e signal NER en vote majoritaire avec CamemBERT ONNX + EDS-Pseudo. Réduit les faux positifs : une entité flaggée par 1 seul modèle sur 3 est supprimée. Modèle : urchade/gliner_multi_pii-v1 (1.1 GB, ~95ms/inférence CPU) Version compatible : gliner==0.2.18 (pas plus récent, casse optimum-onnx) """ from __future__ import annotations import logging from typing import Any, Dict, List, Optional log = logging.getLogger(__name__) try: from gliner import GLiNER _GLINER_AVAILABLE = True except ImportError: GLiNER = None # type: ignore _GLINER_AVAILABLE = False # Labels zero-shot pour la détection PII en contexte clinique français GLINER_PII_LABELS = [ "person_name", "date_of_birth", "phone_number", "email_address", "social_security_number", "postal_address", "hospital", "city", ] # Labels pour identifier les termes médicaux (anti-PII : si classé ici → pas un nom) GLINER_SAFE_LABELS = [ "medication", "medical_condition", "medical_procedure", ] # Mapping GLiNER label → clé PLACEHOLDERS GLINER_LABEL_MAP: Dict[str, str] = { "person_name": "NOM", "date_of_birth": "DATE_NAISSANCE", "phone_number": "TEL", "email_address": "EMAIL", "social_security_number": "NIR", "postal_address": "ADRESSE", "hospital": "ETAB", "city": "VILLE", } DEFAULT_MODEL = "urchade/gliner_multi_pii-v1" class GlinerManager: """Gestionnaire GLiNER pour NER zero-shot. Utilisé en vote majoritaire.""" def __init__(self): self._model = None self._loaded = False self.model_id: Optional[str] = None def is_loaded(self) -> bool: return self._loaded and self._model is not None def load(self, model_id: str = DEFAULT_MODEL) -> None: if not _GLINER_AVAILABLE: raise RuntimeError("gliner non disponible. Installez : pip install 'gliner==0.2.18'") self.unload() self.model_id = model_id self._model = GLiNER.from_pretrained(model_id) self._loaded = True log.info(f"GLiNER chargé: {model_id}") def unload(self) -> None: self._model = None self._loaded = False self.model_id = None def predict( self, text: str, labels: Optional[List[str]] = None, threshold: float = 0.5, ) -> List[Dict[str, Any]]: """Prédit les entités dans un texte. Returns: Liste de dicts avec: text, label, score, start, end """ if not self.is_loaded(): return [] if labels is None: labels = GLINER_PII_LABELS + GLINER_SAFE_LABELS try: entities = self._model.predict_entities(text, labels, threshold=threshold) return [ { "text": e["text"], "label": e["label"], "score": e["score"], "start": e["start"], "end": e["end"], } for e in entities ] except Exception as e: log.warning(f"GLiNER predict error: {e}") return [] def is_pii(self, text: str, entity_text: str, threshold: float = 0.5) -> Optional[str]: """Vérifie si un token est un PII selon GLiNER. Returns: La clé PLACEHOLDERS mappée si PII, None sinon. """ if not self.is_loaded(): return None entities = self.predict(text, threshold=threshold) for e in entities: if e["text"].strip().lower() == entity_text.strip().lower(): if e["label"] in GLINER_LABEL_MAP: return GLINER_LABEL_MAP[e["label"]] if e["label"] in GLINER_SAFE_LABELS: return None # Explicitement classé comme terme médical return None # Pas trouvé → pas de vote def validate_entities( self, text: str, eds_entities: List[Dict[str, Any]], threshold: float = 0.4, ) -> List[Dict[str, Any]]: """Valide les entités EDS-Pseudo via GLiNER (vote croisé). Chaque entité EDS reçoit un champ 'gliner_confirmed': True/False/None. - True : GLiNER aussi détecte ce span comme PII - False : GLiNER classifie ce span comme terme médical (medication/condition/procedure) - None : GLiNER ne détecte rien (neutre) """ if not self.is_loaded() or not eds_entities: return eds_entities # Prédiction GLiNER sur tout le texte all_labels = GLINER_PII_LABELS + GLINER_SAFE_LABELS gliner_preds = self.predict(text, labels=all_labels, threshold=threshold) # Index rapide : pour chaque position de caractère, quelles entités GLiNER couvrent for e in eds_entities: e_start = e.get("start", -1) e_end = e.get("end", -1) e_word = (e.get("word") or "").lower() confirmed = None # par défaut: neutre for g in gliner_preds: g_text = g["text"].lower() # Match par overlap ou par texte identique overlap = ( (g["start"] <= e_start < g["end"]) or (g["start"] < e_end <= g["end"]) or (e_start <= g["start"] and e_end >= g["end"]) ) text_match = g_text == e_word or e_word in g_text or g_text in e_word if overlap or text_match: if g["label"] in GLINER_SAFE_LABELS: confirmed = False # GLiNER dit: c'est médical, pas PII break elif g["label"] in GLINER_LABEL_MAP: confirmed = True # GLiNER confirme: c'est PII e["gliner_confirmed"] = confirmed return eds_entities