diff --git a/CHANGELOG.md b/CHANGELOG.md index 53557a3fb4..6eed9c8508 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ All notable changes to this project will be documented in this file. - Added Python 3.14 package support for `presidio-analyzer` by allowing Python `<3.15` and avoiding `spacy==3.8.14`, which does not provide compatible Python 3.14 wheels. #### Added +- Analyzer YAML now accepts `recognizer_score_thresholds` for per-recognizer and per-entity score cutoffs, keyed by `RecognizerResult.RECOGNIZER_NAME_KEY`, while preserving the global `default_score_threshold` fallback when no override matches. - Optional `countries` filter on `RecognizerRegistry.load_predefined_recognizers()` to scope predefined country-specific recognizers to a subset of locales (e.g. `countries=["us", "uk"]`). The same filter is also exposed as a top-level `supported_countries` field in the recognizer-registry YAML, mirroring `supported_languages`, and as an advisory per-recognizer `country_code:` field on every predefined country-specific entry in `default_recognizers.yaml` (cross-checked against the class attribute at load time). Country tagging works via two reconciled paths: the class-level `EntityRecognizer.COUNTRY_CODE` ClassVar (canonical for predefined recognizers) and the new `country_code` constructor kwarg on `EntityRecognizer` / `PatternRecognizer` (the path for custom recognizers without a subclass — flows through `PatternRecognizer.from_dict` so YAML `type: custom` entries can declare `country_code:` directly). Conflicting values raise `ValueError` at construction time so a predefined country recognizer can never be silently re-tagged. The resolved tag is read via the `country_code()` and `is_country_specific()` instance methods, and serialized through `to_dict()` / `from_dict()` for round-tripping. Inputs to the `countries` filter are validated up front (rejects bare strings, non-iterables, non-string elements, and blank codes). Locale-agnostic recognizers and untagged custom recognizers are always loaded regardless of the filter, preserving backwards compatibility. Adds `RecognizerRegistry.get_country_codes()` for introspection and a `WARNING` log when a requested country has no matching recognizer. See `docs/analyzer/filtering_by_country.md`. Fixes #1328. - Canadian SIN (`CA_SIN`) recognizer for the Canadian Social Insurance Number, using regex pattern matching, context words (English and French), and Luhn checksum validation. Disabled by default. - South African ID number (`ZA_ID_NUMBER`) recognizer for the 13-digit national identity number, using pattern matching, context words, birth-date validation, and Luhn checksum validation. Disabled by default. diff --git a/docs/analyzer/analyzer_engine_provider.md b/docs/analyzer/analyzer_engine_provider.md index ee3a0b34cf..b29efaa751 100644 --- a/docs/analyzer/analyzer_engine_provider.md +++ b/docs/analyzer/analyzer_engine_provider.md @@ -70,10 +70,22 @@ 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. +- `recognizer_score_thresholds`: Optional per-recognizer thresholds keyed by the recognizer name from `RecognizerResult.RECOGNIZER_NAME_KEY`. Use `default` for a recognizer-wide fallback, then add entity names for overrides. +- `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. + +Example: + +```yaml +recognizer_score_thresholds: + CreditCardRecognizer: + default: 0.4 + CREDIT_CARD: 0.7 + SpacyRecognizer: + PERSON: 0.6 +``` !!! note "Note" diff --git a/docs/tutorial/08_no_code.md b/docs/tutorial/08_no_code.md index 9ab100f083..60aa5fc423 100644 --- a/docs/tutorial/08_no_code.md +++ b/docs/tutorial/08_no_code.md @@ -39,9 +39,17 @@ supported_languages: - en - es default_score_threshold: 0.4 +recognizer_score_thresholds: + CreditCardRecognizer: + default: 0.4 + CREDIT_CARD: 0.7 + SpacyRecognizer: + PERSON: 0.6 """ ``` +Use this when a recognizer needs a lower or higher cutoff than the global `default_score_threshold`, with entity-specific overrides taking priority over the recognizer default. + ### Recognizer Registry parameters ([default file](https://github.com/microsoft/presidio/blob/main/presidio-analyzer/presidio_analyzer/conf/default_recognizers.yaml)) diff --git a/presidio-analyzer/presidio_analyzer/analyzer_engine.py b/presidio-analyzer/presidio_analyzer/analyzer_engine.py index b68d24d6ab..0b57d027f7 100644 --- a/presidio-analyzer/presidio_analyzer/analyzer_engine.py +++ b/presidio-analyzer/presidio_analyzer/analyzer_engine.py @@ -2,7 +2,7 @@ import logging import os from collections import Counter -from typing import List, Optional +from typing import Dict, List, Optional, Union import regex as re @@ -15,6 +15,7 @@ ContextAwareEnhancer, LemmaContextAwareEnhancer, ) +from presidio_analyzer.input_validation import ConfigurationValidator from presidio_analyzer.nlp_engine import NlpArtifacts, NlpEngine, NlpEngineProvider from presidio_analyzer.recognizer_registry import ( RecognizerRegistry, @@ -24,6 +25,7 @@ logger = logging.getLogger("presidio-analyzer") REGEX_TIMEOUT_SECONDS = int(os.environ.get("REGEX_TIMEOUT_SECONDS", 60)) +RecognizerScoreThresholds = Dict[str, Union[float, Dict[str, float]]] class AnalyzerEngine: """ @@ -40,6 +42,8 @@ class AnalyzerEngine: defines whether the decision process within the analyzer should be logged or not. :param default_score_threshold: Minimum confidence value for detected entities to be returned + :param recognizer_score_thresholds: Optional recognizer and entity-specific + score thresholds applied when no request-level score threshold is provided. :param supported_languages: List of possible languages this engine could be run on. Used for loading the right NLP models and recognizers for these languages. :param context_aware_enhancer: instance of type ContextAwareEnhancer for enhancing @@ -56,6 +60,7 @@ def __init__( default_score_threshold: float = 0, supported_languages: List[str] = None, context_aware_enhancer: Optional[ContextAwareEnhancer] = None, + recognizer_score_thresholds: Optional[RecognizerScoreThresholds] = None, ): if not supported_languages: supported_languages = ["en"] @@ -102,6 +107,9 @@ def __init__( self.log_decision_process = log_decision_process self.default_score_threshold = default_score_threshold + self.recognizer_score_thresholds = ( + self.__normalize_recognizer_score_thresholds(recognizer_score_thresholds) + ) if not context_aware_enhancer: logger.debug( @@ -254,9 +262,10 @@ def analyze( json.dumps([str(result.to_dict()) for result in results]), ) - # Remove duplicates or low score results - results = EntityRecognizer.remove_duplicates(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) + results = EntityRecognizer.remove_duplicates(results) if allow_list: results = self._remove_allow_list( @@ -340,11 +349,50 @@ def __remove_low_scores( :return: List[RecognizerResult] """ if score_threshold is None: - score_threshold = self.default_score_threshold + return [ + result + for result in results + if result.score >= self.__get_result_score_threshold(result) + ] new_results = [result for result in results if result.score >= score_threshold] return new_results + @staticmethod + def __normalize_recognizer_score_thresholds( + recognizer_score_thresholds: Optional[RecognizerScoreThresholds] + ) -> Dict[str, Dict[str, float]]: + """Normalize shorthand threshold values into the nested mapping shape.""" + if recognizer_score_thresholds is None: + return {} + return ConfigurationValidator.validate_recognizer_score_thresholds( + recognizer_score_thresholds + ) + + def __get_result_score_threshold(self, result: RecognizerResult) -> float: + """Resolve the threshold to apply for a single recognizer result.""" + if not self.recognizer_score_thresholds: + return self.default_score_threshold + + metadata = result.recognition_metadata or {} + recognizer_name = metadata.get(RecognizerResult.RECOGNIZER_NAME_KEY) + if not recognizer_name: + return self.default_score_threshold + + recognizer_thresholds = self.recognizer_score_thresholds.get(recognizer_name) + if not recognizer_thresholds: + return self.default_score_threshold + + 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/analyzer_engine_provider.py b/presidio-analyzer/presidio_analyzer/analyzer_engine_provider.py index d03430a1f9..49f0d9ac62 100644 --- a/presidio-analyzer/presidio_analyzer/analyzer_engine_provider.py +++ b/presidio-analyzer/presidio_analyzer/analyzer_engine_provider.py @@ -88,6 +88,9 @@ def create_engine(self) -> AnalyzerEngine: nlp_engine = self._load_nlp_engine() supported_languages = self.configuration.get("supported_languages", ["en"]) default_score_threshold = self.configuration.get("default_score_threshold", 0) + recognizer_score_thresholds = self.configuration.get( + "recognizer_score_thresholds", {} + ) registry = self._load_recognizer_registry( supported_languages=supported_languages, nlp_engine=nlp_engine @@ -98,6 +101,7 @@ def create_engine(self) -> AnalyzerEngine: registry=registry, supported_languages=supported_languages, default_score_threshold=default_score_threshold, + recognizer_score_thresholds=recognizer_score_thresholds, ) return analyzer diff --git a/presidio-analyzer/presidio_analyzer/conf/default_analyzer_full.yaml b/presidio-analyzer/presidio_analyzer/conf/default_analyzer_full.yaml index e7c769b83b..4dfe8db600 100644 --- a/presidio-analyzer/presidio_analyzer/conf/default_analyzer_full.yaml +++ b/presidio-analyzer/presidio_analyzer/conf/default_analyzer_full.yaml @@ -1,6 +1,12 @@ supported_languages: - en default_score_threshold: 0 +# recognizer_score_thresholds: +# CreditCardRecognizer: +# default: 0.4 +# CREDIT_CARD: 0.7 +# SpacyRecognizer: +# PERSON: 0.6 nlp_configuration: nlp_engine_name: spacy models: diff --git a/presidio-analyzer/presidio_analyzer/input_validation/schemas.py b/presidio-analyzer/presidio_analyzer/input_validation/schemas.py index d5c1050d49..c7e10c9a07 100644 --- a/presidio-analyzer/presidio_analyzer/input_validation/schemas.py +++ b/presidio-analyzer/presidio_analyzer/input_validation/schemas.py @@ -44,6 +44,88 @@ def validate_score_threshold(threshold: float) -> float: ) return threshold + @staticmethod + def validate_recognizer_score_thresholds( + thresholds: Dict[str, Any], + ) -> Dict[str, Dict[str, float]]: + """Validate recognizer-specific score thresholds.""" + if not isinstance(thresholds, dict): + raise ValueError("recognizer_score_thresholds must be a dictionary") + + validated_thresholds: Dict[str, Dict[str, float]] = {} + for recognizer_name, recognizer_thresholds in thresholds.items(): + if ( + not isinstance(recognizer_name, str) + or recognizer_name.strip() != recognizer_name + or not recognizer_name + ): + raise ValueError( + "recognizer_score_thresholds keys must be non-empty strings" + ) + + if isinstance(recognizer_thresholds, (int, float)) and not isinstance( + recognizer_thresholds, bool + ): + validated_thresholds[recognizer_name] = { + "default": ConfigurationValidator._validate_named_threshold( + recognizer_name=recognizer_name, + threshold_name="default", + threshold_value=recognizer_thresholds, + ) + } + continue + + if not isinstance(recognizer_thresholds, dict): + raise ValueError( + f"recognizer_score_thresholds for recognizer '{recognizer_name}' " + f"must be a dictionary" + ) + + validated_recognizer_thresholds: Dict[str, float] = {} + for threshold_name, threshold_value in recognizer_thresholds.items(): + if ( + not isinstance(threshold_name, str) + or threshold_name.strip() != threshold_name + or not threshold_name + ): + raise ValueError( + "recognizer_score_thresholds nested keys must be " + "non-empty strings" + ) + if not isinstance(threshold_value, (int, float)) or isinstance( + threshold_value, bool + ): + raise ValueError( + "recognizer_score_thresholds values must be numeric" + ) + + validated_recognizer_thresholds[threshold_name] = ( + ConfigurationValidator._validate_named_threshold( + recognizer_name=recognizer_name, + threshold_name=threshold_name, + threshold_value=threshold_value, + ) + ) + + validated_thresholds[recognizer_name] = validated_recognizer_thresholds + + return validated_thresholds + + @staticmethod + def _validate_named_threshold( + recognizer_name: str, + threshold_name: str, + threshold_value: float, + ) -> float: + """Validate one named recognizer threshold and keep config context.""" + try: + return ConfigurationValidator.validate_score_threshold(threshold_value) + except ValueError as exc: + raise ValueError( + "recognizer_score_thresholds value for " + f"'{recognizer_name}.{threshold_name}' {exc}" + ) from exc + @staticmethod def validate_nlp_configuration(config: Dict[str, Any]) -> Dict[str, Any]: """Validate NLP configuration structure. @@ -112,6 +194,7 @@ def validate_analyzer_configuration(config: Dict[str, Any]) -> Dict[str, Any]: valid_keys = { "supported_languages", "default_score_threshold", + "recognizer_score_thresholds", "nlp_configuration", "recognizer_registry", } @@ -135,6 +218,13 @@ def validate_analyzer_configuration(config: Dict[str, Any]) -> Dict[str, Any]: config["default_score_threshold"] ) + if "recognizer_score_thresholds" in config: + config["recognizer_score_thresholds"] = ( + ConfigurationValidator.validate_recognizer_score_thresholds( + config["recognizer_score_thresholds"] + ) + ) + # Validate nested configurations if "nlp_configuration" in config: ConfigurationValidator.validate_nlp_configuration( diff --git a/presidio-analyzer/tests/test_analyzer_engine.py b/presidio-analyzer/tests/test_analyzer_engine.py index 9e21e93c43..4ab40c61c7 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,92 @@ 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): + """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="ThresholdRecognizer", + ) + + 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): + """Use the recognizer name as the threshold lookup key.""" + super().__init__(supported_entities=["PERSON"], name=name) + + 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)] + + +def _build_threshold_analyzer( + recognizer_score_thresholds=None, default_score_threshold=0.8 +): + """Create an analyzer with deterministic recognizers for threshold tests.""" + registry = RecognizerRegistry() + registry.add_recognizer(ThresholdRecognizer()) + registry.add_recognizer(FallbackRecognizer()) + return AnalyzerEngine( + registry=registry, + nlp_engine=NlpEngineMock(), + default_score_threshold=default_score_threshold, + recognizer_score_thresholds=recognizer_score_thresholds, + ) + + def test_simple(): dic = { "text": "John Smith drivers license is AC432223", @@ -558,6 +646,161 @@ 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( + recognizer_score_thresholds={ + "ThresholdRecognizer": { + "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( + recognizer_score_thresholds={ + "ThresholdRecognizer": { + "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_numeric_threshold_shorthand_applies(): + """Numeric shorthand should behave like a recognizer default threshold.""" + class NumericThresholdRecognizer(EntityRecognizer, ABC): + """Recognizer with a single score below the global default.""" + + def __init__(self): + """Set up the recognizer metadata for threshold lookup.""" + super().__init__( + supported_entities=["URL"], + name="NumericThresholdRecognizer", + ) + + def load(self): + """Keep the test recognizer lightweight.""" + return None + + def analyze( + self, text: str, entities: List[str], nlp_artifacts: NlpArtifacts + ): + """Return a single deterministic result.""" + return [RecognizerResult("URL", 0, 8, 0.75)] + + registry = RecognizerRegistry() + registry.add_recognizer(NumericThresholdRecognizer()) + + analyzer_engine = AnalyzerEngine( + registry=registry, + nlp_engine=NlpEngineMock(), + default_score_threshold=0.8, + recognizer_score_thresholds={"NumericThresholdRecognizer": 0.4}, + ) + + results = analyzer_engine.analyze( + text="bing.com", + language="en", + entities=["URL"], + ) + + assert len(results) == 1 + assert results[0].entity_type == "URL" + + +def test_direct_threshold_config_rejects_boolean_values(): + """Direct constructor config should reject boolean threshold values.""" + with pytest.raises(ValueError, match="values must be numeric"): + _build_threshold_analyzer( + recognizer_score_thresholds={"ThresholdRecognizer": {"PERSON": True}} + ) + + +def test_direct_threshold_config_rejects_out_of_range_values(): + """Direct constructor config should reject out-of-range threshold values.""" + with pytest.raises(ValueError, match="between 0.0 and 1.0"): + _build_threshold_analyzer( + recognizer_score_thresholds={"ThresholdRecognizer": {"PERSON": 1.5}} + ) + + +def test_direct_threshold_config_rejects_non_dict_top_level_values(): + """Direct constructor config should reject non-dictionary threshold payloads.""" + with pytest.raises(ValueError, match="must be a dictionary"): + _build_threshold_analyzer(recognizer_score_thresholds=0.4) + + +def test_duplicate_results_keep_recognizers_that_meet_their_thresholds(): + """Threshold filtering should run before duplicate collapse.""" + registry = RecognizerRegistry() + registry.add_recognizer(DuplicateThresholdRecognizer("StrictRecognizer")) + registry.add_recognizer(DuplicateThresholdRecognizer("LenientRecognizer")) + + analyzer_engine = AnalyzerEngine( + registry=registry, + nlp_engine=NlpEngineMock(), + default_score_threshold=0.0, + recognizer_score_thresholds={ + "StrictRecognizer": {"PERSON": 0.9}, + "LenientRecognizer": {"PERSON": 0.4}, + }, + ) + + results = analyzer_engine.analyze( + text="John", + language="en", + entities=["PERSON"], + ) + + assert len(results) == 1 + assert ( + results[0].recognition_metadata[RecognizerResult.RECOGNIZER_NAME_KEY] + == "LenientRecognizer" + ) + + +def test_empty_recognizer_thresholds_use_default(): + """An empty threshold map should keep the global default behavior.""" + analyzer_engine = _build_threshold_analyzer(recognizer_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_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..2547e1935b 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,7 @@ 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 engine.recognizer_score_thresholds == {} names = [recognizer.name for recognizer in engine.registry.recognizers] for predefined_recognizer in mandatory_recognizers: assert predefined_recognizer in names @@ -96,11 +96,41 @@ def test_analyzer_engine_provider_configuration_file(): assert engine.nlp_engine.engine_name == "spacy" +def test_analyzer_engine_provider_passes_recognizer_score_thresholds(tmp_path): + """Numeric shorthand should normalize and apply through the provider.""" + 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 + configuration["recognizer_score_thresholds"] = {"CreditCardRecognizer": 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() + + assert engine.recognizer_score_thresholds == { + "CreditCardRecognizer": {"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_defaults(mandatory_recognizers): provider = AnalyzerEngineProvider() engine = provider.create_engine() assert engine.supported_languages == ["en"] assert engine.default_score_threshold == 0 + assert engine.recognizer_score_thresholds == {} recognizer_registry = engine.registry assert ( recognizer_registry.global_regex_flags @@ -366,8 +396,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: diff --git a/presidio-analyzer/tests/test_configuration_validator.py b/presidio-analyzer/tests/test_configuration_validator.py index ad5ed5687a..499d8639f9 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: E501 +import pytest from presidio_analyzer.input_validation import ConfigurationValidator - # ========== Language Code Validation Tests ========== def test_validate_language_codes_valid(): @@ -318,6 +318,90 @@ def test_configuration_validator_analyzer_config_valid(): assert validated == valid_config +def test_configuration_validator_analyzer_config_valid_with_recognizer_score_thresholds(): + """Validator should accept numeric shorthand and nested overrides.""" + valid_config = { + "supported_languages": ["en"], + "default_score_threshold": 0.5, + "recognizer_score_thresholds": { + "CreditCardRecognizer": 0.4, + "SpacyRecognizer": { + "PERSON": 0.6, + }, + }, + } + + validated = ConfigurationValidator.validate_analyzer_configuration(valid_config) + assert validated == { + "supported_languages": ["en"], + "default_score_threshold": 0.5, + "recognizer_score_thresholds": { + "CreditCardRecognizer": {"default": 0.4}, + "SpacyRecognizer": {"PERSON": 0.6}, + }, + } + + +@pytest.mark.parametrize( + "invalid_config,expected_message", + [ + ( + { + "supported_languages": ["en"], + "recognizer_score_thresholds": { + 123: {"default": 0.4}, + }, + }, + "non-empty strings", + ), + ( + { + "supported_languages": ["en"], + "recognizer_score_thresholds": { + "CreditCardRecognizer": ["default", 0.4], + }, + }, + "recognizer_score_thresholds", + ), + ( + { + "supported_languages": ["en"], + "recognizer_score_thresholds": { + "CreditCardRecognizer": {"": 0.4}, + }, + }, + "nested keys must be non-empty strings", + ), + ( + { + "supported_languages": ["en"], + "recognizer_score_thresholds": { + "CreditCardRecognizer": {"default": 1.5}, + }, + }, + "recognizer_score_thresholds", + ), + ( + { + "supported_languages": ["en"], + "recognizer_score_thresholds": { + "CreditCardRecognizer": {"default": True}, + }, + }, + "values must be numeric", + ), + ], +) +def test_configuration_validator_analyzer_config_invalid_recognizer_score_thresholds( + invalid_config, expected_message +): + """Validator should reject malformed threshold overrides.""" + with pytest.raises(ValueError) as exc_info: + ConfigurationValidator.validate_analyzer_configuration(invalid_config) + + assert expected_message in str(exc_info.value) + + def test_analyzer_config_minimal(): """Test minimal valid analyzer configuration.""" valid_config = {