diff --git a/CHANGELOG.md b/CHANGELOG.md index e9ebd1bb44..990d35c9dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ All notable changes to this project will be documented in this file. ### Analyzer #### Added +- Recognizer registry YAML entries now accept `score_thresholds` for recognizer-wide and entity-specific score cutoffs, with the analyzer's `default_score_threshold` as the fallback. - UK Driving Licence Number (`UK_DRIVING_LICENCE`) recognizer with pattern matching and context support (#1857) (Thanks @tee-jagz) - German PII recognizers for `DE_TAX_ID`, `DE_TAX_NUMBER`, `DE_PASSPORT`, `DE_ID_CARD`, `DE_SOCIAL_SECURITY`, `DE_HEALTH_INSURANCE`, `DE_KFZ`, `DE_HANDELSREGISTER`, and `DE_PLZ`; all are disabled by default (#1909) (Thanks @MvdB) - `SE_PERSONNUMMER` recognizer for Swedish personal identity and coordination numbers, plus Swedish Organisationsnummer recognition; both are disabled by default (#1912, #1918) (Thanks @goveebee) diff --git a/docs/analyzer/analyzer_engine_provider.md b/docs/analyzer/analyzer_engine_provider.md index 68dd2b0ae6..f78ef16501 100644 --- a/docs/analyzer/analyzer_engine_provider.md +++ b/docs/analyzer/analyzer_engine_provider.md @@ -63,6 +63,9 @@ recognizer_registry: - en supported_entity: IT_FISCAL_CODE type: predefined + score_thresholds: + default: 0.4 + CREDIT_CARD: 0.7 - name: ItFiscalCodeRecognizer type: predefined @@ -70,10 +73,12 @@ recognizer_registry: The configuration file contains the following parameters: - - `supported_languages`: A list of supported languages that the analyzer will support. - - `default_score_threshold`: A score that determines the minimal threshold for detection. - - `nlp_configuration`: Configuration given to the NLP engine which will detect the PIIs and extract features for the downstream logic. - - `recognizer_registry`: All the recognizers that will be used by the analyzer. +- `supported_languages`: A list of supported languages that the analyzer will support. +- `default_score_threshold`: A score that determines the minimal threshold for detection. +- `nlp_configuration`: Configuration given to the NLP engine which will detect the PIIs and extract features for the downstream logic. +- `recognizer_registry`: All the recognizers that will be used by the analyzer. Each recognizer entry can define `score_thresholds`, using `default` as its fallback and entity names for overrides. + +The threshold precedence is an explicit `analyze(score_threshold=...)` value, an entity-specific recognizer threshold, the recognizer's `default`, then `default_score_threshold`. !!! note "Note" diff --git a/docs/analyzer/recognizer_registry_provider.md b/docs/analyzer/recognizer_registry_provider.md index 4fd39e5c3a..f290fc950f 100644 --- a/docs/analyzer/recognizer_registry_provider.md +++ b/docs/analyzer/recognizer_registry_provider.md @@ -56,6 +56,9 @@ The recognizer list comprises of both the predefined and custom recognizers, for - language: it - language: pl type: predefined + score_thresholds: + default: 0.4 + CREDIT_CARD: 0.7 - name: UsBankRecognizer supported_languages: @@ -107,6 +110,7 @@ The recognizer list comprises of both the predefined and custom recognizers, for - `supported_entity`: the detected entity associated by the recognizer. - `deny_list`: A list of words to detect, in case the recognizer uses a predefined list of words. - `deny_list_score`: confidence score for a term identified using a deny-list. + - `score_thresholds`: optional score cutoffs for this recognizer. Use `default` as the recognizer-wide cutoff and entity names for overrides. An explicit request threshold takes priority, followed by an entity override, this recognizer default, and the analyzer's `default_score_threshold`. !!! tip "Configuration Tip: Agglutinative languages (e.g., Korean)" diff --git a/docs/tutorial/08_no_code.md b/docs/tutorial/08_no_code.md index 1312f6135f..b7c362d5dd 100644 --- a/docs/tutorial/08_no_code.md +++ b/docs/tutorial/08_no_code.md @@ -63,6 +63,9 @@ recognizer_registry: - language: es context: [tarjeta, credito, visa, mastercard, cc, amex, discover, jcb, diners, maestro, instapayment] type: predefined + score_thresholds: + default: 0.4 + CREDIT_CARD: 0.7 - name: DateRecognizer supported_languages: @@ -119,6 +122,8 @@ recognizer_registry: """ ``` +Each recognizer can set a `default` cutoff and entity-specific overrides in `score_thresholds`. An explicit request threshold takes priority, followed by an entity override, the recognizer default, and the analyzer's `default_score_threshold`. + ### NLP Engine parameters ([default file](https://github.com/data-privacy-stack/presidio/blob/main/presidio-analyzer/presidio_analyzer/conf/default.yaml)) diff --git a/presidio-analyzer/presidio_analyzer/analyzer_engine.py b/presidio-analyzer/presidio_analyzer/analyzer_engine.py index b68d24d6ab..de8294c6e3 100644 --- a/presidio-analyzer/presidio_analyzer/analyzer_engine.py +++ b/presidio-analyzer/presidio_analyzer/analyzer_engine.py @@ -25,6 +25,7 @@ REGEX_TIMEOUT_SECONDS = int(os.environ.get("REGEX_TIMEOUT_SECONDS", 60)) + class AnalyzerEngine: """ Entry point for Presidio Analyzer. @@ -254,9 +255,10 @@ def analyze( json.dumps([str(result.to_dict()) for result in results]), ) - # Remove duplicates or low score results + # Filter low-score results before deduplication so recognizer-specific + # thresholds do not get lost when duplicate spans collapse. + results = self.__remove_low_scores(results, score_threshold, recognizers) results = EntityRecognizer.remove_duplicates(results) - results = self.__remove_low_scores(results, score_threshold) if allow_list: results = self._remove_allow_list( @@ -330,21 +332,56 @@ def _enhance_using_context( return results def __remove_low_scores( - self, results: List[RecognizerResult], score_threshold: float = None + self, + results: List[RecognizerResult], + score_threshold: float = None, + recognizers: Optional[List[EntityRecognizer]] = None, ) -> List[RecognizerResult]: """ Remove results for which the confidence is lower than the threshold. :param results: List of RecognizerResult :param score_threshold: float value for minimum possible confidence + :param recognizers: recognizers selected for this request :return: List[RecognizerResult] """ if score_threshold is None: - score_threshold = self.default_score_threshold + recognizers_by_id = { + recognizer.id: recognizer for recognizer in recognizers or [] + } + return [ + result + for result in results + if result.score + >= self.__get_result_score_threshold(result, recognizers_by_id) + ] new_results = [result for result in results if result.score >= score_threshold] return new_results + def __get_result_score_threshold( + self, + result: RecognizerResult, + recognizers_by_id: dict, + ) -> float: + """Resolve the threshold to apply for a single recognizer result.""" + metadata = result.recognition_metadata or {} + recognizer_id = metadata.get(RecognizerResult.RECOGNIZER_IDENTIFIER_KEY) + recognizer = recognizers_by_id.get(recognizer_id) + if recognizer is None: + return self.default_score_threshold + recognizer_thresholds = recognizer.score_thresholds + + entity_threshold = recognizer_thresholds.get(result.entity_type) + if entity_threshold is not None: + return entity_threshold + + recognizer_default_threshold = recognizer_thresholds.get("default") + if recognizer_default_threshold is not None: + return recognizer_default_threshold + + return self.default_score_threshold + @staticmethod def _remove_allow_list( results: List[RecognizerResult], diff --git a/presidio-analyzer/presidio_analyzer/conf/default_analyzer_full.yaml b/presidio-analyzer/presidio_analyzer/conf/default_analyzer_full.yaml index 15c89d7b92..a3788b4015 100644 --- a/presidio-analyzer/presidio_analyzer/conf/default_analyzer_full.yaml +++ b/presidio-analyzer/presidio_analyzer/conf/default_analyzer_full.yaml @@ -57,6 +57,9 @@ recognizer_registry: - language: en context: [credit, card, visa, mastercard, cc, amex, discover, jcb, diners, maestro, instapayment] type: predefined + # score_thresholds: + # default: 0.4 + # CREDIT_CARD: 0.7 - name: UsBankRecognizer type: predefined diff --git a/presidio-analyzer/presidio_analyzer/conf/default_recognizers.yaml b/presidio-analyzer/presidio_analyzer/conf/default_recognizers.yaml index 0ad50b1603..d0ddf815bf 100644 --- a/presidio-analyzer/presidio_analyzer/conf/default_recognizers.yaml +++ b/presidio-analyzer/presidio_analyzer/conf/default_recognizers.yaml @@ -36,6 +36,9 @@ recognizers: - language: it - language: pl type: predefined + # score_thresholds: + # default: 0.4 + # CREDIT_CARD: 0.7 - name: UsBankRecognizer supported_languages: diff --git a/presidio-analyzer/presidio_analyzer/entity_recognizer.py b/presidio-analyzer/presidio_analyzer/entity_recognizer.py index 18a6fc3d65..178d94e63c 100644 --- a/presidio-analyzer/presidio_analyzer/entity_recognizer.py +++ b/presidio-analyzer/presidio_analyzer/entity_recognizer.py @@ -1,8 +1,9 @@ import logging from abc import abstractmethod -from typing import TYPE_CHECKING, ClassVar, Dict, List, Optional, Tuple +from typing import TYPE_CHECKING, Any, ClassVar, Dict, List, Optional, Tuple from presidio_analyzer import RecognizerResult +from presidio_analyzer.score_thresholds import normalize_score_thresholds if TYPE_CHECKING: from presidio_analyzer.nlp_engine import NlpArtifacts @@ -33,6 +34,7 @@ class EntityRecognizer: recognizers may set it per instance; predefined recognizers should prefer the class-level :attr:`COUNTRY_CODE`. Values are stripped, lower-cased, and must match :attr:`COUNTRY_CODE` when both are set. + :param score_thresholds: Optional default and entity-specific score thresholds. """ MIN_SCORE = 0 @@ -53,6 +55,7 @@ def __init__( version: str = "0.0.1", context: Optional[List[str]] = None, country_code: Optional[str] = None, + score_thresholds: Any = None, ): self.supported_entities = supported_entities @@ -67,6 +70,7 @@ def __init__( self.version = version self.is_loaded = False self.context = context if context else [] + self.score_thresholds = score_thresholds self._country_code = self._resolve_country_code(country_code) @@ -74,6 +78,16 @@ def __init__( logger.info("Loaded recognizer: %s", self.name) self.is_loaded = True + @property + def score_thresholds(self) -> Dict[str, float]: + """Return a defensive copy of this recognizer's score thresholds.""" + return self._score_thresholds.copy() + + @score_thresholds.setter + def score_thresholds(self, value: Any) -> None: + """Validate and store this recognizer's score thresholds.""" + self._score_thresholds = normalize_score_thresholds(value) + @classmethod def _resolve_country_code(cls, passed: Optional[str]) -> Optional[str]: """Reconcile a constructor-passed country code with the class attribute. diff --git a/presidio-analyzer/presidio_analyzer/input_validation/schemas.py b/presidio-analyzer/presidio_analyzer/input_validation/schemas.py index d5c1050d49..cf9887a8ac 100644 --- a/presidio-analyzer/presidio_analyzer/input_validation/schemas.py +++ b/presidio-analyzer/presidio_analyzer/input_validation/schemas.py @@ -3,6 +3,11 @@ from pydantic import ValidationError +from presidio_analyzer.score_thresholds import ( + normalize_score_thresholds, + validate_score_threshold, +) + from . import validate_language_codes from .yaml_recognizer_models import RecognizerRegistryConfig @@ -38,11 +43,7 @@ def validate_score_threshold(threshold: float) -> float: :param threshold: score threshold to validate. """ - if not 0.0 <= threshold <= 1.0: - raise ValueError( - f"Score threshold must be between 0.0 and 1.0, got: {threshold}" - ) - return threshold + return validate_score_threshold(threshold) @staticmethod def validate_nlp_configuration(config: Dict[str, Any]) -> Dict[str, Any]: @@ -80,9 +81,15 @@ def validate_recognizer_registry_configuration( try: # Use Pydantic model for validation validated_config = RecognizerRegistryConfig(**config) - return ConfigurationValidator._dump_recognizer_registry_configuration( - validated_config + dumped_config = ( + ConfigurationValidator._dump_recognizer_registry_configuration( + validated_config + ) ) + for recognizer in dumped_config["recognizers"]: + if isinstance(recognizer, dict): + normalize_score_thresholds(recognizer.get("score_thresholds")) + return dumped_config except ValidationError as e: raise ValueError("Invalid recognizer registry configuration") from e diff --git a/presidio-analyzer/presidio_analyzer/input_validation/yaml_recognizer_models.py b/presidio-analyzer/presidio_analyzer/input_validation/yaml_recognizer_models.py index 3b58101d92..bf950e7432 100644 --- a/presidio-analyzer/presidio_analyzer/input_validation/yaml_recognizer_models.py +++ b/presidio-analyzer/presidio_analyzer/input_validation/yaml_recognizer_models.py @@ -81,6 +81,10 @@ class BaseRecognizerConfig(BaseModel): supported_entities: Optional[List[str]] = Field( default=None, description="List of supported entities for this recognizer" ) + score_thresholds: Optional[Any] = Field( + default=None, + description="Default and entity-specific score thresholds", + ) @field_validator("supported_language") @classmethod @@ -435,6 +439,7 @@ def parse_recognizers( continue if isinstance(recognizer, dict): + recognizer = recognizer.copy() recognizer_type = recognizer.get("type") # Validate conflicting custom-only fields if explicitly predefined diff --git a/presidio-analyzer/presidio_analyzer/recognizer_registry/recognizer_registry.py b/presidio-analyzer/presidio_analyzer/recognizer_registry/recognizer_registry.py index 059f194f8a..a47e3f7dbb 100644 --- a/presidio-analyzer/presidio_analyzer/recognizer_registry/recognizer_registry.py +++ b/presidio-analyzer/presidio_analyzer/recognizer_registry/recognizer_registry.py @@ -22,6 +22,7 @@ RecognizerConfigurationLoader, RecognizerListLoader, ) +from presidio_analyzer.score_thresholds import normalize_score_thresholds logger = logging.getLogger("presidio-analyzer") @@ -314,7 +315,12 @@ def add_pattern_recognizer_from_dict(self, recognizer_dict: Dict) -> None: >>> registry.add_pattern_recognizer_from_dict(recognizer) """ # noqa: E501 - recognizer = PatternRecognizer.from_dict(recognizer_dict) + recognizer_config = recognizer_dict.copy() + score_thresholds = normalize_score_thresholds( + recognizer_config.pop("score_thresholds", None) + ) + recognizer = PatternRecognizer.from_dict(recognizer_config) + recognizer.score_thresholds = score_thresholds self.add_recognizer(recognizer) def add_recognizers_from_yaml(self, yml_path: Union[str, Path]) -> None: diff --git a/presidio-analyzer/presidio_analyzer/recognizer_registry/recognizers_loader_utils.py b/presidio-analyzer/presidio_analyzer/recognizer_registry/recognizers_loader_utils.py index d41d6398c7..c433e8bf1f 100644 --- a/presidio-analyzer/presidio_analyzer/recognizer_registry/recognizers_loader_utils.py +++ b/presidio-analyzer/presidio_analyzer/recognizer_registry/recognizers_loader_utils.py @@ -20,6 +20,7 @@ import yaml from presidio_analyzer import EntityRecognizer, PatternRecognizer +from presidio_analyzer.score_thresholds import normalize_score_thresholds logger = logging.getLogger("presidio-analyzer") @@ -393,9 +394,13 @@ def get( "supported_languages", "class_name", "country_code", + "score_thresholds", } - custom_to_exclude = {"enabled", "type", "class_name"} + custom_to_exclude = {"enabled", "type", "class_name", "score_thresholds"} for recognizer_conf in predefined: + score_thresholds = normalize_score_thresholds( + recognizer_conf.get("score_thresholds") + ) for language_conf in RecognizerListLoader._get_recognizer_languages( recognizer_conf=recognizer_conf, supported_languages=supported_languages ): @@ -421,19 +426,25 @@ def get( new_conf, language_conf, recognizer_cls ) - recognizer_instances.append(recognizer_cls(**kwargs)) + recognizer = recognizer_cls(**kwargs) + recognizer.score_thresholds = score_thresholds + recognizer_instances.append(recognizer) for recognizer_conf in custom: if RecognizerListLoader.is_recognizer_enabled(recognizer_conf): + score_thresholds = normalize_score_thresholds( + recognizer_conf.get("score_thresholds") + ) new_conf = RecognizerListLoader._filter_recognizer_fields( recognizer_conf, to_exclude=custom_to_exclude ) - recognizer_instances.extend( - RecognizerListLoader._create_custom_recognizers( - recognizer_conf=new_conf, - supported_languages=supported_languages, - ) + custom_recognizers = RecognizerListLoader._create_custom_recognizers( + recognizer_conf=new_conf, + supported_languages=supported_languages, ) + for recognizer in custom_recognizers: + recognizer.score_thresholds = score_thresholds + recognizer_instances.extend(custom_recognizers) for recognizer_conf in recognizer_instances: if isinstance(recognizer_conf, PatternRecognizer): diff --git a/presidio-analyzer/presidio_analyzer/score_thresholds.py b/presidio-analyzer/presidio_analyzer/score_thresholds.py new file mode 100644 index 0000000000..b809b8ccef --- /dev/null +++ b/presidio-analyzer/presidio_analyzer/score_thresholds.py @@ -0,0 +1,30 @@ +"""Validation helpers for recognizer score thresholds.""" + +from collections.abc import Mapping +from typing import Any, Dict + + +def validate_score_threshold(threshold: Any) -> float: + """Validate a score threshold without coercing its input type.""" + if not isinstance(threshold, (int, float)) or isinstance(threshold, bool): + raise ValueError(f"Score threshold must be numeric, got: {threshold}") + if not 0.0 <= threshold <= 1.0: + raise ValueError( + f"Score threshold must be between 0.0 and 1.0, got: {threshold}" + ) + return threshold + + +def normalize_score_thresholds(score_thresholds: Any) -> Dict[str, float]: + """Validate and defensively copy one recognizer's score thresholds.""" + if score_thresholds is None: + return {} + if not isinstance(score_thresholds, Mapping): + raise ValueError("score_thresholds must be a mapping") + + normalized = {} + for entity, threshold in score_thresholds.items(): + if not isinstance(entity, str) or not entity or entity.strip() != entity: + raise ValueError("score_thresholds keys must be non-empty strings") + normalized[entity] = validate_score_threshold(threshold) + return normalized diff --git a/presidio-analyzer/tests/conf/test_analyzer_engine.yaml b/presidio-analyzer/tests/conf/test_analyzer_engine.yaml index 9d68dea705..e13b08113f 100644 --- a/presidio-analyzer/tests/conf/test_analyzer_engine.yaml +++ b/presidio-analyzer/tests/conf/test_analyzer_engine.yaml @@ -6,6 +6,9 @@ recognizer_registry: - en supported_entity: CREDIT_CARD type: predefined + score_thresholds: + default: 0.4 + CREDIT_CARD: 0.7 - name: ItFiscalCodeRecognizer type: predefined @@ -90,4 +93,4 @@ nlp_configuration: low_confidence_score_multiplier: 0.4 low_score_entity_names: - - ID \ No newline at end of file + - ID diff --git a/presidio-analyzer/tests/conf/test_recognizer_registry.yaml b/presidio-analyzer/tests/conf/test_recognizer_registry.yaml index c81c197150..2ca7f895e9 100644 --- a/presidio-analyzer/tests/conf/test_recognizer_registry.yaml +++ b/presidio-analyzer/tests/conf/test_recognizer_registry.yaml @@ -5,6 +5,9 @@ recognizers: - en supported_entity: IT_FISCAL_CODE type: predefined + score_thresholds: + default: 0.4 + CREDIT_CARD: 0.8 - name: ItFiscalCodeRecognizer type: predefined @@ -27,6 +30,8 @@ recognizers: deny_list_score: 1 type: custom enabled: true + score_thresholds: + ZIP: 0.6 - name: "ZipCodeRecognizer" supported_language: "de" @@ -54,4 +59,4 @@ recognizers: supported_languages: - en - - es \ No newline at end of file + - es diff --git a/presidio-analyzer/tests/test_analyzer_engine.py b/presidio-analyzer/tests/test_analyzer_engine.py index 9e21e93c43..6aed1e468d 100644 --- a/presidio-analyzer/tests/test_analyzer_engine.py +++ b/presidio-analyzer/tests/test_analyzer_engine.py @@ -1,3 +1,5 @@ +# ruff: noqa: D103,E501,W291 + import copy import re from abc import ABC @@ -57,6 +59,123 @@ def unit_test_guid(): return "00000000-0000-0000-0000-000000000000" +class ThresholdRecognizer(EntityRecognizer, ABC): + """Static recognizer used to exercise score threshold precedence.""" + + def __init__(self, score_thresholds=None, name="ThresholdRecognizer"): + """Seed fixed results for threshold precedence assertions.""" + self._results = [ + RecognizerResult("PERSON", 0, 4, 0.55), + RecognizerResult("CREDIT_CARD", 5, 9, 0.65), + RecognizerResult("URL", 10, 13, 0.75), + RecognizerResult("DATE_TIME", 14, 18, 0.9), + ] + super().__init__( + supported_entities=["PERSON", "CREDIT_CARD", "URL", "DATE_TIME"], + name=name, + score_thresholds=score_thresholds, + ) + + def load(self): + """Keep the test recognizer lightweight.""" + return None + + def analyze(self, text: str, entities: List[str], nlp_artifacts: NlpArtifacts): + """Return deterministic results for threshold filtering tests.""" + return [ + RecognizerResult( + result.entity_type, result.start, result.end, result.score + ) + for result in self._results + ] + + +class FallbackRecognizer(EntityRecognizer, ABC): + """Recognizer that exercises the default fallback threshold path.""" + + def __init__(self): + """Seed a result that only the global default should filter.""" + self._results = [RecognizerResult("PHONE_NUMBER", 20, 26, 0.75)] + super().__init__( + supported_entities=["PHONE_NUMBER"], + name="FallbackRecognizer", + ) + + def load(self): + """Keep the test recognizer lightweight.""" + return None + + def analyze(self, text: str, entities: List[str], nlp_artifacts: NlpArtifacts): + """Return deterministic results for threshold filtering tests.""" + return [ + RecognizerResult( + result.entity_type, result.start, result.end, result.score + ) + for result in self._results + ] + + +class DuplicateThresholdRecognizer(EntityRecognizer, ABC): + """Recognizer that emits a duplicate span for threshold ordering tests.""" + + def __init__(self, name, score_thresholds): + """Use the recognizer name as the threshold lookup key.""" + super().__init__( + supported_entities=["PERSON"], + name=name, + score_thresholds=score_thresholds, + ) + + def load(self): + """Keep the test recognizer lightweight.""" + return None + + def analyze(self, text: str, entities: List[str], nlp_artifacts: NlpArtifacts): + """Return a duplicate result with a configurable recognizer name.""" + return [RecognizerResult("PERSON", 0, 4, 0.5)] + + +class ContextThresholdRecognizer(DuplicateThresholdRecognizer): + """Recognizer which raises its result score during context enhancement.""" + + def enhance_using_context(self, *args, **kwargs): + """Raise the score above the configured cutoff.""" + results = kwargs["raw_recognizer_results"] + results[0].score = 0.8 + return results + + +class MissingIdentifierRecognizer(DuplicateThresholdRecognizer): + """Recognizer which replaces producer metadata during context enhancement.""" + + def __init__(self, identifier): + """Choose whether the final producer identifier is missing or unmatched.""" + self.identifier = identifier + super().__init__("MissingIdentifierRecognizer", {"PERSON": 0.1}) + + def enhance_using_context(self, *args, **kwargs): + """Replace the identifier after recognizer execution.""" + results = kwargs["raw_recognizer_results"] + results[0].recognition_metadata = ( + {} + if self.identifier is None + else {RecognizerResult.RECOGNIZER_IDENTIFIER_KEY: self.identifier} + ) + return results + + +def _build_threshold_analyzer(score_thresholds=None, default_score_threshold=0.8): + """Create an analyzer with deterministic recognizers for threshold tests.""" + registry = RecognizerRegistry() + registry.add_recognizer(ThresholdRecognizer(score_thresholds)) + registry.add_recognizer(FallbackRecognizer()) + return AnalyzerEngine( + registry=registry, + nlp_engine=NlpEngineMock(), + default_score_threshold=default_score_threshold, + ) + + def test_simple(): dic = { "text": "John Smith drivers license is AC432223", @@ -558,6 +677,139 @@ def test_when_default_threshold_is_zero_then_all_results_pass( assert len(results) == 2 +def test_recognizer_threshold_precedence(): + """Recognizer-specific and entity-specific thresholds should win.""" + analyzer_engine = _build_threshold_analyzer( + {"default": 0.4, "PERSON": 0.6, "CREDIT_CARD": 0.7} + ) + + results = analyzer_engine.analyze( + text="Threshold config", + language="en", + entities=["PERSON", "CREDIT_CARD", "URL", "DATE_TIME", "PHONE_NUMBER"], + ) + + scores = {result.entity_type: result.score for result in results} + + assert scores == {"URL": 0.75, "DATE_TIME": 0.9} + + +def test_explicit_score_threshold_overrides_config(): + """A request-level score threshold should override config thresholds.""" + analyzer_engine = _build_threshold_analyzer( + {"default": 0.4, "PERSON": 0.6, "CREDIT_CARD": 0.7} + ) + + results = analyzer_engine.analyze( + text="Threshold config", + language="en", + entities=["PERSON", "CREDIT_CARD", "URL", "DATE_TIME", "PHONE_NUMBER"], + score_threshold=0.8, + ) + + scores = {result.entity_type: result.score for result in results} + + assert scores == {"DATE_TIME": 0.9} + + +def test_duplicate_results_keep_recognizers_that_meet_their_thresholds(): + """Threshold filtering should run before duplicate collapse.""" + registry = RecognizerRegistry() + strict = DuplicateThresholdRecognizer("SameRecognizer", {"PERSON": 0.9}) + lenient = DuplicateThresholdRecognizer("SameRecognizer", {"PERSON": 0.4}) + registry.add_recognizer(strict) + registry.add_recognizer(lenient) + + analyzer_engine = AnalyzerEngine( + registry=registry, + nlp_engine=NlpEngineMock(), + default_score_threshold=0.0, + ) + + results = analyzer_engine.analyze( + text="John", + language="en", + entities=["PERSON"], + ) + + assert len(results) == 1 + assert strict.id != lenient.id + assert results[0].recognition_metadata[ + RecognizerResult.RECOGNIZER_IDENTIFIER_KEY + ] == lenient.id + + +def test_empty_recognizer_thresholds_use_default(): + """An empty threshold map should keep the global default behavior.""" + analyzer_engine = _build_threshold_analyzer(score_thresholds={}) + + results = analyzer_engine.analyze( + text="Threshold config", + language="en", + entities=["PERSON", "CREDIT_CARD", "URL", "DATE_TIME", "PHONE_NUMBER"], + ) + + scores = {result.entity_type: result.score for result in results} + + assert scores == {"DATE_TIME": 0.9} + + +def test_context_enhancement_runs_before_recognizer_threshold_filtering(): + """A context-enhanced score should be compared with the final score.""" + registry = RecognizerRegistry() + registry.add_recognizer( + ContextThresholdRecognizer("ContextThresholdRecognizer", {"PERSON": 0.7}) + ) + analyzer = AnalyzerEngine( + registry=registry, + nlp_engine=NlpEngineMock(), + default_score_threshold=0.9, + ) + + results = analyzer.analyze("John", "en", entities=["PERSON"]) + + assert [result.score for result in results] == [0.8] + + +@pytest.mark.parametrize("identifier", [None, "not-a-selected-recognizer"]) +def test_missing_or_unmatched_recognizer_identifier_uses_engine_default(identifier): + """Unknown producer provenance should use the engine fallback.""" + registry = RecognizerRegistry() + registry.add_recognizer(MissingIdentifierRecognizer(identifier)) + analyzer = AnalyzerEngine( + registry=registry, + nlp_engine=NlpEngineMock(), + default_score_threshold=0.8, + ) + + assert analyzer.analyze("John", "en", entities=["PERSON"]) == [] + + +def test_ad_hoc_recognizer_uses_its_score_thresholds(): + """Request-local recognizers should participate in identifier lookup.""" + ad_hoc = PatternRecognizer( + supported_entity="ROCKET", + patterns=[Pattern("rocket", "rocket", 0.5)], + ) + ad_hoc.score_thresholds = {"default": 0.4} + registry = RecognizerRegistry() + registry.add_recognizer(FallbackRecognizer()) + analyzer = AnalyzerEngine( + registry=registry, + nlp_engine=NlpEngineMock(), + default_score_threshold=0.8, + ) + + results = analyzer.analyze( + "rocket", + "en", + entities=["ROCKET"], + ad_hoc_recognizers=[ad_hoc], + ) + + assert [result.entity_type for result in results] == ["ROCKET"] + + def test_when_get_supported_fields_then_return_all_languages( analyzer_engine_simple, unit_test_guid ): diff --git a/presidio-analyzer/tests/test_analyzer_engine_provider.py b/presidio-analyzer/tests/test_analyzer_engine_provider.py index ed58e4cfaf..59df104e11 100644 --- a/presidio-analyzer/tests/test_analyzer_engine_provider.py +++ b/presidio-analyzer/tests/test_analyzer_engine_provider.py @@ -1,12 +1,15 @@ +# ruff: noqa: D103,D205,E501,F541,F841,W293 + import re from pathlib import Path from typing import List from unittest.mock import patch -from presidio_analyzer import AnalyzerEngineProvider, RecognizerResult, PatternRecognizer -from presidio_analyzer.nlp_engine import SpacyNlpEngine, NlpArtifacts - - +import pytest +import yaml +from install_nlp_models import _install_models_from_nlp_config, install_models +from presidio_analyzer import AnalyzerEngineProvider, RecognizerResult +from presidio_analyzer.nlp_engine import NlpArtifacts, SpacyNlpEngine from presidio_analyzer.predefined_recognizers import ( AzureAILanguageRecognizer, CreditCardRecognizer, @@ -14,10 +17,6 @@ StanzaRecognizer, ) -import pytest - -from install_nlp_models import install_models, _install_models_from_nlp_config - def get_full_paths(analyzer_yaml, nlp_engine_yaml=None, recognizer_registry_yaml=None): this_path = Path(__file__).parent.absolute() @@ -38,6 +37,10 @@ def test_analyzer_engine_provider_default_configuration(mandatory_recognizers): engine.registry.global_regex_flags == re.DOTALL | re.MULTILINE | re.IGNORECASE ) assert engine.default_score_threshold == 0 + assert all( + recognizer.score_thresholds == {} + for recognizer in engine.registry.recognizers + ) names = [recognizer.name for recognizer in engine.registry.recognizers] for predefined_recognizer in mandatory_recognizers: assert predefined_recognizer in names @@ -92,15 +95,107 @@ def test_analyzer_engine_provider_configuration_file(): and recognizer.supported_language == "es" ][0] assert spanish_recognizer.context == ["tarjeta", "credito"] + credit_card = next( + recognizer + for recognizer in recognizer_registry.recognizers + if recognizer.name == "CreditCardRecognizer" + ) + assert credit_card.score_thresholds == { + "default": 0.4, + "CREDIT_CARD": 0.7, + } assert isinstance(engine.nlp_engine, SpacyNlpEngine) assert engine.nlp_engine.engine_name == "spacy" +def test_analyzer_engine_provider_inline_recognizer_thresholds_affect_output(tmp_path): + analyzer_yaml, _, _ = get_full_paths("conf/test_analyzer_engine.yaml") + + with open(analyzer_yaml) as file: + configuration = yaml.safe_load(file) + + configuration["default_score_threshold"] = 0.9 + credit_card = configuration["recognizer_registry"]["recognizers"][0] + credit_card["score_thresholds"] = {"default": 0.4} + + threshold_yaml = tmp_path / "analyzer_with_thresholds.yaml" + threshold_yaml.write_text(yaml.safe_dump(configuration, sort_keys=False)) + + provider = AnalyzerEngineProvider(threshold_yaml) + engine = provider.create_engine() + + loaded_credit_card = next( + recognizer + for recognizer in engine.registry.recognizers + if recognizer.name == "CreditCardRecognizer" + ) + assert loaded_credit_card.score_thresholds == {"default": 0.4} + + results = engine.analyze( + text=" Credit card: 4095-2609-9393-4932", + language="en", + entities=["CREDIT_CARD"], + ) + + assert len(results) == 1 + + +def test_analyzer_engine_provider_external_registry_thresholds_affect_output(tmp_path): + analyzer_yaml = tmp_path / "analyzer.yaml" + analyzer_yaml.write_text( + yaml.safe_dump( + { + "supported_languages": ["en"], + "default_score_threshold": 0.9, + "nlp_configuration": { + "nlp_engine_name": "spacy", + "models": [ + {"lang_code": "en", "model_name": "en_core_web_lg"} + ], + }, + } + ) + ) + registry_yaml = tmp_path / "registry.yaml" + registry_yaml.write_text( + yaml.safe_dump( + { + "supported_languages": ["en"], + "recognizers": [ + { + "name": "RocketRecognizer", + "type": "custom", + "supported_entity": "ROCKET", + "supported_language": "en", + "patterns": [ + {"name": "rocket", "regex": "rocket", "score": 0.5} + ], + "score_thresholds": {"default": 0.4}, + } + ], + } + ) + ) + provider = AnalyzerEngineProvider( + analyzer_engine_conf_file=analyzer_yaml, + recognizer_registry_conf_file=registry_yaml, + ) + + engine = provider.create_engine() + results = engine.analyze("rocket", "en", entities=["ROCKET"]) + + assert [result.entity_type for result in results] == ["ROCKET"] + + def test_analyzer_engine_provider_defaults(mandatory_recognizers): provider = AnalyzerEngineProvider() engine = provider.create_engine() assert engine.supported_languages == ["en"] assert engine.default_score_threshold == 0 + assert all( + recognizer.score_thresholds == {} + for recognizer in engine.registry.recognizers + ) recognizer_registry = engine.registry assert ( recognizer_registry.global_regex_flags @@ -366,8 +461,8 @@ def test_analyzer_engine_provider_get_configuration_with_nonexistent_file(): def test_analyzer_engine_provider_get_configuration_with_invalid_yaml(): """Test get_configuration handles invalid YAML gracefully.""" - import tempfile import os + import tempfile # Create a temporary file with invalid YAML with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f: @@ -550,6 +645,15 @@ def test_analyzer_engine_provider_inline_sections_take_priority_over_per_section # The registry from the inline section has more than the 6 recognizers # in test_recognizer_registry.yaml, confirming the inline section won. assert len(engine.registry.recognizers) > 6 + credit_card = next( + recognizer + for recognizer in engine.registry.recognizers + if recognizer.name == "CreditCardRecognizer" + ) + assert credit_card.score_thresholds == { + "default": 0.4, + "CREDIT_CARD": 0.7, + } def test_analyzer_engine_provider_multiple_languages_support(): diff --git a/presidio-analyzer/tests/test_configuration_validator.py b/presidio-analyzer/tests/test_configuration_validator.py index ad5ed5687a..73d5172b6f 100644 --- a/presidio-analyzer/tests/test_configuration_validator.py +++ b/presidio-analyzer/tests/test_configuration_validator.py @@ -1,9 +1,9 @@ """Tests for the Pydantic-based validation system using existing adapted classes.""" -import pytest +# ruff: noqa: D103,E501 +import pytest from presidio_analyzer.input_validation import ConfigurationValidator - # ========== Language Code Validation Tests ========== def test_validate_language_codes_valid(): @@ -106,6 +106,14 @@ def test_validate_score_threshold_way_above(): assert "must be between 0.0 and 1.0" in str(exc_info.value) +@pytest.mark.parametrize("threshold", [True, False, "0.5", None, [], {}]) +def test_validate_score_threshold_non_numeric(threshold): + """Test score threshold rejects booleans and non-numeric values.""" + with pytest.raises(ValueError) as exc_info: + ConfigurationValidator.validate_score_threshold(threshold) + assert "must be numeric" in str(exc_info.value) + + # ========== NLP Configuration Validation Tests ========== def test_configuration_validator_nlp_config_valid(): @@ -318,6 +326,60 @@ def test_configuration_validator_analyzer_config_valid(): assert validated == valid_config +def test_analyzer_config_rejects_removed_recognizer_score_thresholds_key(): + with pytest.raises(ValueError, match="Unknown configuration key"): + ConfigurationValidator.validate_analyzer_configuration( + { + "supported_languages": ["en"], + "recognizer_score_thresholds": {"CreditCardRecognizer": 0.4}, + } + ) + + +def test_registry_config_preserves_valid_score_thresholds(): + valid_config = { + "supported_languages": ["en"], + "recognizers": [ + { + "name": "CreditCardRecognizer", + "type": "predefined", + "score_thresholds": {"default": 0.4, "CREDIT_CARD": 0.7}, + } + ], + } + + validated = ConfigurationValidator.validate_recognizer_registry_configuration( + valid_config + ) + + assert validated["recognizers"][0]["score_thresholds"] == { + "default": 0.4, + "CREDIT_CARD": 0.7, + } + + +@pytest.mark.parametrize( + "score_thresholds", + [False, 0, "", [], {"default": True}, {"default": "0.4"}], +) +def test_registry_config_rejects_uncoerced_invalid_score_thresholds( + score_thresholds, +): + config = { + "supported_languages": ["en"], + "recognizers": [ + { + "name": "CreditCardRecognizer", + "type": "predefined", + "score_thresholds": score_thresholds, + } + ], + } + + with pytest.raises(ValueError): + ConfigurationValidator.validate_recognizer_registry_configuration(config) + + def test_analyzer_config_minimal(): """Test minimal valid analyzer configuration.""" valid_config = { @@ -360,6 +422,20 @@ def test_configuration_validator_analyzer_config_invalid_threshold(): assert "must be between 0.0 and 1.0" in str(exc_info.value) +@pytest.mark.parametrize("threshold", [True, "0.5"]) +def test_configuration_validator_analyzer_config_non_numeric_threshold(threshold): + """Test ConfigurationValidator rejects non-numeric score thresholds.""" + invalid_config = { + "supported_languages": ["en"], + "default_score_threshold": threshold, + } + + with pytest.raises(ValueError) as exc_info: + ConfigurationValidator.validate_analyzer_configuration(invalid_config) + + assert "must be numeric" in str(exc_info.value) + + def test_analyzer_config_not_dict(): """Test analyzer configuration that is not a dictionary.""" with pytest.raises(ValueError) as exc_info: diff --git a/presidio-analyzer/tests/test_entity_recognizer.py b/presidio-analyzer/tests/test_entity_recognizer.py index 3bb695eb5d..6035ea1652 100644 --- a/presidio-analyzer/tests/test_entity_recognizer.py +++ b/presidio-analyzer/tests/test_entity_recognizer.py @@ -1,4 +1,8 @@ -from presidio_analyzer import EntityRecognizer, RecognizerResult, AnalysisExplanation +# ruff: noqa: D103,E501,I001 + +import pytest + +from presidio_analyzer import AnalysisExplanation, EntityRecognizer, RecognizerResult def test_when_to_dict_then_return_correct_dictionary(): @@ -120,8 +124,6 @@ def test_when_remove_duplicates_contained_shorter_length_results_removed(): results = EntityRecognizer.remove_duplicates(arr) assert len(results) == 1 -import pytest - sanitizer_test_set = [ [" a|b:c ::-", [("-", ""), (" ", ""), (":", ""), ("|", "")], "abc"], ["def", "", "def"], @@ -138,3 +140,44 @@ def test_sanitize_value(input_text, params, expected_output): :return: True/False """ assert EntityRecognizer.sanitize_value(input_text, params) == expected_output + + +def test_score_thresholds_default_to_empty_mapping(): + recognizer = EntityRecognizer(["ENTITY"]) + + assert recognizer.score_thresholds == {} + + +def test_score_thresholds_constructor_and_setter_defensively_copy(): + thresholds = {"default": 0.4, "ENTITY": 0.7} + recognizer = EntityRecognizer(["ENTITY"], score_thresholds=thresholds) + thresholds["ENTITY"] = 0.1 + returned = recognizer.score_thresholds + returned["ENTITY"] = 0.2 + + assert recognizer.score_thresholds == {"default": 0.4, "ENTITY": 0.7} + + recognizer.score_thresholds = {"ENTITY": 0.5} + assert recognizer.score_thresholds == {"ENTITY": 0.5} + + +@pytest.mark.parametrize("thresholds", [False, True, 0, "", "0.4", []]) +def test_score_thresholds_reject_non_mapping_values(thresholds): + with pytest.raises(ValueError, match="must be a mapping"): + EntityRecognizer(["ENTITY"], score_thresholds=thresholds) + + +@pytest.mark.parametrize( + "thresholds", + [ + {"ENTITY": False}, + {"ENTITY": "0.4"}, + {"ENTITY": -0.1}, + {"ENTITY": 1.1}, + {"": 0.4}, + {" ENTITY": 0.4}, + ], +) +def test_score_thresholds_reject_invalid_entries(thresholds): + with pytest.raises(ValueError): + EntityRecognizer(["ENTITY"], score_thresholds=thresholds) diff --git a/presidio-analyzer/tests/test_recognizer_registry.py b/presidio-analyzer/tests/test_recognizer_registry.py index e0c6b8db53..a7b2d596b3 100644 --- a/presidio-analyzer/tests/test_recognizer_registry.py +++ b/presidio-analyzer/tests/test_recognizer_registry.py @@ -1,3 +1,5 @@ +# ruff: noqa: D103,D205,E501,F841,I001 + from pathlib import Path import pytest @@ -162,6 +164,79 @@ def test_add_recognizer_from_dict(): assert registry.recognizers[0].name == "Zip code Recognizer" +def test_add_recognizer_from_dict_attaches_thresholds_without_mutating_input( + monkeypatch, +): + registry = RecognizerRegistry() + recognizer = { + "name": "Zip code Recognizer", + "supported_language": "de", + "patterns": [{"name": "zip", "regex": r"\d{5}", "score": 0.5}], + "supported_entity": "ZIP", + "score_thresholds": {"default": 0.4, "ZIP": 0.7}, + } + original = recognizer.copy() + received = {} + from_dict = PatternRecognizer.from_dict + + def capture_from_dict(config): + received.update(config) + return from_dict(config) + + monkeypatch.setattr(PatternRecognizer, "from_dict", capture_from_dict) + + registry.add_pattern_recognizer_from_dict(recognizer) + + assert "score_thresholds" not in received + assert recognizer == original + assert registry.recognizers[0].score_thresholds == { + "default": 0.4, + "ZIP": 0.7, + } + + +def test_add_recognizers_from_yaml_attaches_thresholds(tmp_path): + yaml_path = tmp_path / "recognizers.yaml" + yaml_path.write_text( + """recognizers: +- name: Zip code Recognizer + supported_language: de + supported_entity: ZIP + patterns: + - name: zip + regex: '\\d{5}' + score: 0.5 + score_thresholds: + default: 0.4 + ZIP: 0.7 +""" + ) + registry = RecognizerRegistry() + + registry.add_recognizers_from_yaml(yaml_path) + + assert registry.recognizers[0].score_thresholds == { + "default": 0.4, + "ZIP": 0.7, + } + + +@pytest.mark.parametrize("score_thresholds", [False, 0, "", []]) +def test_add_recognizer_from_dict_rejects_falsey_non_mapping_thresholds( + score_thresholds, +): + recognizer = { + "name": "Zip code Recognizer", + "supported_language": "de", + "patterns": [{"name": "zip", "regex": r"\d{5}", "score": 0.5}], + "supported_entity": "ZIP", + "score_thresholds": score_thresholds, + } + + with pytest.raises(ValueError, match="must be a mapping"): + RecognizerRegistry().add_pattern_recognizer_from_dict(recognizer) + + def test_recognizer_registry_add_from_yaml_file(): this_path = Path(__file__).parent.absolute() test_yaml = Path(this_path, "conf/recognizers.yaml") diff --git a/presidio-analyzer/tests/test_recognizer_registry_provider.py b/presidio-analyzer/tests/test_recognizer_registry_provider.py index 17771a1d3e..bc294c7f43 100644 --- a/presidio-analyzer/tests/test_recognizer_registry_provider.py +++ b/presidio-analyzer/tests/test_recognizer_registry_provider.py @@ -1,3 +1,5 @@ +# ruff: noqa: D103,D200,D205,E501,F841,I001 + import pytest import re from pathlib import Path @@ -38,6 +40,81 @@ def test_recognizer_registry_provider_configuration_file(): assert [recognizer.supported_language for recognizer in recognizer_registry.recognizers if recognizer.name == "ExampleCustomRecognizer"] == ["en", "es"] spanish_recognizer = [recognizer for recognizer in recognizer_registry.recognizers if recognizer.name == "ExampleCustomRecognizer" and recognizer.supported_language == "es"][0] assert spanish_recognizer.context == ["tarjeta", "credito"] + credit_card = next( + recognizer + for recognizer in recognizer_registry.recognizers + if recognizer.name == "CreditCardRecognizer" + ) + assert credit_card.score_thresholds == { + "default": 0.4, + "CREDIT_CARD": 0.8, + } + assert all( + recognizer.score_thresholds == {"ZIP": 0.6} + for recognizer in recognizer_registry.recognizers + if recognizer.name == "ExampleCustomRecognizer" + ) + + +def test_recognizer_registry_provider_inline_thresholds_attach_to_instance(): + provider = RecognizerRegistryProvider( + registry_configuration={ + "supported_languages": ["en"], + "recognizers": [ + { + "name": "CreditCardRecognizer", + "type": "predefined", + "supported_language": "en", + "score_thresholds": {"default": 0.4}, + } + ], + } + ) + + recognizer = provider.create_recognizer_registry().recognizers[0] + + assert recognizer.score_thresholds == {"default": 0.4} + + +def test_recognizer_registry_provider_omitted_thresholds_default_to_empty(): + provider = RecognizerRegistryProvider( + registry_configuration={ + "supported_languages": ["en"], + "recognizers": [ + { + "name": "CreditCardRecognizer", + "type": "predefined", + "supported_language": "en", + } + ], + } + ) + + recognizer = provider.create_recognizer_registry().recognizers[0] + + assert recognizer.score_thresholds == {} + + +@pytest.mark.parametrize( + "score_thresholds", + [True, False, 0, "", "0.4", [], {"": 0.4}, {"default": True}, {"default": 1.1}], +) +def test_recognizer_registry_provider_rejects_invalid_score_thresholds( + score_thresholds, +): + with pytest.raises(ValueError): + RecognizerRegistryProvider( + registry_configuration={ + "supported_languages": ["en"], + "recognizers": [ + { + "name": "CreditCardRecognizer", + "type": "predefined", + "score_thresholds": score_thresholds, + } + ], + } + ) def test_recognizer_registry_provider_configuration_file_load_predefined(mandatory_recognizers): diff --git a/presidio-analyzer/tests/test_recognizers_loader_utils.py b/presidio-analyzer/tests/test_recognizers_loader_utils.py index 89a178b182..7a5bf2b7cd 100644 --- a/presidio-analyzer/tests/test_recognizers_loader_utils.py +++ b/presidio-analyzer/tests/test_recognizers_loader_utils.py @@ -1,3 +1,6 @@ +# ruff: noqa: D103,D200,D205,E501,F841,I001 + +import copy import functools import re from pathlib import Path @@ -78,6 +81,120 @@ def __init__(self, **kwargs): ) +def load_recognizers(recognizers, languages=("en",)): + return list(RecognizerListLoader.get(recognizers, languages, 26)) + + +def test_predefined_score_thresholds_attach_without_mutating_config(): + config = [ + { + "name": "CreditCardRecognizer", + "type": "predefined", + "supported_language": "en", + "score_thresholds": {"default": 0.4, "CREDIT_CARD": 0.7}, + } + ] + original = copy.deepcopy(config) + + recognizers = load_recognizers(config) + + assert recognizers[0].score_thresholds == { + "default": 0.4, + "CREDIT_CARD": 0.7, + } + assert config == original + + +def test_custom_multilanguage_score_thresholds_attach_to_each_instance(): + config = [ + { + "name": "custom_thresholds", + "type": "custom", + "supported_entity": "CUSTOM", + "supported_languages": [{"language": "en"}, {"language": "es"}], + "patterns": [{"name": "custom", "regex": "x", "score": 0.5}], + "score_thresholds": {"default": 0.4, "CUSTOM": 0.6}, + } + ] + original = copy.deepcopy(config) + + recognizers = load_recognizers(config, ("en", "es")) + + assert {recognizer.supported_language for recognizer in recognizers} == {"en", "es"} + assert all( + recognizer.score_thresholds == {"default": 0.4, "CUSTOM": 0.6} + for recognizer in recognizers + ) + assert config == original + + +@pytest.mark.parametrize("score_thresholds", [None, {}]) +def test_loader_missing_or_empty_score_thresholds_default_to_empty(score_thresholds): + config = { + "name": "CreditCardRecognizer", + "type": "predefined", + "supported_language": "en", + } + if score_thresholds is not None: + config["score_thresholds"] = score_thresholds + + recognizer = load_recognizers([config])[0] + + assert recognizer.score_thresholds == {} + + +def test_loader_explicit_none_score_thresholds_defaults_to_empty(): + recognizer = load_recognizers( + [ + { + "name": "CreditCardRecognizer", + "type": "predefined", + "supported_language": "en", + "score_thresholds": None, + } + ] + )[0] + + assert recognizer.score_thresholds == {} + + +@pytest.mark.parametrize("score_thresholds", [False, 0, "", []]) +def test_loader_rejects_falsey_non_mapping_score_thresholds(score_thresholds): + config = [ + { + "name": "CreditCardRecognizer", + "type": "predefined", + "supported_language": "en", + "score_thresholds": score_thresholds, + } + ] + + with pytest.raises(ValueError, match="must be a mapping"): + load_recognizers(config) + + +def test_same_name_and_language_entries_keep_distinct_thresholds_and_ids(): + config = [ + { + "name": "CreditCardRecognizer", + "type": "predefined", + "supported_language": "en", + "score_thresholds": {"default": threshold}, + } + for threshold in (0.4, 0.8) + ] + + recognizers = load_recognizers(config) + + assert [recognizer.score_thresholds for recognizer in recognizers] == [ + {"default": 0.4}, + {"default": 0.8}, + ] + assert recognizers[0].name == recognizers[1].name + assert recognizers[0].supported_language == recognizers[1].supported_language + assert recognizers[0].id != recognizers[1].id + + def test_cleanup_none_removes_entity_keys(): """Test that explicit None values for entity keys are removed.""" kwargs = prepare( diff --git a/presidio-analyzer/tests/test_yaml_recognizer_models.py b/presidio-analyzer/tests/test_yaml_recognizer_models.py index 72415031d8..e6b6eb7bab 100644 --- a/presidio-analyzer/tests/test_yaml_recognizer_models.py +++ b/presidio-analyzer/tests/test_yaml_recognizer_models.py @@ -1,4 +1,5 @@ """Tests for YAML recognizer configuration models.""" +# ruff: noqa: D103,E501,F841,I001 import pytest from presidio_analyzer.input_validation.yaml_recognizer_models import ( @@ -853,3 +854,71 @@ def test_config_model_map_fallback_to_predefined(): assert isinstance(recognizer, PredefinedRecognizerConfig) assert recognizer.name == "MySpacy" assert recognizer.class_name == "SpacyRecognizer" + + +@pytest.mark.parametrize( + "raw_thresholds", + [True, "0.4", ["default", 0.4], {"default": "0.4"}, {"default": 0.4}], +) +def test_base_recognizer_score_thresholds_preserve_raw_values(raw_thresholds): + config = BaseRecognizerConfig( + name="CreditCardRecognizer", score_thresholds=raw_thresholds + ) + + assert config.model_dump()["score_thresholds"] == raw_thresholds + assert type(config.model_dump()["score_thresholds"]) is type(raw_thresholds) + + +@pytest.mark.parametrize( + "recognizer", + [ + { + "name": "CreditCardRecognizer", + "type": "predefined", + "score_thresholds": {"default": 0.4}, + }, + { + "name": "custom_thresholds", + "type": "custom", + "supported_entity": "CUSTOM", + "supported_language": "en", + "patterns": [{"name": "custom", "regex": "x", "score": 0.5}], + "score_thresholds": {"CUSTOM": 0.6}, + }, + { + "name": "HuggingFaceNerRecognizer", + "type": "predefined", + "supported_language": "en", + "score_thresholds": {"PERSON": 0.7}, + }, + { + "name": "GLiNERRecognizer", + "type": "predefined", + "supported_language": "en", + "score_thresholds": {"PERSON": 0.8}, + }, + ], +) +def test_registry_model_dump_preserves_score_thresholds_for_every_entry_type( + recognizer, +): + original = recognizer["score_thresholds"].copy() + + dumped = RecognizerRegistryConfig(recognizers=[recognizer]).model_dump() + + assert dumped["recognizers"][0]["score_thresholds"] == original + + +def test_registry_model_does_not_mutate_recognizer_input_when_inferring_type(): + recognizer = { + "name": "custom_thresholds", + "supported_entity": "CUSTOM", + "supported_language": "en", + "patterns": [{"name": "custom", "regex": "x", "score": 0.5}], + "score_thresholds": {"default": 0.4}, + } + original = recognizer.copy() + + RecognizerRegistryConfig(recognizers=[recognizer]) + + assert recognizer == original