Skip to content

Commit 8b6c2df

Browse files
Merge branch 'feat/pii-gliner-engine' into dev
2 parents fcfacf9 + bff7626 commit 8b6c2df

35 files changed

Lines changed: 1811 additions & 458 deletions

File tree

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,3 +96,7 @@ i18n.cache
9696

9797
# Personal Cursor Skills
9898
.cursor/skills/ask-sim/
99+
100+
# Python (apps/pii tests/tooling)
101+
__pycache__/
102+
.pytest_cache/

apps/pii/engines.py

Lines changed: 267 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,267 @@
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

apps/pii/requirements-dev.txt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# Test-only deps. Unit tests need requirements.txt + this file (no models);
2+
# integration tests additionally need the models baked into the docker images
3+
# (see tests/test_integration.py).
4+
pytest==8.4.1
5+
httpx==0.28.1

apps/pii/requirements-gliner.txt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
# Extras for the opt-in GLiNER engine — installed ONLY in the `gliner`
2+
# Dockerfile target, on top of requirements.txt. Pinned for reproducible image
3+
# builds; bump deliberately. presidio-analyzer 2.2.362 requires
4+
# gliner >=0.2.13,<1.0.0.
5+
#
6+
# torch is pinned in the Dockerfile instead: the CPU and CUDA targets install
7+
# the same version from different wheel indexes.
8+
gliner==0.2.27
9+
transformers==4.56.2
10+
huggingface_hub==0.35.3

0 commit comments

Comments
 (0)