Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions presidio-analyzer/install_nlp_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,8 @@ def _download_model(engine_name: str, model_name: Union[str, Dict[str, str]]) ->
_install_transformers_spacy_models(model_name)
else:
raise ImportError("transformers is not installed")
elif engine_name == "no_op":
logger.info("No NLP model installation required for no_op engine")
else:
raise ValueError(f"Unsupported nlp engine: {engine_name}")

Expand Down
20 changes: 19 additions & 1 deletion presidio-analyzer/presidio_analyzer/analyzer_engine.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import json
import logging
import os
import warnings
from collections import Counter
from typing import List, Optional

Expand All @@ -15,7 +16,12 @@
ContextAwareEnhancer,
LemmaContextAwareEnhancer,
)
from presidio_analyzer.nlp_engine import NlpArtifacts, NlpEngine, NlpEngineProvider
from presidio_analyzer.nlp_engine import (
NlpArtifacts,
NlpEngine,
NlpEngineProvider,
NoOpNlpEngine,
)
from presidio_analyzer.recognizer_registry import (
RecognizerRegistry,
RecognizerRegistryProvider,
Expand Down Expand Up @@ -97,12 +103,24 @@ def __init__(
registry.load_predefined_recognizers(
nlp_engine=self.nlp_engine, languages=self.supported_languages
)
registry.validate_nlp_engine_compatibility(self.nlp_engine)

self.registry = registry

self.log_decision_process = log_decision_process
self.default_score_threshold = default_score_threshold

if isinstance(self.nlp_engine, NoOpNlpEngine) and isinstance(
context_aware_enhancer, LemmaContextAwareEnhancer
):
warnings.warn(
"LemmaContextAwareEnhancer cannot use context words from the analyzed "
"text with NoOpNlpEngine because no tokens or lemmas are produced. "
"Only context passed explicitly to analyze() can be used.",
UserWarning,
stacklevel=2,
)

if not context_aware_enhancer:
logger.debug(
"context aware enhancer not provided, creating default"
Expand Down
4 changes: 4 additions & 0 deletions presidio-analyzer/presidio_analyzer/conf/no_op.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
nlp_engine_name: no_op
models:
- lang_code: en
model_name: no_op
Comment thread
ultramancode marked this conversation as resolved.
2 changes: 2 additions & 0 deletions presidio-analyzer/presidio_analyzer/nlp_engine/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from .ner_model_configuration import NerModelConfiguration
from .nlp_artifacts import NlpArtifacts
from .nlp_engine import NlpEngine
from .no_op_nlp_engine import NoOpNlpEngine
from .slim_spacy_nlp_engine import SlimSpacyNlpEngine
from .spacy_nlp_engine import SpacyNlpEngine
from .stanza_nlp_engine import StanzaNlpEngine
Expand All @@ -16,6 +17,7 @@
"NerModelConfiguration",
"NlpArtifacts",
"NlpEngine",
"NoOpNlpEngine",
"SlimSpacyNlpEngine",
"SpacyNlpEngine",
"StanzaNlpEngine",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from presidio_analyzer.nlp_engine import (
NerModelConfiguration,
NlpEngine,
NoOpNlpEngine,
SlimSpacyNlpEngine,
SpacyNlpEngine,
StanzaNlpEngine,
Expand All @@ -21,7 +22,8 @@ class NlpEngineProvider:
"""Create different NLP engines from configuration.

:param nlp_engines: List of available NLP engines
Default: (SpacyNlpEngine, StanzaNlpEngine)
Default: (SpacyNlpEngine, StanzaNlpEngine, TransformersNlpEngine,
SlimSpacyNlpEngine, NoOpNlpEngine)
:param nlp_configuration: Dict containing nlp configuration
:example: configuration:
{
Expand All @@ -30,7 +32,7 @@ class NlpEngineProvider:
"model_name": "en_core_web_lg"
}]
}
Nlp engine names available by default: spacy, stanza.
Nlp engine names available by default: spacy, stanza, transformers, slim, no_op.
:param conf_file: Path to yaml file containing nlp engine configuration.
"""

Expand All @@ -46,6 +48,7 @@ def __init__(
StanzaNlpEngine,
TransformersNlpEngine,
SlimSpacyNlpEngine,
NoOpNlpEngine,
)

self.nlp_engines = {
Expand Down Expand Up @@ -102,7 +105,9 @@ def create_engine(self) -> NlpEngine:
nlp_engine_class = self.nlp_engines[nlp_engine_name]
nlp_models = self.nlp_configuration["models"]

if nlp_engine_name == SlimSpacyNlpEngine.engine_name:
if nlp_engine_name == NoOpNlpEngine.engine_name:
engine = nlp_engine_class(models=nlp_models)
elif nlp_engine_name == SlimSpacyNlpEngine.engine_name:
generic_tokenizer = self.nlp_configuration.get("generic_tokenizer")
engine = nlp_engine_class(
models=nlp_models, generic_tokenizer=generic_tokenizer
Expand Down
168 changes: 168 additions & 0 deletions presidio-analyzer/presidio_analyzer/nlp_engine/no_op_nlp_engine.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import logging
from typing import Any, Dict, Iterable, Iterator, List, Tuple, Union

from spacy.tokens import Doc
from spacy.vocab import Vocab

from presidio_analyzer.nlp_engine import NlpArtifacts, NlpEngine

logger = logging.getLogger("presidio-analyzer")


class NoOpNlpEngine(NlpEngine):
"""An NLP engine that performs no linguistic processing.

This engine is intended for AnalyzerEngine configurations where all active
recognizers are self-contained and do not require NLP artifacts. It loads no
external model and returns empty artifacts for each analyzed text.
The default LemmaContextAwareEnhancer can use context passed explicitly to
``AnalyzerEngine.analyze()``, but cannot use context words from the analyzed
text because this engine produces no tokens or lemmas.
"""

engine_name = "no_op"
is_available = True

def __init__(
self,
models: List[Dict[str, str]],
):
self.models = self._validate_models(models)
self._vocab = Vocab()
self.nlp = None

def load(self) -> None:
"""Mark configured languages as loaded without loading external models."""
nlp = {}
for model in self.models:
nlp[model["lang_code"]] = None

self.nlp = nlp
logger.info("Loaded no-op NLP engine with languages: %s", list(self.nlp.keys()))

@classmethod
def _validate_models(cls, models: List[Dict[str, str]]) -> List[Dict[str, str]]:
if not isinstance(models, list) or not models:
raise ValueError("models must be a non-empty list")

seen_languages = set()
validated_models = []
for model in models:
validated_model = cls._validate_model_params(model)
language = validated_model["lang_code"]
if language in seen_languages:
raise ValueError(
f"Duplicate language '{language}' in no-op NLP engine models"
)
seen_languages.add(language)
validated_models.append(validated_model)

return validated_models

@classmethod
def _validate_model_params(cls, model: Dict) -> Dict[str, str]:
"""Validate that required model parameters are present."""
if not isinstance(model, dict):
raise ValueError("Each model must be a dictionary")
if "lang_code" not in model:
raise ValueError("lang_code is missing from model configuration")
if "model_name" not in model:
raise ValueError("model_name is missing from model configuration")
if not isinstance(model["lang_code"], str) or not model["lang_code"]:
raise ValueError("lang_code must be a non-empty string")
if model["lang_code"] != model["lang_code"].strip():
raise ValueError("lang_code must not contain leading or trailing spaces")
if not isinstance(model["model_name"], str):
raise ValueError("model_name must be a string")
return {"lang_code": model["lang_code"], "model_name": model["model_name"]}

def is_loaded(self) -> bool:
"""Return True if the no-op engine has been initialized."""
return self.nlp is not None

def process_text(self, text: str, language: str) -> NlpArtifacts:
"""Return empty NLP artifacts for the given text and language."""
self._validate_string(text, "text")
self._validate_loaded_language(language)
return self._create_empty_nlp_artifacts(language)

def process_batch(
self,
texts: Iterable[Union[str, Tuple[str, Any]]],
language: str,
batch_size: int = 1,
n_process: int = 1,
as_tuples: bool = False,
**kwargs,
) -> Iterator[Union[Tuple[str, NlpArtifacts], Tuple[str, NlpArtifacts, Any]]]:
"""Return empty NLP artifacts for each text in a batch."""
if kwargs:
logger.warning(
"Ignoring unsupported kwargs in NoOpNlpEngine.process_batch: %s",
sorted(kwargs.keys()),
)
self._validate_loaded_language(language)

for item in texts:
if as_tuples:
if not isinstance(item, tuple) or len(item) != 2:
raise ValueError(
"When 'as_tuples' is True, "
"'texts' must be an iterable of tuples (text, context)."
)
Comment thread
ultramancode marked this conversation as resolved.
text, context = item
text = str(text)
yield text, self._create_empty_nlp_artifacts(language), context
else:
text = str(item)
yield text, self._create_empty_nlp_artifacts(language)

def is_stopword(self, word: str, language: str) -> bool:
"""Return False because no stop-word vocabulary is available."""
self._validate_string(word, "word")
self._validate_loaded_language(language)
return False

def is_punct(self, word: str, language: str) -> bool:
"""Return False because no punctuation vocabulary is available."""
self._validate_string(word, "word")
self._validate_loaded_language(language)
return False

def get_supported_entities(self) -> List[str]:
"""Return no entities because this engine does not run NER."""
return []

def get_supported_languages(self) -> List[str]:
"""Return the languages configured for this no-op engine."""
if not self.nlp:
raise ValueError("NLP engine is not loaded. Consider calling .load()")
return list(self.nlp.keys())

def _validate_loaded_language(self, language: str) -> None:
self._validate_string(language, "language")
if not self.nlp:
raise ValueError("NLP engine is not loaded. Consider calling .load()")

if language not in self.nlp:
raise ValueError(
f"Language '{language}' is not supported by this NLP engine. "
f"Supported languages: {list(self.nlp.keys())}"
)

@staticmethod
def _validate_string(value: Any, parameter_name: str) -> str:
if not isinstance(value, str):
raise TypeError(f"{parameter_name} must be a string")
return value

def _create_empty_nlp_artifacts(self, language: str) -> NlpArtifacts:
return NlpArtifacts(
entities=[],
tokens=Doc(self._vocab, words=[]),
tokens_indices=[],
lemmas=[],
nlp_engine=self,
language=language,
scores=[],
)
Comment on lines +159 to +168

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NlpArtifacts does not accept a keywords constructor argument. Keywords are derived internally from lemmas in NlpArtifacts.__init__, so passing lemmas=[] keeps artifacts.keywords as an empty list.

Original file line number Diff line number Diff line change
Expand Up @@ -55,23 +55,24 @@ class HuggingFaceNerRecognizer(LocalRecognizer):

Example:
>>> from presidio_analyzer import AnalyzerEngine
>>> from presidio_analyzer.nlp_engine import NlpEngineProvider
>>> from presidio_analyzer.predefined_recognizers.ner import (
... HuggingFaceNerRecognizer
... )
>>>
>>> # For Korean
>>> recognizer = HuggingFaceNerRecognizer(
... model_name="test-owner/test-model",
... supported_language="ko"
... )
>>>
>>> # For English
>>> recognizer = HuggingFaceNerRecognizer(
... model_name="dslim/bert-base-NER",
... supported_language="en"
... )
>>>
>>> analyzer = AnalyzerEngine()
>>> nlp_engine = NlpEngineProvider(
... nlp_configuration={
... "nlp_engine_name": "no_op",
... "models": [{"lang_code": "en", "model_name": "no_op"}],
... }
... ).create_engine()
>>> analyzer = AnalyzerEngine(
... nlp_engine=nlp_engine, supported_languages=["en"]
... )
>>> analyzer.registry.add_recognizer(recognizer)
"""

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from presidio_analyzer import EntityRecognizer, PatternRecognizer
from presidio_analyzer.nlp_engine import (
NlpEngine,
NoOpNlpEngine,
SpacyNlpEngine,
StanzaNlpEngine,
TransformersNlpEngine,
Expand Down Expand Up @@ -53,6 +54,23 @@ def __init__(
supported_languages if supported_languages else ["en"]
)

def validate_nlp_engine_compatibility(
self, nlp_engine: Optional[NlpEngine]
) -> None:
"""Validate that registered recognizers can use the selected NLP engine."""
if not isinstance(nlp_engine, NoOpNlpEngine):
return

nlp_recognizers = [
rec for rec in self.recognizers if isinstance(rec, SpacyRecognizer)
]
if nlp_recognizers:
names = sorted({rec.__class__.__name__ for rec in nlp_recognizers})
raise ValueError(
"NoOpNlpEngine cannot be used with NLP engine recognizers. "
f"Remove or disable these recognizers: {names}."
)

def _create_nlp_recognizer(
self,
nlp_engine: Optional[NlpEngine] = None,
Expand All @@ -76,6 +94,11 @@ def add_nlp_recognizer(self, nlp_engine: NlpEngine) -> None:
:return: None
"""

if isinstance(nlp_engine, NoOpNlpEngine):
Comment thread
omri374 marked this conversation as resolved.
self.validate_nlp_engine_compatibility(nlp_engine)
logger.info("Skipping NLP recognizer registration for no-op NLP engine.")
return

if not nlp_engine:
supported_languages = self.supported_languages
else:
Expand Down Expand Up @@ -148,6 +171,8 @@ def get_nlp_recognizer(
return StanzaRecognizer
if isinstance(nlp_engine, TransformersNlpEngine):
return TransformersRecognizer
if isinstance(nlp_engine, NoOpNlpEngine):
raise ValueError("NoOpNlpEngine does not have an NLP recognizer")
if not nlp_engine or isinstance(nlp_engine, SpacyNlpEngine):
return SpacyRecognizer
else:
Expand Down
Loading
Loading