|
| 1 | +"""Analyzer engine builders for the PII service. |
| 2 | +
|
| 3 | +Two NER engines share one recognizer surface: |
| 4 | +
|
| 5 | +- spacy (default): the 5 large spaCy models do NER (PERSON/LOCATION/NRP/ |
| 6 | + DATE_TIME) and tokenization. |
| 7 | +- gliner (opt-in): one multilingual GLiNER model does NER on CPU or GPU; |
| 8 | + small spaCy models remain only for tokenization + lemmas. |
| 9 | +
|
| 10 | +Both engines register the identical regex/checksum recognizer set (Presidio |
| 11 | +defaults, EXTRA_RECOGNIZERS, VIN) — only the source of the 4 NER entity types |
| 12 | +differs. Side-effect free: importing this module loads no models. |
| 13 | +""" |
| 14 | + |
| 15 | +import importlib.util |
| 16 | + |
| 17 | +import spacy.util |
| 18 | +from presidio_analyzer import AnalyzerEngine, Pattern, PatternRecognizer |
| 19 | +from presidio_analyzer.nlp_engine import NlpEngineProvider |
| 20 | +from presidio_analyzer.predefined_recognizers import ( |
| 21 | + AuAbnRecognizer, |
| 22 | + AuAcnRecognizer, |
| 23 | + AuMedicareRecognizer, |
| 24 | + AuTfnRecognizer, |
| 25 | + EsNieRecognizer, |
| 26 | + EsNifRecognizer, |
| 27 | + FiPersonalIdentityCodeRecognizer, |
| 28 | + GLiNERRecognizer, |
| 29 | + InAadhaarRecognizer, |
| 30 | + InPanRecognizer, |
| 31 | + InPassportRecognizer, |
| 32 | + InVehicleRegistrationRecognizer, |
| 33 | + InVoterRecognizer, |
| 34 | + ItDriverLicenseRecognizer, |
| 35 | + ItFiscalCodeRecognizer, |
| 36 | + ItIdentityCardRecognizer, |
| 37 | + ItPassportRecognizer, |
| 38 | + ItVatCodeRecognizer, |
| 39 | + PlPeselRecognizer, |
| 40 | + SgFinRecognizer, |
| 41 | + SgUenRecognizer, |
| 42 | + UkNinoRecognizer, |
| 43 | +) |
| 44 | + |
| 45 | +# Languages served. Each needs its spaCy model installed in the image; the |
| 46 | +# es/it/pl/fi predefined recognizers (ES_NIF, IT_FISCAL_CODE, PL_PESEL, ...) |
| 47 | +# auto-load once their NLP engine is present. |
| 48 | +NLP_CONFIGURATION = { |
| 49 | + "nlp_engine_name": "spacy", |
| 50 | + "models": [ |
| 51 | + {"lang_code": "en", "model_name": "en_core_web_lg"}, |
| 52 | + {"lang_code": "es", "model_name": "es_core_news_lg"}, |
| 53 | + {"lang_code": "it", "model_name": "it_core_news_lg"}, |
| 54 | + {"lang_code": "pl", "model_name": "pl_core_news_lg"}, |
| 55 | + {"lang_code": "fi", "model_name": "fi_core_news_lg"}, |
| 56 | + ], |
| 57 | +} |
| 58 | +SUPPORTED_LANGUAGES = [m["lang_code"] for m in NLP_CONFIGURATION["models"]] |
| 59 | + |
| 60 | +# The gliner engine still needs a spaCy pipeline per language: the regex |
| 61 | +# recognizers consume NlpArtifacts and the LemmaContextAwareEnhancer boosts |
| 62 | +# scores from surrounding lemmas. The small models (~12-40MB each vs ~400MB |
| 63 | +# large) keep tokenization + lemmas intact while GLiNER owns NER. Blank |
| 64 | +# pipelines ("blank:xx") are not an option: Presidio's SpacyNlpEngine treats |
| 65 | +# unknown model names as pip packages and tries to download them. |
| 66 | +# labels_to_ignore strips the small models' NER output from NlpArtifacts — |
| 67 | +# correctness comes from removing SpacyRecognizer in build_gliner_analyzer; |
| 68 | +# this only silences unmapped-label noise. |
| 69 | +GLINER_NLP_CONFIGURATION = { |
| 70 | + "nlp_engine_name": "spacy", |
| 71 | + "models": [ |
| 72 | + {"lang_code": "en", "model_name": "en_core_web_sm"}, |
| 73 | + {"lang_code": "es", "model_name": "es_core_news_sm"}, |
| 74 | + {"lang_code": "it", "model_name": "it_core_news_sm"}, |
| 75 | + {"lang_code": "pl", "model_name": "pl_core_news_sm"}, |
| 76 | + {"lang_code": "fi", "model_name": "fi_core_news_sm"}, |
| 77 | + ], |
| 78 | + "ner_model_configuration": { |
| 79 | + "labels_to_ignore": [ |
| 80 | + "CARDINAL", "DATE", "EVENT", "FAC", "GPE", "LANGUAGE", "LAW", |
| 81 | + "LOC", "MISC", "MONEY", "NORP", "ORDINAL", "ORG", "PER", |
| 82 | + "PERCENT", "PERSON", "PRODUCT", "QUANTITY", "TIME", "WORK_OF_ART", |
| 83 | + ], |
| 84 | + }, |
| 85 | +} |
| 86 | + |
| 87 | +# Zero-shot label prompts -> the 4 Presidio NER entities GLiNER owns. Multiple |
| 88 | +# prompts per entity trade a little inference cost for recall; tune against |
| 89 | +# scripts/bench_engines.py output. |
| 90 | +GLINER_ENTITY_MAPPING = { |
| 91 | + "person": "PERSON", |
| 92 | + "name": "PERSON", |
| 93 | + "location": "LOCATION", |
| 94 | + "address": "LOCATION", |
| 95 | + "date": "DATE_TIME", |
| 96 | + "time": "DATE_TIME", |
| 97 | + "nationality": "NRP", |
| 98 | + "religious group": "NRP", |
| 99 | + "political group": "NRP", |
| 100 | + "ethnic group": "NRP", |
| 101 | +} |
| 102 | + |
| 103 | +# Predefined recognizers Presidio ships but does NOT load into the default |
| 104 | +# registry — they must be added explicitly. Each carries its own |
| 105 | +# supported_language, so it fires under that language once its NLP model is |
| 106 | +# loaded. en: UK/AU/IN/SG locale ids; es/it/pl/fi: national ids. |
| 107 | +EXTRA_RECOGNIZERS = [ |
| 108 | + UkNinoRecognizer, |
| 109 | + AuAbnRecognizer, |
| 110 | + AuAcnRecognizer, |
| 111 | + AuTfnRecognizer, |
| 112 | + AuMedicareRecognizer, |
| 113 | + InPanRecognizer, |
| 114 | + InAadhaarRecognizer, |
| 115 | + InVehicleRegistrationRecognizer, |
| 116 | + InVoterRecognizer, |
| 117 | + InPassportRecognizer, |
| 118 | + SgFinRecognizer, |
| 119 | + SgUenRecognizer, |
| 120 | + EsNifRecognizer, |
| 121 | + EsNieRecognizer, |
| 122 | + ItFiscalCodeRecognizer, |
| 123 | + ItDriverLicenseRecognizer, |
| 124 | + ItVatCodeRecognizer, |
| 125 | + ItPassportRecognizer, |
| 126 | + ItIdentityCardRecognizer, |
| 127 | + PlPeselRecognizer, |
| 128 | + FiPersonalIdentityCodeRecognizer, |
| 129 | +] |
| 130 | + |
| 131 | + |
| 132 | +class VinRecognizer(PatternRecognizer): |
| 133 | + """VIN (17 chars, A-Z/0-9 excluding I/O/Q) with ISO 3779 check-digit |
| 134 | + validation (position 9). Validation makes accidental matches on arbitrary |
| 135 | + 17-char codes (request ids, SKUs, tokens) extremely unlikely. Some |
| 136 | + non-North-American VINs omit the check digit and are skipped — an |
| 137 | + intentional bias toward precision. |
| 138 | + """ |
| 139 | + |
| 140 | + _TRANSLIT = { |
| 141 | + **{str(d): d for d in range(10)}, |
| 142 | + "A": 1, "B": 2, "C": 3, "D": 4, "E": 5, "F": 6, "G": 7, "H": 8, |
| 143 | + "J": 1, "K": 2, "L": 3, "M": 4, "N": 5, "P": 7, "R": 9, |
| 144 | + "S": 2, "T": 3, "U": 4, "V": 5, "W": 6, "X": 7, "Y": 8, "Z": 9, |
| 145 | + } |
| 146 | + _WEIGHTS = [8, 7, 6, 5, 4, 3, 2, 10, 0, 9, 8, 7, 6, 5, 4, 3, 2] |
| 147 | + |
| 148 | + def validate_result(self, pattern_text: str): |
| 149 | + vin = pattern_text.upper() |
| 150 | + if len(vin) != 17: |
| 151 | + return False |
| 152 | + try: |
| 153 | + total = sum(self._TRANSLIT[c] * w for c, w in zip(vin, self._WEIGHTS)) |
| 154 | + except KeyError: |
| 155 | + return False |
| 156 | + check = total % 11 |
| 157 | + expected = "X" if check == 10 else str(check) |
| 158 | + return vin[8] == expected |
| 159 | + |
| 160 | + |
| 161 | +class SharedModelGLiNERRecognizer(GLiNERRecognizer): |
| 162 | + """Per-language GLiNER recognizer sharing ONE loaded model. |
| 163 | +
|
| 164 | + Presidio routes recognizers by supported_language, so the registry holds |
| 165 | + one instance per served language — but each instance's load() would pull |
| 166 | + its own ~1.2GB model copy. The first instance loads (an ImportError from |
| 167 | + a missing gliner package propagates — fail fast in the lean image); the |
| 168 | + rest reuse the cached model. |
| 169 | + """ |
| 170 | + |
| 171 | + _shared_models: dict = {} |
| 172 | + |
| 173 | + def load(self) -> None: |
| 174 | + key = (self.model_name, self.map_location) |
| 175 | + cached = self._shared_models.get(key) |
| 176 | + if cached is None: |
| 177 | + super().load() |
| 178 | + self._shared_models[key] = self.gliner |
| 179 | + else: |
| 180 | + self.gliner = cached |
| 181 | + |
| 182 | + def analyze(self, text, entities, nlp_artifacts=None): |
| 183 | + """GLiNERRecognizer appends any requested entity it doesn't know as an |
| 184 | + ad-hoc zero-shot label and returns its hits. The analyzer passes ALL |
| 185 | + supported entities (~40) when a request doesn't narrow them, which |
| 186 | + would prompt GLiNER for CREDIT_CARD/VIN/ES_NIF/... — wrong scope, and |
| 187 | + inference cost scales with label count. Restrict to the NER entities |
| 188 | + this recognizer owns.""" |
| 189 | + requested = [e for e in (entities or self.supported_entities) if e in self.supported_entities] |
| 190 | + if not requested: |
| 191 | + return [] |
| 192 | + return super().analyze(text, requested, nlp_artifacts) |
| 193 | + |
| 194 | + |
| 195 | +def _register_common_recognizers(analyzer: AnalyzerEngine) -> None: |
| 196 | + """Regex/checksum recognizers shared by both engines.""" |
| 197 | + # VIN is language-agnostic, so register it under every served language — |
| 198 | + # a recognizer only fires for the language the caller routes to. |
| 199 | + vin_pattern = Pattern(name="vin", regex=r"\b[A-HJ-NPR-Z0-9]{17}\b", score=0.7) |
| 200 | + for language in SUPPORTED_LANGUAGES: |
| 201 | + analyzer.registry.add_recognizer( |
| 202 | + VinRecognizer( |
| 203 | + supported_entity="VIN", |
| 204 | + patterns=[vin_pattern], |
| 205 | + context=["vin", "vehicle", "chassis"], |
| 206 | + supported_language=language, |
| 207 | + ) |
| 208 | + ) |
| 209 | + for recognizer_cls in EXTRA_RECOGNIZERS: |
| 210 | + analyzer.registry.add_recognizer(recognizer_cls()) |
| 211 | + |
| 212 | + |
| 213 | +def build_spacy_analyzer() -> AnalyzerEngine: |
| 214 | + nlp_engine = NlpEngineProvider(nlp_configuration=NLP_CONFIGURATION).create_engine() |
| 215 | + analyzer = AnalyzerEngine(nlp_engine=nlp_engine, supported_languages=SUPPORTED_LANGUAGES) |
| 216 | + _register_common_recognizers(analyzer) |
| 217 | + return analyzer |
| 218 | + |
| 219 | + |
| 220 | +def build_gliner_analyzer(model_name: str, device: str | None) -> AnalyzerEngine: |
| 221 | + """GLiNER engine: one multilingual zero-shot model replaces spaCy NER for |
| 222 | + PERSON/LOCATION/NRP/DATE_TIME; everything else is unchanged. |
| 223 | +
|
| 224 | + :param model_name: HuggingFace id of the GLiNER model. |
| 225 | + :param device: torch device ("cpu", "cuda", "cuda:0"); None auto-detects |
| 226 | + via Presidio's device_detector (cuda when available, else cpu). |
| 227 | + """ |
| 228 | + # Fail fast with an actionable message when gliner deps are missing (e.g. |
| 229 | + # a custom-built image without them). Without these checks Presidio would |
| 230 | + # try to pip-download the missing spaCy models at startup (a silent |
| 231 | + # network fallback that dies with an unrelated pip permission error), and |
| 232 | + # the gliner ImportError would surface only later. |
| 233 | + if importlib.util.find_spec("gliner") is None: |
| 234 | + raise RuntimeError( |
| 235 | + "PII_ENGINE=gliner but the gliner package is not installed; " |
| 236 | + "use the stock pii image (docker/pii.Dockerfile ships torch + gliner)" |
| 237 | + ) |
| 238 | + missing = [ |
| 239 | + m["model_name"] |
| 240 | + for m in GLINER_NLP_CONFIGURATION["models"] |
| 241 | + if not spacy.util.is_package(m["model_name"]) |
| 242 | + ] |
| 243 | + if missing: |
| 244 | + raise RuntimeError( |
| 245 | + f"PII_ENGINE=gliner needs spaCy models {missing}; " |
| 246 | + "use the stock pii image (docker/pii.Dockerfile ships them)" |
| 247 | + ) |
| 248 | + nlp_engine = NlpEngineProvider(nlp_configuration=GLINER_NLP_CONFIGURATION).create_engine() |
| 249 | + analyzer = AnalyzerEngine(nlp_engine=nlp_engine, supported_languages=SUPPORTED_LANGUAGES) |
| 250 | + # The default registry wires SpacyRecognizer per language; with GLiNER |
| 251 | + # owning the NER entities it would emit duplicate/competing spans from the |
| 252 | + # small models' ner pipe. remove_recognizer only logs when nothing matched, |
| 253 | + # so assert the removal actually happened. |
| 254 | + analyzer.registry.remove_recognizer("SpacyRecognizer") |
| 255 | + if any(r.name == "SpacyRecognizer" for r in analyzer.registry.recognizers): |
| 256 | + raise RuntimeError("SpacyRecognizer removal failed; Presidio registry layout changed") |
| 257 | + for language in SUPPORTED_LANGUAGES: |
| 258 | + analyzer.registry.add_recognizer( |
| 259 | + SharedModelGLiNERRecognizer( |
| 260 | + entity_mapping=GLINER_ENTITY_MAPPING, |
| 261 | + model_name=model_name, |
| 262 | + map_location=device, |
| 263 | + supported_language=language, |
| 264 | + ) |
| 265 | + ) |
| 266 | + _register_common_recognizers(analyzer) |
| 267 | + return analyzer |
0 commit comments