chore: import upstream snapshot with attribution
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
+267
View File
@@ -0,0 +1,267 @@
"""Analyzer engine builders for the PII service.
Two NER engines share one recognizer surface:
- spacy (default): the 5 large spaCy models do NER (PERSON/LOCATION/NRP/
DATE_TIME) and tokenization.
- gliner (opt-in): one multilingual GLiNER model does NER on CPU or GPU;
small spaCy models remain only for tokenization + lemmas.
Both engines register the identical regex/checksum recognizer set (Presidio
defaults, EXTRA_RECOGNIZERS, VIN) — only the source of the 4 NER entity types
differs. Side-effect free: importing this module loads no models.
"""
import importlib.util
import spacy.util
from presidio_analyzer import AnalyzerEngine, Pattern, PatternRecognizer
from presidio_analyzer.nlp_engine import NlpEngineProvider
from presidio_analyzer.predefined_recognizers import (
AuAbnRecognizer,
AuAcnRecognizer,
AuMedicareRecognizer,
AuTfnRecognizer,
EsNieRecognizer,
EsNifRecognizer,
FiPersonalIdentityCodeRecognizer,
GLiNERRecognizer,
InAadhaarRecognizer,
InPanRecognizer,
InPassportRecognizer,
InVehicleRegistrationRecognizer,
InVoterRecognizer,
ItDriverLicenseRecognizer,
ItFiscalCodeRecognizer,
ItIdentityCardRecognizer,
ItPassportRecognizer,
ItVatCodeRecognizer,
PlPeselRecognizer,
SgFinRecognizer,
SgUenRecognizer,
UkNinoRecognizer,
)
# Languages served. Each needs its spaCy model installed in the image; the
# es/it/pl/fi predefined recognizers (ES_NIF, IT_FISCAL_CODE, PL_PESEL, ...)
# auto-load once their NLP engine is present.
NLP_CONFIGURATION = {
"nlp_engine_name": "spacy",
"models": [
{"lang_code": "en", "model_name": "en_core_web_lg"},
{"lang_code": "es", "model_name": "es_core_news_lg"},
{"lang_code": "it", "model_name": "it_core_news_lg"},
{"lang_code": "pl", "model_name": "pl_core_news_lg"},
{"lang_code": "fi", "model_name": "fi_core_news_lg"},
],
}
SUPPORTED_LANGUAGES = [m["lang_code"] for m in NLP_CONFIGURATION["models"]]
# The gliner engine still needs a spaCy pipeline per language: the regex
# recognizers consume NlpArtifacts and the LemmaContextAwareEnhancer boosts
# scores from surrounding lemmas. The small models (~12-40MB each vs ~400MB
# large) keep tokenization + lemmas intact while GLiNER owns NER. Blank
# pipelines ("blank:xx") are not an option: Presidio's SpacyNlpEngine treats
# unknown model names as pip packages and tries to download them.
# labels_to_ignore strips the small models' NER output from NlpArtifacts —
# correctness comes from removing SpacyRecognizer in build_gliner_analyzer;
# this only silences unmapped-label noise.
GLINER_NLP_CONFIGURATION = {
"nlp_engine_name": "spacy",
"models": [
{"lang_code": "en", "model_name": "en_core_web_sm"},
{"lang_code": "es", "model_name": "es_core_news_sm"},
{"lang_code": "it", "model_name": "it_core_news_sm"},
{"lang_code": "pl", "model_name": "pl_core_news_sm"},
{"lang_code": "fi", "model_name": "fi_core_news_sm"},
],
"ner_model_configuration": {
"labels_to_ignore": [
"CARDINAL", "DATE", "EVENT", "FAC", "GPE", "LANGUAGE", "LAW",
"LOC", "MISC", "MONEY", "NORP", "ORDINAL", "ORG", "PER",
"PERCENT", "PERSON", "PRODUCT", "QUANTITY", "TIME", "WORK_OF_ART",
],
},
}
# Zero-shot label prompts -> the 4 Presidio NER entities GLiNER owns. Multiple
# prompts per entity trade a little inference cost for recall; tune against
# scripts/bench_engines.py output.
GLINER_ENTITY_MAPPING = {
"person": "PERSON",
"name": "PERSON",
"location": "LOCATION",
"address": "LOCATION",
"date": "DATE_TIME",
"time": "DATE_TIME",
"nationality": "NRP",
"religious group": "NRP",
"political group": "NRP",
"ethnic group": "NRP",
}
# Predefined recognizers Presidio ships but does NOT load into the default
# registry — they must be added explicitly. Each carries its own
# supported_language, so it fires under that language once its NLP model is
# loaded. en: UK/AU/IN/SG locale ids; es/it/pl/fi: national ids.
EXTRA_RECOGNIZERS = [
UkNinoRecognizer,
AuAbnRecognizer,
AuAcnRecognizer,
AuTfnRecognizer,
AuMedicareRecognizer,
InPanRecognizer,
InAadhaarRecognizer,
InVehicleRegistrationRecognizer,
InVoterRecognizer,
InPassportRecognizer,
SgFinRecognizer,
SgUenRecognizer,
EsNifRecognizer,
EsNieRecognizer,
ItFiscalCodeRecognizer,
ItDriverLicenseRecognizer,
ItVatCodeRecognizer,
ItPassportRecognizer,
ItIdentityCardRecognizer,
PlPeselRecognizer,
FiPersonalIdentityCodeRecognizer,
]
class VinRecognizer(PatternRecognizer):
"""VIN (17 chars, A-Z/0-9 excluding I/O/Q) with ISO 3779 check-digit
validation (position 9). Validation makes accidental matches on arbitrary
17-char codes (request ids, SKUs, tokens) extremely unlikely. Some
non-North-American VINs omit the check digit and are skipped — an
intentional bias toward precision.
"""
_TRANSLIT = {
**{str(d): d for d in range(10)},
"A": 1, "B": 2, "C": 3, "D": 4, "E": 5, "F": 6, "G": 7, "H": 8,
"J": 1, "K": 2, "L": 3, "M": 4, "N": 5, "P": 7, "R": 9,
"S": 2, "T": 3, "U": 4, "V": 5, "W": 6, "X": 7, "Y": 8, "Z": 9,
}
_WEIGHTS = [8, 7, 6, 5, 4, 3, 2, 10, 0, 9, 8, 7, 6, 5, 4, 3, 2]
def validate_result(self, pattern_text: str):
vin = pattern_text.upper()
if len(vin) != 17:
return False
try:
total = sum(self._TRANSLIT[c] * w for c, w in zip(vin, self._WEIGHTS))
except KeyError:
return False
check = total % 11
expected = "X" if check == 10 else str(check)
return vin[8] == expected
class SharedModelGLiNERRecognizer(GLiNERRecognizer):
"""Per-language GLiNER recognizer sharing ONE loaded model.
Presidio routes recognizers by supported_language, so the registry holds
one instance per served language — but each instance's load() would pull
its own ~1.2GB model copy. The first instance loads (an ImportError from
a missing gliner package propagates — fail fast in the lean image); the
rest reuse the cached model.
"""
_shared_models: dict = {}
def load(self) -> None:
key = (self.model_name, self.map_location)
cached = self._shared_models.get(key)
if cached is None:
super().load()
self._shared_models[key] = self.gliner
else:
self.gliner = cached
def analyze(self, text, entities, nlp_artifacts=None):
"""GLiNERRecognizer appends any requested entity it doesn't know as an
ad-hoc zero-shot label and returns its hits. The analyzer passes ALL
supported entities (~40) when a request doesn't narrow them, which
would prompt GLiNER for CREDIT_CARD/VIN/ES_NIF/... — wrong scope, and
inference cost scales with label count. Restrict to the NER entities
this recognizer owns."""
requested = [e for e in (entities or self.supported_entities) if e in self.supported_entities]
if not requested:
return []
return super().analyze(text, requested, nlp_artifacts)
def _register_common_recognizers(analyzer: AnalyzerEngine) -> None:
"""Regex/checksum recognizers shared by both engines."""
# VIN is language-agnostic, so register it under every served language —
# a recognizer only fires for the language the caller routes to.
vin_pattern = Pattern(name="vin", regex=r"\b[A-HJ-NPR-Z0-9]{17}\b", score=0.7)
for language in SUPPORTED_LANGUAGES:
analyzer.registry.add_recognizer(
VinRecognizer(
supported_entity="VIN",
patterns=[vin_pattern],
context=["vin", "vehicle", "chassis"],
supported_language=language,
)
)
for recognizer_cls in EXTRA_RECOGNIZERS:
analyzer.registry.add_recognizer(recognizer_cls())
def build_spacy_analyzer() -> AnalyzerEngine:
nlp_engine = NlpEngineProvider(nlp_configuration=NLP_CONFIGURATION).create_engine()
analyzer = AnalyzerEngine(nlp_engine=nlp_engine, supported_languages=SUPPORTED_LANGUAGES)
_register_common_recognizers(analyzer)
return analyzer
def build_gliner_analyzer(model_name: str, device: str | None) -> AnalyzerEngine:
"""GLiNER engine: one multilingual zero-shot model replaces spaCy NER for
PERSON/LOCATION/NRP/DATE_TIME; everything else is unchanged.
:param model_name: HuggingFace id of the GLiNER model.
:param device: torch device ("cpu", "cuda", "cuda:0"); None auto-detects
via Presidio's device_detector (cuda when available, else cpu).
"""
# Fail fast with an actionable message when gliner deps are missing (e.g.
# a custom-built image without them). Without these checks Presidio would
# try to pip-download the missing spaCy models at startup (a silent
# network fallback that dies with an unrelated pip permission error), and
# the gliner ImportError would surface only later.
if importlib.util.find_spec("gliner") is None:
raise RuntimeError(
"PII_ENGINE=gliner but the gliner package is not installed; "
"use the stock pii image (docker/pii.Dockerfile ships torch + gliner)"
)
missing = [
m["model_name"]
for m in GLINER_NLP_CONFIGURATION["models"]
if not spacy.util.is_package(m["model_name"])
]
if missing:
raise RuntimeError(
f"PII_ENGINE=gliner needs spaCy models {missing}; "
"use the stock pii image (docker/pii.Dockerfile ships them)"
)
nlp_engine = NlpEngineProvider(nlp_configuration=GLINER_NLP_CONFIGURATION).create_engine()
analyzer = AnalyzerEngine(nlp_engine=nlp_engine, supported_languages=SUPPORTED_LANGUAGES)
# The default registry wires SpacyRecognizer per language; with GLiNER
# owning the NER entities it would emit duplicate/competing spans from the
# small models' ner pipe. remove_recognizer only logs when nothing matched,
# so assert the removal actually happened.
analyzer.registry.remove_recognizer("SpacyRecognizer")
if any(r.name == "SpacyRecognizer" for r in analyzer.registry.recognizers):
raise RuntimeError("SpacyRecognizer removal failed; Presidio registry layout changed")
for language in SUPPORTED_LANGUAGES:
analyzer.registry.add_recognizer(
SharedModelGLiNERRecognizer(
entity_mapping=GLINER_ENTITY_MAPPING,
model_name=model_name,
map_location=device,
supported_language=language,
)
)
_register_common_recognizers(analyzer)
return analyzer