Skip to content

Commit e836d99

Browse files
fix(pii): gate the spaCy fast path on entities the loaded models can produce
The registry's SpacyRecognizers claim every entity in Presidio's default NER-model mapping — including PHONE_NUMBER/AGE/ID/EMAIL, which exist for transformer de-identification backends and which no spaCy model can emit. The NER_ENTITIES derivation trusted that claim, so any request naming PHONE_NUMBER (present in nearly every redaction rule) silently forced the full spaCy pass and the regex-only fast path never fired. Intersect the claimed set with the entities the loaded models' actual NER labels map onto; the hard floor of core NER entities is unchanged, and a future backend that genuinely emits phone labels would re-gate automatically. Verified live: PHONE_NUMBER-only requests take nlp=skip with span parity against the full path, PERSON still forces NER, and a 26MB/40k-record block-output redaction with the realistic entity set runs entirely on the fast path (~3.2min vs ~15min projected full-NER on one worker). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A1JYstmLHk9qMGyBDqYRcJ
1 parent b7c66c0 commit e836d99

1 file changed

Lines changed: 39 additions & 12 deletions

File tree

apps/pii/server.py

Lines changed: 39 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -176,23 +176,50 @@ def build_analyzer() -> AnalyzerEngine:
176176
batch_analyzer = BatchAnalyzerEngine(analyzer_engine=analyzer)
177177
anonymizer = AnonymizerEngine()
178178

179-
# Every entity the spaCy NER recognizers can produce. A request touching any of
180-
# these must run spaCy; a request naming only non-NER (regex/checksum) entities can
181-
# skip it. Derived from the live registry so it stays authoritative if Presidio's
182-
# default entity set changes (e.g. ORGANIZATION), unioned with a known floor so an
183-
# unexpectedly empty derivation can never let an NER request skip the NLP pass.
179+
# Every entity the spaCy NER recognizers can actually produce. A request touching
180+
# any of these must run spaCy; a request naming only non-NER (regex/checksum)
181+
# entities can skip it. The registry's SpacyRecognizers CLAIM every entity in
182+
# Presidio's default NER-model mapping — including PHONE_NUMBER/AGE/ID/EMAIL,
183+
# which exist for transformer models and which no spaCy model can emit — so the
184+
# claimed set is intersected with the entities the loaded models' NER labels map
185+
# onto. Without that filter, selecting PHONE_NUMBER (present in nearly every
186+
# redaction rule) silently forces the full spaCy pass and the regex-only fast
187+
# path never fires. The floor guarantees the core NER entities always take the
188+
# full pass even if label introspection ever derives empty.
184189
_SPACY_NER_FLOOR = frozenset({"PERSON", "LOCATION", "NRP", "DATE_TIME", "ORGANIZATION"})
185-
NER_ENTITIES = _SPACY_NER_FLOOR | frozenset(
186-
entity
187-
for recognizer in analyzer.registry.recognizers
188-
if isinstance(recognizer, SpacyRecognizer)
189-
for entity in recognizer.supported_entities
190+
191+
192+
def _producible_ner_entities() -> frozenset:
193+
"""Presidio entities some loaded spaCy model's NER labels map onto."""
194+
configuration = getattr(analyzer.nlp_engine, "ner_model_configuration", None)
195+
mapping = configuration.model_to_presidio_entity_mapping if configuration else {}
196+
producible = set()
197+
for nlp in getattr(analyzer.nlp_engine, "nlp", {}).values():
198+
if "ner" not in nlp.pipe_names:
199+
continue
200+
for label in nlp.get_pipe("ner").labels:
201+
entity = mapping.get(label)
202+
if entity:
203+
producible.add(entity)
204+
return frozenset(producible)
205+
206+
207+
NER_ENTITIES = _SPACY_NER_FLOOR | (
208+
frozenset(
209+
entity
210+
for recognizer in analyzer.registry.recognizers
211+
if isinstance(recognizer, SpacyRecognizer)
212+
for entity in recognizer.supported_entities
213+
)
214+
& _producible_ner_entities()
190215
)
191216

192217
# One blank NlpArtifacts per language, built once at startup. Passing these to
193218
# analyze() skips nlp_engine.process_text (the spaCy tok2vec+ner pass) entirely:
194-
# the pattern recognizers still match on the raw text, SpacyRecognizer is excluded
195-
# by the entity filter, and score_threshold is unset so detection is identical.
219+
# the pattern recognizers still match on the raw text, SpacyRecognizer finds
220+
# nothing in blank artifacts (and can only be consulted at all for claimed-but-
221+
# unproducible entities like PHONE_NUMBER), and score_threshold is unset so
222+
# detection is identical.
196223
# Only context-based score boosting (which needs real tokens) is unavailable — an
197224
# accepted trade for skipping NER on the hot block-output path. Read-only, so it
198225
# is safe to share across requests and workers.

0 commit comments

Comments
 (0)