From 9f4ec7de9129744e3b8d3460778698133b59979f Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Sat, 27 Jun 2026 23:16:34 -0400 Subject: [PATCH 1/7] Allow per-recognizer analyzer tuning from YAML --- CHANGELOG.md | 1 + docs/analyzer/analyzer_engine_provider.md | 20 +- docs/tutorial/08_no_code.md | 8 + .../presidio_analyzer/analyzer_engine.py | 62 +++++- .../analyzer_engine_provider.py | 4 + .../conf/default_analyzer_full.yaml | 6 + .../input_validation/schemas.py | 63 +++++++ .../tests/test_analyzer_engine.py | 176 ++++++++++++++++++ .../tests/test_analyzer_engine_provider.py | 48 ++++- .../tests/test_configuration_validator.py | 79 +++++++- 10 files changed, 450 insertions(+), 17 deletions(-) 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..ff36f89777 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..fdc69f2a89 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 import regex as re @@ -56,6 +56,7 @@ def __init__( default_score_threshold: float = 0, supported_languages: List[str] = None, context_aware_enhancer: Optional[ContextAwareEnhancer] = None, + recognizer_score_thresholds: Optional[Dict[str, Dict[str, float]]] = None, ): if not supported_languages: supported_languages = ["en"] @@ -102,6 +103,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( @@ -340,11 +344,65 @@ 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[Dict[str, Dict[str, float]]] + ) -> Dict[str, Dict[str, float]]: + """Normalize shorthand threshold values into the nested mapping shape.""" + normalized_thresholds: Dict[str, Dict[str, float]] = {} + + for recognizer_name, recognizer_thresholds in ( + recognizer_score_thresholds or {} + ).items(): + if isinstance(recognizer_thresholds, (int, float)) and not isinstance( + recognizer_thresholds, bool + ): + normalized_thresholds[recognizer_name] = { + "default": recognizer_thresholds + } + elif isinstance(recognizer_thresholds, dict): + normalized_thresholds[recognizer_name] = recognizer_thresholds + else: + raise ValueError( + "recognizer_score_thresholds values must be a number " + "or a dictionary" + ) + + return normalized_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..794c66e191 100644 --- a/presidio-analyzer/presidio_analyzer/input_validation/schemas.py +++ b/presidio-analyzer/presidio_analyzer/input_validation/schemas.py @@ -44,6 +44,61 @@ 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_score_threshold( + recognizer_thresholds + ) + } + continue + + if not isinstance(recognizer_thresholds, dict): + raise ValueError( + f"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" + ) + + validated_recognizer_thresholds[threshold_name] = ( + ConfigurationValidator.validate_score_threshold(threshold_value) + ) + + validated_thresholds[recognizer_name] = validated_recognizer_thresholds + + return validated_thresholds + @staticmethod def validate_nlp_configuration(config: Dict[str, Any]) -> Dict[str, Any]: """Validate NLP configuration structure. @@ -112,6 +167,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 +191,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..2be93bf1b2 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,76 @@ 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 + ] + + +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 +630,110 @@ 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_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..6916b12a68 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,81 @@ 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], + }, + }, + "must be a dictionary", + ), + ( + { + "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}, + }, + }, + "must be between 0.0 and 1.0", + ), + ], +) +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 = { From cf7c572933e561a08f627bbf642e536d63e2df2b Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Sat, 27 Jun 2026 23:32:19 -0400 Subject: [PATCH 2/7] Keep boolean flags from slipping into threshold config --- .../presidio_analyzer/analyzer_engine.py | 30 ++++++++++++++++++- .../input_validation/schemas.py | 6 ++++ .../tests/test_analyzer_engine.py | 8 +++++ .../tests/test_configuration_validator.py | 9 ++++++ 4 files changed, 52 insertions(+), 1 deletion(-) diff --git a/presidio-analyzer/presidio_analyzer/analyzer_engine.py b/presidio-analyzer/presidio_analyzer/analyzer_engine.py index fdc69f2a89..e248798a8d 100644 --- a/presidio-analyzer/presidio_analyzer/analyzer_engine.py +++ b/presidio-analyzer/presidio_analyzer/analyzer_engine.py @@ -363,6 +363,15 @@ def __normalize_recognizer_score_thresholds( for recognizer_name, recognizer_thresholds in ( recognizer_score_thresholds or {} ).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 ): @@ -370,7 +379,26 @@ def __normalize_recognizer_score_thresholds( "default": recognizer_thresholds } elif isinstance(recognizer_thresholds, dict): - normalized_thresholds[recognizer_name] = recognizer_thresholds + normalized_thresholds[recognizer_name] = {} + 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" + ) + normalized_thresholds[recognizer_name][ + threshold_name + ] = threshold_value else: raise ValueError( "recognizer_score_thresholds values must be a number " diff --git a/presidio-analyzer/presidio_analyzer/input_validation/schemas.py b/presidio-analyzer/presidio_analyzer/input_validation/schemas.py index 794c66e191..d8d5909395 100644 --- a/presidio-analyzer/presidio_analyzer/input_validation/schemas.py +++ b/presidio-analyzer/presidio_analyzer/input_validation/schemas.py @@ -90,6 +90,12 @@ def validate_recognizer_score_thresholds( "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_score_threshold(threshold_value) diff --git a/presidio-analyzer/tests/test_analyzer_engine.py b/presidio-analyzer/tests/test_analyzer_engine.py index 2be93bf1b2..edb612efc7 100644 --- a/presidio-analyzer/tests/test_analyzer_engine.py +++ b/presidio-analyzer/tests/test_analyzer_engine.py @@ -719,6 +719,14 @@ def analyze( 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_empty_recognizer_thresholds_use_default(): """An empty threshold map should keep the global default behavior.""" analyzer_engine = _build_threshold_analyzer(recognizer_score_thresholds={}) diff --git a/presidio-analyzer/tests/test_configuration_validator.py b/presidio-analyzer/tests/test_configuration_validator.py index 6916b12a68..f8fa319211 100644 --- a/presidio-analyzer/tests/test_configuration_validator.py +++ b/presidio-analyzer/tests/test_configuration_validator.py @@ -381,6 +381,15 @@ def test_configuration_validator_analyzer_config_valid_with_recognizer_score_thr }, "must be between 0.0 and 1.0", ), + ( + { + "supported_languages": ["en"], + "recognizer_score_thresholds": { + "CreditCardRecognizer": {"default": True}, + }, + }, + "values must be numeric", + ), ], ) def test_configuration_validator_analyzer_config_invalid_recognizer_score_thresholds( From 0e162d2a33821080f86a9e757b93b8c4eae0eaba Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Sat, 27 Jun 2026 23:36:45 -0400 Subject: [PATCH 3/7] Keep analyzer docs aligned with the new threshold contract --- presidio-analyzer/presidio_analyzer/analyzer_engine.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/presidio-analyzer/presidio_analyzer/analyzer_engine.py b/presidio-analyzer/presidio_analyzer/analyzer_engine.py index e248798a8d..ff95b0a82e 100644 --- a/presidio-analyzer/presidio_analyzer/analyzer_engine.py +++ b/presidio-analyzer/presidio_analyzer/analyzer_engine.py @@ -40,6 +40,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 From 53ad75212b56554385fc33db84130c9d9956839b Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Sat, 27 Jun 2026 23:43:01 -0400 Subject: [PATCH 4/7] Keep direct threshold config aligned with YAML validation --- .../presidio_analyzer/analyzer_engine.py | 21 ++++++++++++++----- .../tests/test_analyzer_engine.py | 14 +++++++++++++ 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/presidio-analyzer/presidio_analyzer/analyzer_engine.py b/presidio-analyzer/presidio_analyzer/analyzer_engine.py index ff95b0a82e..70a501bda2 100644 --- a/presidio-analyzer/presidio_analyzer/analyzer_engine.py +++ b/presidio-analyzer/presidio_analyzer/analyzer_engine.py @@ -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, @@ -360,11 +361,17 @@ def __normalize_recognizer_score_thresholds( recognizer_score_thresholds: Optional[Dict[str, Dict[str, float]]] ) -> Dict[str, Dict[str, float]]: """Normalize shorthand threshold values into the nested mapping shape.""" + if recognizer_score_thresholds is None: + return {} + if not isinstance(recognizer_score_thresholds, dict): + raise ValueError("recognizer_score_thresholds must be a dictionary") + normalized_thresholds: Dict[str, Dict[str, float]] = {} - for recognizer_name, recognizer_thresholds in ( - recognizer_score_thresholds or {} - ).items(): + for ( + recognizer_name, + recognizer_thresholds, + ) in recognizer_score_thresholds.items(): if ( not isinstance(recognizer_name, str) or recognizer_name.strip() != recognizer_name @@ -378,7 +385,9 @@ def __normalize_recognizer_score_thresholds( recognizer_thresholds, bool ): normalized_thresholds[recognizer_name] = { - "default": recognizer_thresholds + "default": ConfigurationValidator.validate_score_threshold( + recognizer_thresholds + ) } elif isinstance(recognizer_thresholds, dict): normalized_thresholds[recognizer_name] = {} @@ -400,7 +409,9 @@ def __normalize_recognizer_score_thresholds( ) normalized_thresholds[recognizer_name][ threshold_name - ] = threshold_value + ] = ConfigurationValidator.validate_score_threshold( + threshold_value + ) else: raise ValueError( "recognizer_score_thresholds values must be a number " diff --git a/presidio-analyzer/tests/test_analyzer_engine.py b/presidio-analyzer/tests/test_analyzer_engine.py index edb612efc7..b81394d510 100644 --- a/presidio-analyzer/tests/test_analyzer_engine.py +++ b/presidio-analyzer/tests/test_analyzer_engine.py @@ -727,6 +727,20 @@ def test_direct_threshold_config_rejects_boolean_values(): ) +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_empty_recognizer_thresholds_use_default(): """An empty threshold map should keep the global default behavior.""" analyzer_engine = _build_threshold_analyzer(recognizer_score_thresholds={}) From 87c455be23fb3b99a4c20a3c7672c025840fb8cb Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Sat, 27 Jun 2026 23:50:13 -0400 Subject: [PATCH 5/7] Preserve lenient recognizers when duplicate spans collide --- docs/analyzer/analyzer_engine_provider.md | 2 +- .../presidio_analyzer/analyzer_engine.py | 5 ++- .../tests/test_analyzer_engine.py | 45 +++++++++++++++++++ 3 files changed, 49 insertions(+), 3 deletions(-) diff --git a/docs/analyzer/analyzer_engine_provider.md b/docs/analyzer/analyzer_engine_provider.md index ff36f89777..b29efaa751 100644 --- a/docs/analyzer/analyzer_engine_provider.md +++ b/docs/analyzer/analyzer_engine_provider.md @@ -74,7 +74,7 @@ The configuration file contains the following parameters: - `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. +- `recognizer_registry`: All the recognizers that will be used by the analyzer. Example: diff --git a/presidio-analyzer/presidio_analyzer/analyzer_engine.py b/presidio-analyzer/presidio_analyzer/analyzer_engine.py index 70a501bda2..bcfe0135ec 100644 --- a/presidio-analyzer/presidio_analyzer/analyzer_engine.py +++ b/presidio-analyzer/presidio_analyzer/analyzer_engine.py @@ -261,9 +261,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( diff --git a/presidio-analyzer/tests/test_analyzer_engine.py b/presidio-analyzer/tests/test_analyzer_engine.py index b81394d510..4ab40c61c7 100644 --- a/presidio-analyzer/tests/test_analyzer_engine.py +++ b/presidio-analyzer/tests/test_analyzer_engine.py @@ -114,6 +114,22 @@ def analyze(self, text: str, entities: List[str], nlp_artifacts: NlpArtifacts): ] +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 ): @@ -741,6 +757,35 @@ def test_direct_threshold_config_rejects_non_dict_top_level_values(): _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={}) From 89ff6ce2cee89d238e754184bc6164f5c62378f7 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Sun, 28 Jun 2026 00:03:00 -0400 Subject: [PATCH 6/7] Keep threshold validation errors tied to the config key --- .../input_validation/schemas.py | 29 ++++++++++++++++--- .../tests/test_configuration_validator.py | 4 +-- 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/presidio-analyzer/presidio_analyzer/input_validation/schemas.py b/presidio-analyzer/presidio_analyzer/input_validation/schemas.py index d8d5909395..c7e10c9a07 100644 --- a/presidio-analyzer/presidio_analyzer/input_validation/schemas.py +++ b/presidio-analyzer/presidio_analyzer/input_validation/schemas.py @@ -67,15 +67,17 @@ def validate_recognizer_score_thresholds( recognizer_thresholds, bool ): validated_thresholds[recognizer_name] = { - "default": ConfigurationValidator.validate_score_threshold( - recognizer_thresholds + "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"Thresholds for recognizer '{recognizer_name}' " + f"recognizer_score_thresholds for recognizer '{recognizer_name}' " f"must be a dictionary" ) @@ -98,13 +100,32 @@ def validate_recognizer_score_thresholds( ) validated_recognizer_thresholds[threshold_name] = ( - ConfigurationValidator.validate_score_threshold(threshold_value) + 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. diff --git a/presidio-analyzer/tests/test_configuration_validator.py b/presidio-analyzer/tests/test_configuration_validator.py index f8fa319211..499d8639f9 100644 --- a/presidio-analyzer/tests/test_configuration_validator.py +++ b/presidio-analyzer/tests/test_configuration_validator.py @@ -361,7 +361,7 @@ def test_configuration_validator_analyzer_config_valid_with_recognizer_score_thr "CreditCardRecognizer": ["default", 0.4], }, }, - "must be a dictionary", + "recognizer_score_thresholds", ), ( { @@ -379,7 +379,7 @@ def test_configuration_validator_analyzer_config_valid_with_recognizer_score_thr "CreditCardRecognizer": {"default": 1.5}, }, }, - "must be between 0.0 and 1.0", + "recognizer_score_thresholds", ), ( { From 9ebc0e84430db73805ff451ff2efa4db76a3cb2f Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Sun, 28 Jun 2026 00:16:26 -0400 Subject: [PATCH 7/7] Keep constructor threshold hints aligned with shared validation --- .../presidio_analyzer/analyzer_engine.py | 70 +++---------------- 1 file changed, 9 insertions(+), 61 deletions(-) diff --git a/presidio-analyzer/presidio_analyzer/analyzer_engine.py b/presidio-analyzer/presidio_analyzer/analyzer_engine.py index bcfe0135ec..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 Dict, List, Optional +from typing import Dict, List, Optional, Union import regex as re @@ -25,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: """ @@ -59,7 +60,7 @@ def __init__( default_score_threshold: float = 0, supported_languages: List[str] = None, context_aware_enhancer: Optional[ContextAwareEnhancer] = None, - recognizer_score_thresholds: Optional[Dict[str, Dict[str, float]]] = None, + recognizer_score_thresholds: Optional[RecognizerScoreThresholds] = None, ): if not supported_languages: supported_languages = ["en"] @@ -106,8 +107,8 @@ 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 + self.recognizer_score_thresholds = ( + self.__normalize_recognizer_score_thresholds(recognizer_score_thresholds) ) if not context_aware_enhancer: @@ -359,67 +360,14 @@ def __remove_low_scores( @staticmethod def __normalize_recognizer_score_thresholds( - recognizer_score_thresholds: Optional[Dict[str, Dict[str, float]]] + 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 {} - if not isinstance(recognizer_score_thresholds, dict): - raise ValueError("recognizer_score_thresholds must be a dictionary") - - normalized_thresholds: Dict[str, Dict[str, float]] = {} - - for ( - recognizer_name, - recognizer_thresholds, - ) in recognizer_score_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 - ): - normalized_thresholds[recognizer_name] = { - "default": ConfigurationValidator.validate_score_threshold( - recognizer_thresholds - ) - } - elif isinstance(recognizer_thresholds, dict): - normalized_thresholds[recognizer_name] = {} - 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" - ) - normalized_thresholds[recognizer_name][ - threshold_name - ] = ConfigurationValidator.validate_score_threshold( - threshold_value - ) - else: - raise ValueError( - "recognizer_score_thresholds values must be a number " - "or a dictionary" - ) - - return normalized_thresholds + 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."""