From 370d46435e5d0fa7b3a44bd4a15e6d540bbc9600 Mon Sep 17 00:00:00 2001 From: Bhargavi Kalicheti <102758710+bhargavikalicheti@users.noreply.github.com> Date: Sun, 12 Jul 2026 17:24:18 -0500 Subject: [PATCH 01/15] Healthcare recognizers --- CHANGELOG.md | 5 + docs/supported_entities.md | 6 + .../conf/default_recognizers.yaml | 42 +++ .../predefined_recognizers/__init__.py | 16 + .../country_specific/us/__init__.py | 16 + ...s_health_insurance_member_id_recognizer.py | 105 ++++++ .../us/us_healthcare_admin_recognizers.py | 311 ++++++++++++++++++ ...s_health_insurance_member_id_recognizer.py | 85 +++++ .../test_us_healthcare_admin_recognizers.py | 224 +++++++++++++ 9 files changed, 810 insertions(+) create mode 100644 presidio-analyzer/presidio_analyzer/predefined_recognizers/country_specific/us/us_health_insurance_member_id_recognizer.py create mode 100644 presidio-analyzer/presidio_analyzer/predefined_recognizers/country_specific/us/us_healthcare_admin_recognizers.py create mode 100644 presidio-analyzer/tests/test_us_health_insurance_member_id_recognizer.py create mode 100644 presidio-analyzer/tests/test_us_healthcare_admin_recognizers.py diff --git a/CHANGELOG.md b/CHANGELOG.md index e9ebd1bb44..1e12898b6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ All notable changes to this project will be documented in this file. ## [unreleased] +### Analyzer +#### Added +- Added a disabled-by-default US health insurance member ID (`US_HEALTH_INSURANCE_MEMBER_ID`) recognizer requiring healthcare or insurance context. +- Added disabled-by-default US healthcare administrative ID recognizers for claim numbers, prior authorization numbers, prescription numbers, provider tax IDs, and referral numbers. + ### Anonymizer ### General #### Fixed diff --git a/docs/supported_entities.md b/docs/supported_entities.md index 4273461a41..023db5653f 100644 --- a/docs/supported_entities.md +++ b/docs/supported_entities.md @@ -33,9 +33,15 @@ For more information, refer to the [adding new recognizers documentation](analyz |US_BANK_NUMBER|A US bank account number is between 8 to 17 digits.|Pattern match and context| |US_DRIVER_LICENSE|A US driver license according to |Pattern match and context| |US_ITIN | US Individual Taxpayer Identification Number (ITIN). Nine digits that start with a "9" and contain a "7" or "8" as the 4 digit.|Pattern match and context| +|US_CLAIM_NUMBER|A US healthcare claim identifier used in billing and claims processing.|Pattern match and required context| +|US_HEALTH_INSURANCE_MEMBER_ID|A US health insurance member or subscriber identifier printed on an insurance card. Detection requires healthcare or insurance context.|Pattern match and required context| |US_MBI|A US Medicare Beneficiary Identifier (MBI) with 11 alphanumeric characters.|Pattern match and context| |US_NPI|A US National Provider Identifier (NPI) is a 10-digit number issued to healthcare providers by CMS under HIPAA.|Pattern match, context and checksum| |US_PASSPORT |A US passport number with 9 digits.|Pattern match and context| +|US_PRESCRIPTION_NUMBER|A US prescription or pharmacy order identifier.|Pattern match and required context| +|US_PRIOR_AUTHORIZATION_NUMBER|A US prior authorization identifier used for treatment or drug approval requests.|Pattern match and required context| +|US_PROVIDER_TAX_ID|A US provider organization tax identifier (TIN/EIN) used in healthcare billing workflows.|Pattern match and required context| +|US_REFERRAL_NUMBER|A US healthcare referral identifier, including specialty or infusion referral numbers.|Pattern match and required context| |US_SSN|A US Social Security Number (SSN) with 9 digits.|Pattern match and context| ### UK diff --git a/presidio-analyzer/presidio_analyzer/conf/default_recognizers.yaml b/presidio-analyzer/presidio_analyzer/conf/default_recognizers.yaml index 0ad50b1603..bae7a044b3 100644 --- a/presidio-analyzer/presidio_analyzer/conf/default_recognizers.yaml +++ b/presidio-analyzer/presidio_analyzer/conf/default_recognizers.yaml @@ -81,6 +81,48 @@ recognizers: enabled: false country_code: us + - name: UsHealthInsuranceMemberIdRecognizer + supported_languages: + - en + type: predefined + enabled: false + country_code: us + + - name: UsPriorAuthorizationNumberRecognizer + supported_languages: + - en + type: predefined + enabled: false + country_code: us + + - name: UsClaimNumberRecognizer + supported_languages: + - en + type: predefined + enabled: false + country_code: us + + - name: UsPrescriptionNumberRecognizer + supported_languages: + - en + type: predefined + enabled: false + country_code: us + + - name: UsReferralNumberRecognizer + supported_languages: + - en + type: predefined + enabled: false + country_code: us + + - name: UsProviderTaxIdRecognizer + supported_languages: + - en + type: predefined + enabled: false + country_code: us + - name: NhsRecognizer supported_languages: - en diff --git a/presidio-analyzer/presidio_analyzer/predefined_recognizers/__init__.py b/presidio-analyzer/presidio_analyzer/predefined_recognizers/__init__.py index 2823ff035d..f3359ccae5 100644 --- a/presidio-analyzer/presidio_analyzer/predefined_recognizers/__init__.py +++ b/presidio-analyzer/presidio_analyzer/predefined_recognizers/__init__.py @@ -129,6 +129,16 @@ from .country_specific.us.medical_license_recognizer import MedicalLicenseRecognizer from .country_specific.us.us_bank_recognizer import UsBankRecognizer from .country_specific.us.us_driver_license_recognizer import UsLicenseRecognizer +from .country_specific.us.us_healthcare_admin_recognizers import ( + UsClaimNumberRecognizer, + UsPrescriptionNumberRecognizer, + UsPriorAuthorizationNumberRecognizer, + UsProviderTaxIdRecognizer, + UsReferralNumberRecognizer, +) +from .country_specific.us.us_health_insurance_member_id_recognizer import ( + UsHealthInsuranceMemberIdRecognizer, +) from .country_specific.us.us_itin_recognizer import UsItinRecognizer from .country_specific.us.us_mbi_recognizer import UsMbiRecognizer from .country_specific.us.us_npi_recognizer import UsNpiRecognizer @@ -198,11 +208,17 @@ "SgFinRecognizer", "UrlRecognizer", "UsBankRecognizer", + "UsClaimNumberRecognizer", + "UsHealthInsuranceMemberIdRecognizer", "UsItinRecognizer", "UsLicenseRecognizer", "UsMbiRecognizer", "UsNpiRecognizer", "UsPassportRecognizer", + "UsPrescriptionNumberRecognizer", + "UsPriorAuthorizationNumberRecognizer", + "UsProviderTaxIdRecognizer", + "UsReferralNumberRecognizer", "UsSsnRecognizer", "EsNifRecognizer", "SpacyRecognizer", diff --git a/presidio-analyzer/presidio_analyzer/predefined_recognizers/country_specific/us/__init__.py b/presidio-analyzer/presidio_analyzer/predefined_recognizers/country_specific/us/__init__.py index 6e80dbbaef..32f0e07eba 100644 --- a/presidio-analyzer/presidio_analyzer/predefined_recognizers/country_specific/us/__init__.py +++ b/presidio-analyzer/presidio_analyzer/predefined_recognizers/country_specific/us/__init__.py @@ -4,6 +4,16 @@ from .medical_license_recognizer import MedicalLicenseRecognizer from .us_bank_recognizer import UsBankRecognizer from .us_driver_license_recognizer import UsLicenseRecognizer +from .us_healthcare_admin_recognizers import ( + UsClaimNumberRecognizer, + UsPrescriptionNumberRecognizer, + UsPriorAuthorizationNumberRecognizer, + UsProviderTaxIdRecognizer, + UsReferralNumberRecognizer, +) +from .us_health_insurance_member_id_recognizer import ( + UsHealthInsuranceMemberIdRecognizer, +) from .us_itin_recognizer import UsItinRecognizer from .us_mbi_recognizer import UsMbiRecognizer from .us_npi_recognizer import UsNpiRecognizer @@ -15,9 +25,15 @@ "UsItinRecognizer", "UsBankRecognizer", "UsLicenseRecognizer", + "UsClaimNumberRecognizer", + "UsHealthInsuranceMemberIdRecognizer", "UsMbiRecognizer", "UsNpiRecognizer", "UsPassportRecognizer", + "UsPrescriptionNumberRecognizer", + "UsPriorAuthorizationNumberRecognizer", + "UsProviderTaxIdRecognizer", + "UsReferralNumberRecognizer", "AbaRoutingRecognizer", "UsSsnRecognizer", ] diff --git a/presidio-analyzer/presidio_analyzer/predefined_recognizers/country_specific/us/us_health_insurance_member_id_recognizer.py b/presidio-analyzer/presidio_analyzer/predefined_recognizers/country_specific/us/us_health_insurance_member_id_recognizer.py new file mode 100644 index 0000000000..3369c0a5c8 --- /dev/null +++ b/presidio-analyzer/presidio_analyzer/predefined_recognizers/country_specific/us/us_health_insurance_member_id_recognizer.py @@ -0,0 +1,105 @@ +"""Recognizer for US health insurance member identifiers.""" + +from typing import List, Optional + +from presidio_analyzer import Pattern, PatternRecognizer, RecognizerResult + + +class UsHealthInsuranceMemberIdRecognizer(PatternRecognizer): + """Recognize US health insurance member/subscriber IDs with context. + + US health insurance member identifiers are payer-specific and do not have a + single universal checksum or format. To avoid broad matching of generic + alphanumeric IDs, this recognizer requires both: + - a plausible alphanumeric member ID pattern, and + - nearby healthcare/insurance context. + + :param patterns: List of patterns to be used by this recognizer + :param context: List of context words to require near a match + :param supported_language: Language this recognizer supports + :param supported_entity: The entity this recognizer can detect + :param context_window: Number of characters before/after a match to scan + for context. + """ + + COUNTRY_CODE = "us" + + PATTERNS = [ + Pattern( + "Health insurance member ID (alphanumeric)", + r"\b(?=[A-Z0-9-]{6,20}\b)(?=[A-Z0-9-]*[A-Z])" + r"(?=[A-Z0-9-]*\d)[A-Z]{1,5}-?[A-Z0-9]{5,14}\b", + 0.3, + ), + ] + + CONTEXT = [ + "member id", + "member number", + "subscriber id", + "subscriber number", + "insurance id", + "health plan id", + "plan member id", + "policy id", + "policy number", + "health insurance", + "insurance member", + "insurance card", + ] + + NEGATIVE_CONTEXT = [ + "order number", + "order no", + "tracking number", + "tracking no", + "case number", + "case no", + "claim number", + "claim no", + "claim id", + ] + + def __init__( + self, + patterns: Optional[List[Pattern]] = None, + context: Optional[List[str]] = None, + supported_language: str = "en", + supported_entity: str = "US_HEALTH_INSURANCE_MEMBER_ID", + name: Optional[str] = None, + context_window: int = 40, + ): + self.context_window = context_window + patterns = patterns if patterns else self.PATTERNS + context = context if context else self.CONTEXT + super().__init__( + supported_entity=supported_entity, + patterns=patterns, + context=context, + supported_language=supported_language, + name=name, + ) + + def analyze( + self, + text: str, + entities: List[str], + nlp_artifacts=None, + regex_flags: Optional[int] = None, + ) -> List[RecognizerResult]: + """Analyze text and keep only matches with nearby positive context.""" + results = super().analyze(text, entities, nlp_artifacts, regex_flags) + return [ + result for result in results if self.__has_required_context(text, result) + ] + + def __has_required_context(self, text: str, result: RecognizerResult) -> bool: + window_text = self.__get_context_window(text, result).lower() + if any(context in window_text for context in self.NEGATIVE_CONTEXT): + return False + return any(context in window_text for context in self.context) + + def __get_context_window(self, text: str, result: RecognizerResult) -> str: + start = max(0, result.start - self.context_window) + end = min(len(text), result.end + self.context_window) + return text[start:end] diff --git a/presidio-analyzer/presidio_analyzer/predefined_recognizers/country_specific/us/us_healthcare_admin_recognizers.py b/presidio-analyzer/presidio_analyzer/predefined_recognizers/country_specific/us/us_healthcare_admin_recognizers.py new file mode 100644 index 0000000000..0f7b6b4000 --- /dev/null +++ b/presidio-analyzer/presidio_analyzer/predefined_recognizers/country_specific/us/us_healthcare_admin_recognizers.py @@ -0,0 +1,311 @@ +"""Recognizers for US healthcare administrative identifiers.""" + +from typing import List, Optional + +from presidio_analyzer import Pattern, PatternRecognizer, RecognizerResult + + +class _ContextRequiredPatternRecognizer(PatternRecognizer): + """Pattern recognizer which keeps only matches with required context.""" + + COUNTRY_CODE = "us" + + NEGATIVE_CONTEXT: List[str] = [] + + def __init__( + self, + patterns: List[Pattern], + context: List[str], + supported_entity: str, + supported_language: str = "en", + name: Optional[str] = None, + context_window: int = 45, + ): + self.context_window = context_window + super().__init__( + supported_entity=supported_entity, + patterns=patterns, + context=context, + supported_language=supported_language, + name=name, + ) + + def analyze( + self, + text: str, + entities: List[str], + nlp_artifacts=None, + regex_flags: Optional[int] = None, + ) -> List[RecognizerResult]: + """Analyze text and keep only matches with nearby positive context.""" + results = super().analyze(text, entities, nlp_artifacts, regex_flags) + return [ + result for result in results if self.__has_required_context(text, result) + ] + + def __has_required_context(self, text: str, result: RecognizerResult) -> bool: + window_text = self.__get_context_window(text, result).lower() + if any(context in window_text for context in self.NEGATIVE_CONTEXT): + return False + return any(context in window_text for context in self.context) + + def __get_context_window(self, text: str, result: RecognizerResult) -> str: + start = max(0, result.start - self.context_window) + end = min(len(text), result.end + self.context_window) + return text[start:end] + + +class UsPriorAuthorizationNumberRecognizer(_ContextRequiredPatternRecognizer): + """Recognize US healthcare prior authorization numbers with context.""" + + PATTERNS = [ + Pattern( + "Prior authorization number", + r"\bPA-?\d{6,12}\b", + 0.35, + ), + ] + + CONTEXT = [ + "prior authorization", + "prior auth", + "preauthorization", + "pre-auth", + "authorization number", + "auth number", + "approval request", + "treatment authorization", + "drug authorization", + ] + + NEGATIVE_CONTEXT = [ + "order number", + "tracking number", + "case number", + "claim number", + "claim id", + "invoice number", + ] + + def __init__( + self, + patterns: Optional[List[Pattern]] = None, + context: Optional[List[str]] = None, + supported_language: str = "en", + supported_entity: str = "US_PRIOR_AUTHORIZATION_NUMBER", + name: Optional[str] = None, + context_window: int = 45, + ): + super().__init__( + patterns=patterns if patterns else self.PATTERNS, + context=context if context else self.CONTEXT, + supported_entity=supported_entity, + supported_language=supported_language, + name=name, + context_window=context_window, + ) + + +class UsClaimNumberRecognizer(_ContextRequiredPatternRecognizer): + """Recognize US healthcare claim numbers with billing/claims context.""" + + PATTERNS = [ + Pattern( + "Claim number", + r"\bCLM-?\d{6,12}\b", + 0.35, + ), + ] + + CONTEXT = [ + "claim number", + "claim id", + "claim", + "healthcare claim", + "medical claim", + "billing", + "billing claim", + "claims processing", + "processed claim", + ] + + NEGATIVE_CONTEXT = [ + "order number", + "tracking number", + "case number", + "referral number", + "authorization number", + "invoice number", + ] + + def __init__( + self, + patterns: Optional[List[Pattern]] = None, + context: Optional[List[str]] = None, + supported_language: str = "en", + supported_entity: str = "US_CLAIM_NUMBER", + name: Optional[str] = None, + context_window: int = 45, + ): + super().__init__( + patterns=patterns if patterns else self.PATTERNS, + context=context if context else self.CONTEXT, + supported_entity=supported_entity, + supported_language=supported_language, + name=name, + context_window=context_window, + ) + + +class UsPrescriptionNumberRecognizer(_ContextRequiredPatternRecognizer): + """Recognize US prescription numbers with pharmacy context.""" + + PATTERNS = [ + Pattern( + "Prescription number", + r"\bRX-?\d{6,12}\b", + 0.35, + ), + ] + + CONTEXT = [ + "prescription number", + "prescription id", + "rx number", + "rx no", + "pharmacy", + "prescription", + "medication order", + "drug order", + ] + + NEGATIVE_CONTEXT = [ + "order number", + "tracking number", + "case number", + "claim number", + "claim id", + "invoice number", + ] + + def __init__( + self, + patterns: Optional[List[Pattern]] = None, + context: Optional[List[str]] = None, + supported_language: str = "en", + supported_entity: str = "US_PRESCRIPTION_NUMBER", + name: Optional[str] = None, + context_window: int = 45, + ): + super().__init__( + patterns=patterns if patterns else self.PATTERNS, + context=context if context else self.CONTEXT, + supported_entity=supported_entity, + supported_language=supported_language, + name=name, + context_window=context_window, + ) + + +class UsReferralNumberRecognizer(_ContextRequiredPatternRecognizer): + """Recognize US healthcare referral numbers with referral context.""" + + PATTERNS = [ + Pattern( + "Referral number", + r"\b(?:REF|INF)-?\d{6,12}\b", + 0.35, + ), + ] + + CONTEXT = [ + "referral number", + "referral id", + "referral", + "infusion referral", + "infusion therapy", + "specialty referral", + "specialty care", + "referring provider", + ] + + NEGATIVE_CONTEXT = [ + "order number", + "tracking number", + "case number", + "claim number", + "claim id", + "invoice number", + ] + + def __init__( + self, + patterns: Optional[List[Pattern]] = None, + context: Optional[List[str]] = None, + supported_language: str = "en", + supported_entity: str = "US_REFERRAL_NUMBER", + name: Optional[str] = None, + context_window: int = 45, + ): + super().__init__( + patterns=patterns if patterns else self.PATTERNS, + context=context if context else self.CONTEXT, + supported_entity=supported_entity, + supported_language=supported_language, + name=name, + context_window=context_window, + ) + + +class UsProviderTaxIdRecognizer(_ContextRequiredPatternRecognizer): + """Recognize US provider TIN/EIN values with healthcare provider context.""" + + PATTERNS = [ + Pattern( + "Provider tax ID", + r"\b\d{2}-\d{7}\b", + 0.35, + ), + ] + + CONTEXT = [ + "provider tax id", + "provider tin", + "provider ein", + "tax id", + "tin", + "ein", + "healthcare organization", + "provider organization", + "billing provider", + "rendering provider", + ] + + NEGATIVE_CONTEXT = [ + "employee tax id", + "vendor tax id", + "company tax id", + "order number", + "tracking number", + "case number", + "claim number", + "invoice number", + ] + + def __init__( + self, + patterns: Optional[List[Pattern]] = None, + context: Optional[List[str]] = None, + supported_language: str = "en", + supported_entity: str = "US_PROVIDER_TAX_ID", + name: Optional[str] = None, + context_window: int = 45, + ): + super().__init__( + patterns=patterns if patterns else self.PATTERNS, + context=context if context else self.CONTEXT, + supported_entity=supported_entity, + supported_language=supported_language, + name=name, + context_window=context_window, + ) diff --git a/presidio-analyzer/tests/test_us_health_insurance_member_id_recognizer.py b/presidio-analyzer/tests/test_us_health_insurance_member_id_recognizer.py new file mode 100644 index 0000000000..fa641bdaf5 --- /dev/null +++ b/presidio-analyzer/tests/test_us_health_insurance_member_id_recognizer.py @@ -0,0 +1,85 @@ +import pytest +from presidio_analyzer.predefined_recognizers import ( + UsHealthInsuranceMemberIdRecognizer, +) + +from tests import assert_result + + +@pytest.fixture(scope="module") +def recognizer(): + """Return an instance of the US health insurance member ID recognizer.""" + return UsHealthInsuranceMemberIdRecognizer() + + +@pytest.fixture(scope="module") +def entities(): + """Return the US health insurance member ID entity list.""" + return ["US_HEALTH_INSURANCE_MEMBER_ID"] + + +@pytest.mark.parametrize( + "text, expected_len, expected_positions", + [ + # fmt: off + ("Member ID ABC123456789", 1, ((10, 22),)), + ("member number ZX-987654321 appears on the card", 1, ((14, 26),)), + ("Subscriber ID HPN12345A9 is active", 1, ((14, 24),)), + ("Insurance ID BCBSM1234567 was verified", 1, ((13, 25),)), + ("Health plan ID UHC-12345AB covers the visit", 1, ((15, 26),)), + ("Plan member ID AET987654 for this policy", 1, ((15, 24),)), + ("Policy ID CIGNA123456 belongs to the patient", 1, ((10, 21),)), + ("The insurance card lists subscriber number K123456789", 1, ((43, 53),)), + # Plausible pattern alone should not be detected. + ("ABC123456789", 0, ()), + ("Please store HPN12345A9 in the table", 0, ()), + # Similar-looking IDs in non-healthcare contexts should not be detected. + ("Order number ABC123456789 shipped yesterday", 0, ()), + ("Tracking number ZX-987654321 is in transit", 0, ()), + ("Case number HPN12345A9 is pending review", 0, ()), + ("Claim number BCBSM1234567 was denied", 0, ()), + # Broad generic numeric IDs are intentionally not matched. + ("Member ID 1234567890", 0, ()), + # Too short to be a plausible member ID. + ("Subscriber ID A123", 0, ()), + # fmt: on + ], +) +def test_when_us_health_insurance_member_id_in_text_then_detected_only_with_context( + text, expected_len, expected_positions, recognizer, entities +): + """Test that plausible member IDs are detected only with insurance context.""" + results = recognizer.analyze(text, entities) + results = sorted(results, key=lambda x: x.start) + assert len(results) == expected_len + for res, (st_pos, fn_pos) in zip(results, expected_positions): + assert_result(res, entities[0], st_pos, fn_pos, 0.3) + + +def test_us_health_insurance_member_id_recognizer_supported_entity(recognizer): + """Test that recognizer supports the correct entity.""" + assert recognizer.supported_entities == ["US_HEALTH_INSURANCE_MEMBER_ID"] + + +def test_us_health_insurance_member_id_recognizer_supported_language(recognizer): + """Test that recognizer supports English by default.""" + assert recognizer.supported_language == "en" + + +def test_us_health_insurance_member_id_recognizer_context_words(recognizer): + """Test that recognizer has appropriate health insurance context words.""" + expected_context = [ + "member id", + "member number", + "subscriber id", + "subscriber number", + "insurance id", + "health plan id", + "plan member id", + "policy id", + "policy number", + "health insurance", + "insurance member", + "insurance card", + ] + assert recognizer.context == expected_context diff --git a/presidio-analyzer/tests/test_us_healthcare_admin_recognizers.py b/presidio-analyzer/tests/test_us_healthcare_admin_recognizers.py new file mode 100644 index 0000000000..1d89086824 --- /dev/null +++ b/presidio-analyzer/tests/test_us_healthcare_admin_recognizers.py @@ -0,0 +1,224 @@ +import pytest +from presidio_analyzer.predefined_recognizers import ( + UsClaimNumberRecognizer, + UsPrescriptionNumberRecognizer, + UsPriorAuthorizationNumberRecognizer, + UsProviderTaxIdRecognizer, + UsReferralNumberRecognizer, +) + +from tests import assert_result + + +@pytest.mark.parametrize( + "recognizer, entity, text, expected_positions", + [ + # fmt: off + ( + UsPriorAuthorizationNumberRecognizer(), + "US_PRIOR_AUTHORIZATION_NUMBER", + "Prior Authorization Number PA-987654321 approved for treatment.", + ((27, 39),), + ), + ( + UsClaimNumberRecognizer(), + "US_CLAIM_NUMBER", + "Processed healthcare claim CLM456789123 was paid.", + ((27, 39),), + ), + ( + UsPrescriptionNumberRecognizer(), + "US_PRESCRIPTION_NUMBER", + "Prescription number RX789456123 was filled by the pharmacy.", + ((20, 31),), + ), + ( + UsReferralNumberRecognizer(), + "US_REFERRAL_NUMBER", + "Infusion referral number INF2025001234 is ready for scheduling.", + ((25, 38),), + ), + ( + UsProviderTaxIdRecognizer(), + "US_PROVIDER_TAX_ID", + "Provider Tax ID 12-3456789 belongs to the billing provider.", + ((16, 26),), + ), + # fmt: on + ], +) +def test_when_us_healthcare_admin_id_has_context_then_detected( + recognizer, entity, text, expected_positions +): + """Test that healthcare administrative identifiers are found with context.""" + results = recognizer.analyze(text, [entity]) + results = sorted(results, key=lambda x: x.start) + assert len(results) == len(expected_positions) + for result, (start, end) in zip(results, expected_positions): + assert_result(result, entity, start, end, 0.35) + + +@pytest.mark.parametrize( + "recognizer, entity, text", + [ + # fmt: off + ( + UsPriorAuthorizationNumberRecognizer(), + "US_PRIOR_AUTHORIZATION_NUMBER", + "PA-987654321", + ), + ( + UsClaimNumberRecognizer(), + "US_CLAIM_NUMBER", + "CLM456789123", + ), + ( + UsPrescriptionNumberRecognizer(), + "US_PRESCRIPTION_NUMBER", + "RX789456123", + ), + ( + UsReferralNumberRecognizer(), + "US_REFERRAL_NUMBER", + "INF2025001234", + ), + ( + UsProviderTaxIdRecognizer(), + "US_PROVIDER_TAX_ID", + "12-3456789", + ), + # fmt: on + ], +) +def test_when_us_healthcare_admin_id_lacks_context_then_not_detected( + recognizer, entity, text +): + """Test that plausible patterns alone are not detected.""" + assert recognizer.analyze(text, [entity]) == [] + + +@pytest.mark.parametrize( + "recognizer, entity, text", + [ + # fmt: off + ( + UsPriorAuthorizationNumberRecognizer(), + "US_PRIOR_AUTHORIZATION_NUMBER", + "Order number PA-987654321 is ready.", + ), + ( + UsClaimNumberRecognizer(), + "US_CLAIM_NUMBER", + "Tracking number CLM456789123 is active.", + ), + ( + UsPrescriptionNumberRecognizer(), + "US_PRESCRIPTION_NUMBER", + "Case number RX789456123 is pending.", + ), + ( + UsReferralNumberRecognizer(), + "US_REFERRAL_NUMBER", + "Claim number INF2025001234 was denied.", + ), + ( + UsProviderTaxIdRecognizer(), + "US_PROVIDER_TAX_ID", + "Invoice number 12-3456789 was posted.", + ), + # fmt: on + ], +) +def test_when_us_healthcare_admin_id_has_negative_context_then_not_detected( + recognizer, entity, text +): + """Test that similar-looking non-healthcare workflow IDs are not detected.""" + assert recognizer.analyze(text, [entity]) == [] + + +@pytest.mark.parametrize( + "recognizer, entity, expected_context", + [ + ( + UsPriorAuthorizationNumberRecognizer(), + "US_PRIOR_AUTHORIZATION_NUMBER", + [ + "prior authorization", + "prior auth", + "preauthorization", + "pre-auth", + "authorization number", + "auth number", + "approval request", + "treatment authorization", + "drug authorization", + ], + ), + ( + UsClaimNumberRecognizer(), + "US_CLAIM_NUMBER", + [ + "claim number", + "claim id", + "claim", + "healthcare claim", + "medical claim", + "billing", + "billing claim", + "claims processing", + "processed claim", + ], + ), + ( + UsPrescriptionNumberRecognizer(), + "US_PRESCRIPTION_NUMBER", + [ + "prescription number", + "prescription id", + "rx number", + "rx no", + "pharmacy", + "prescription", + "medication order", + "drug order", + ], + ), + ( + UsReferralNumberRecognizer(), + "US_REFERRAL_NUMBER", + [ + "referral number", + "referral id", + "referral", + "infusion referral", + "infusion therapy", + "specialty referral", + "specialty care", + "referring provider", + ], + ), + ( + UsProviderTaxIdRecognizer(), + "US_PROVIDER_TAX_ID", + [ + "provider tax id", + "provider tin", + "provider ein", + "tax id", + "tin", + "ein", + "healthcare organization", + "provider organization", + "billing provider", + "rendering provider", + ], + ), + ], +) +def test_us_healthcare_admin_recognizer_metadata( + recognizer, entity, expected_context +): + """Test supported entities, language, and context words.""" + assert recognizer.supported_entities == [entity] + assert recognizer.supported_language == "en" + assert recognizer.context == expected_context From c67e7a3aef76593b8b04356a3aa6a1c17022087e Mon Sep 17 00:00:00 2001 From: Bhargavi Kalicheti <102758710+bhargavikalicheti@users.noreply.github.com> Date: Tue, 21 Jul 2026 21:49:40 -0500 Subject: [PATCH 02/15] fix(analyzer): use thresholds for healthcare recognizers --- .../predefined_recognizers/__init__.py | 6 +- .../country_specific/us/__init__.py | 6 +- ...s_health_insurance_member_id_recognizer.py | 69 ++----- .../us/us_healthcare_admin_recognizers.py | 168 ++++-------------- presidio-analyzer/tests/mocks/__init__.py | 9 +- .../tests/mocks/nlp_engine_mock.py | 16 +- ...s_health_insurance_member_id_recognizer.py | 147 +++++++++------ .../test_us_healthcare_admin_recognizers.py | 160 ++++++++--------- 8 files changed, 238 insertions(+), 343 deletions(-) diff --git a/presidio-analyzer/presidio_analyzer/predefined_recognizers/__init__.py b/presidio-analyzer/presidio_analyzer/predefined_recognizers/__init__.py index d3d7453fcf..17df66be1f 100644 --- a/presidio-analyzer/presidio_analyzer/predefined_recognizers/__init__.py +++ b/presidio-analyzer/presidio_analyzer/predefined_recognizers/__init__.py @@ -130,6 +130,9 @@ from .country_specific.us.medical_license_recognizer import MedicalLicenseRecognizer from .country_specific.us.us_bank_recognizer import UsBankRecognizer from .country_specific.us.us_driver_license_recognizer import UsLicenseRecognizer +from .country_specific.us.us_health_insurance_member_id_recognizer import ( + UsHealthInsuranceMemberIdRecognizer, +) from .country_specific.us.us_healthcare_admin_recognizers import ( UsClaimNumberRecognizer, UsPrescriptionNumberRecognizer, @@ -137,9 +140,6 @@ UsProviderTaxIdRecognizer, UsReferralNumberRecognizer, ) -from .country_specific.us.us_health_insurance_member_id_recognizer import ( - UsHealthInsuranceMemberIdRecognizer, -) from .country_specific.us.us_itin_recognizer import UsItinRecognizer from .country_specific.us.us_mbi_recognizer import UsMbiRecognizer from .country_specific.us.us_npi_recognizer import UsNpiRecognizer diff --git a/presidio-analyzer/presidio_analyzer/predefined_recognizers/country_specific/us/__init__.py b/presidio-analyzer/presidio_analyzer/predefined_recognizers/country_specific/us/__init__.py index 32f0e07eba..df013a80d0 100644 --- a/presidio-analyzer/presidio_analyzer/predefined_recognizers/country_specific/us/__init__.py +++ b/presidio-analyzer/presidio_analyzer/predefined_recognizers/country_specific/us/__init__.py @@ -4,6 +4,9 @@ from .medical_license_recognizer import MedicalLicenseRecognizer from .us_bank_recognizer import UsBankRecognizer from .us_driver_license_recognizer import UsLicenseRecognizer +from .us_health_insurance_member_id_recognizer import ( + UsHealthInsuranceMemberIdRecognizer, +) from .us_healthcare_admin_recognizers import ( UsClaimNumberRecognizer, UsPrescriptionNumberRecognizer, @@ -11,9 +14,6 @@ UsProviderTaxIdRecognizer, UsReferralNumberRecognizer, ) -from .us_health_insurance_member_id_recognizer import ( - UsHealthInsuranceMemberIdRecognizer, -) from .us_itin_recognizer import UsItinRecognizer from .us_mbi_recognizer import UsMbiRecognizer from .us_npi_recognizer import UsNpiRecognizer diff --git a/presidio-analyzer/presidio_analyzer/predefined_recognizers/country_specific/us/us_health_insurance_member_id_recognizer.py b/presidio-analyzer/presidio_analyzer/predefined_recognizers/country_specific/us/us_health_insurance_member_id_recognizer.py index 3369c0a5c8..f7ea6c676f 100644 --- a/presidio-analyzer/presidio_analyzer/predefined_recognizers/country_specific/us/us_health_insurance_member_id_recognizer.py +++ b/presidio-analyzer/presidio_analyzer/predefined_recognizers/country_specific/us/us_health_insurance_member_id_recognizer.py @@ -1,8 +1,8 @@ """Recognizer for US health insurance member identifiers.""" -from typing import List, Optional +from typing import Dict, List, Optional -from presidio_analyzer import Pattern, PatternRecognizer, RecognizerResult +from presidio_analyzer import Pattern, PatternRecognizer class UsHealthInsuranceMemberIdRecognizer(PatternRecognizer): @@ -15,11 +15,10 @@ class UsHealthInsuranceMemberIdRecognizer(PatternRecognizer): - nearby healthcare/insurance context. :param patterns: List of patterns to be used by this recognizer - :param context: List of context words to require near a match + :param context: List of context words which increase detection confidence :param supported_language: Language this recognizer supports :param supported_entity: The entity this recognizer can detect - :param context_window: Number of characters before/after a match to scan - for context. + :param score_thresholds: Optional default and entity-specific score thresholds """ COUNTRY_CODE = "us" @@ -34,30 +33,10 @@ class UsHealthInsuranceMemberIdRecognizer(PatternRecognizer): ] CONTEXT = [ - "member id", - "member number", - "subscriber id", - "subscriber number", - "insurance id", - "health plan id", - "plan member id", - "policy id", - "policy number", - "health insurance", - "insurance member", - "insurance card", - ] - - NEGATIVE_CONTEXT = [ - "order number", - "order no", - "tracking number", - "tracking no", - "case number", - "case no", - "claim number", - "claim no", - "claim id", + "member", + "subscriber", + "insurance", + "policy", ] def __init__( @@ -67,9 +46,8 @@ def __init__( supported_language: str = "en", supported_entity: str = "US_HEALTH_INSURANCE_MEMBER_ID", name: Optional[str] = None, - context_window: int = 40, + score_thresholds: Optional[Dict[str, float]] = None, ): - self.context_window = context_window patterns = patterns if patterns else self.PATTERNS context = context if context else self.CONTEXT super().__init__( @@ -79,27 +57,8 @@ def __init__( supported_language=supported_language, name=name, ) - - def analyze( - self, - text: str, - entities: List[str], - nlp_artifacts=None, - regex_flags: Optional[int] = None, - ) -> List[RecognizerResult]: - """Analyze text and keep only matches with nearby positive context.""" - results = super().analyze(text, entities, nlp_artifacts, regex_flags) - return [ - result for result in results if self.__has_required_context(text, result) - ] - - def __has_required_context(self, text: str, result: RecognizerResult) -> bool: - window_text = self.__get_context_window(text, result).lower() - if any(context in window_text for context in self.NEGATIVE_CONTEXT): - return False - return any(context in window_text for context in self.context) - - def __get_context_window(self, text: str, result: RecognizerResult) -> str: - start = max(0, result.start - self.context_window) - end = min(len(text), result.end + self.context_window) - return text[start:end] + self.score_thresholds = ( + score_thresholds + if score_thresholds is not None + else {supported_entity: 0.6} + ) diff --git a/presidio-analyzer/presidio_analyzer/predefined_recognizers/country_specific/us/us_healthcare_admin_recognizers.py b/presidio-analyzer/presidio_analyzer/predefined_recognizers/country_specific/us/us_healthcare_admin_recognizers.py index 0f7b6b4000..9729880256 100644 --- a/presidio-analyzer/presidio_analyzer/predefined_recognizers/country_specific/us/us_healthcare_admin_recognizers.py +++ b/presidio-analyzer/presidio_analyzer/predefined_recognizers/country_specific/us/us_healthcare_admin_recognizers.py @@ -1,16 +1,15 @@ """Recognizers for US healthcare administrative identifiers.""" -from typing import List, Optional +from typing import Dict, List, Optional -from presidio_analyzer import Pattern, PatternRecognizer, RecognizerResult +from presidio_analyzer import Pattern, PatternRecognizer -class _ContextRequiredPatternRecognizer(PatternRecognizer): - """Pattern recognizer which keeps only matches with required context.""" +class _HealthcareAdminPatternRecognizer(PatternRecognizer): + """Pattern recognizer using context enhancement and score thresholds.""" COUNTRY_CODE = "us" - - NEGATIVE_CONTEXT: List[str] = [] + DEFAULT_SCORE_THRESHOLD = 0.6 def __init__( self, @@ -19,9 +18,8 @@ def __init__( supported_entity: str, supported_language: str = "en", name: Optional[str] = None, - context_window: int = 45, + score_thresholds: Optional[Dict[str, float]] = None, ): - self.context_window = context_window super().__init__( supported_entity=supported_entity, patterns=patterns, @@ -29,33 +27,14 @@ def __init__( supported_language=supported_language, name=name, ) - - def analyze( - self, - text: str, - entities: List[str], - nlp_artifacts=None, - regex_flags: Optional[int] = None, - ) -> List[RecognizerResult]: - """Analyze text and keep only matches with nearby positive context.""" - results = super().analyze(text, entities, nlp_artifacts, regex_flags) - return [ - result for result in results if self.__has_required_context(text, result) - ] - - def __has_required_context(self, text: str, result: RecognizerResult) -> bool: - window_text = self.__get_context_window(text, result).lower() - if any(context in window_text for context in self.NEGATIVE_CONTEXT): - return False - return any(context in window_text for context in self.context) - - def __get_context_window(self, text: str, result: RecognizerResult) -> str: - start = max(0, result.start - self.context_window) - end = min(len(text), result.end + self.context_window) - return text[start:end] + self.score_thresholds = ( + score_thresholds + if score_thresholds is not None + else {supported_entity: self.DEFAULT_SCORE_THRESHOLD} + ) -class UsPriorAuthorizationNumberRecognizer(_ContextRequiredPatternRecognizer): +class UsPriorAuthorizationNumberRecognizer(_HealthcareAdminPatternRecognizer): """Recognize US healthcare prior authorization numbers with context.""" PATTERNS = [ @@ -67,24 +46,10 @@ class UsPriorAuthorizationNumberRecognizer(_ContextRequiredPatternRecognizer): ] CONTEXT = [ - "prior authorization", - "prior auth", + "authorization", + "auth", "preauthorization", - "pre-auth", - "authorization number", - "auth number", - "approval request", - "treatment authorization", - "drug authorization", - ] - - NEGATIVE_CONTEXT = [ - "order number", - "tracking number", - "case number", - "claim number", - "claim id", - "invoice number", + "approval", ] def __init__( @@ -94,7 +59,7 @@ def __init__( supported_language: str = "en", supported_entity: str = "US_PRIOR_AUTHORIZATION_NUMBER", name: Optional[str] = None, - context_window: int = 45, + score_thresholds: Optional[Dict[str, float]] = None, ): super().__init__( patterns=patterns if patterns else self.PATTERNS, @@ -102,11 +67,11 @@ def __init__( supported_entity=supported_entity, supported_language=supported_language, name=name, - context_window=context_window, + score_thresholds=score_thresholds, ) -class UsClaimNumberRecognizer(_ContextRequiredPatternRecognizer): +class UsClaimNumberRecognizer(_HealthcareAdminPatternRecognizer): """Recognize US healthcare claim numbers with billing/claims context.""" PATTERNS = [ @@ -118,24 +83,8 @@ class UsClaimNumberRecognizer(_ContextRequiredPatternRecognizer): ] CONTEXT = [ - "claim number", - "claim id", "claim", - "healthcare claim", - "medical claim", "billing", - "billing claim", - "claims processing", - "processed claim", - ] - - NEGATIVE_CONTEXT = [ - "order number", - "tracking number", - "case number", - "referral number", - "authorization number", - "invoice number", ] def __init__( @@ -145,7 +94,7 @@ def __init__( supported_language: str = "en", supported_entity: str = "US_CLAIM_NUMBER", name: Optional[str] = None, - context_window: int = 45, + score_thresholds: Optional[Dict[str, float]] = None, ): super().__init__( patterns=patterns if patterns else self.PATTERNS, @@ -153,11 +102,11 @@ def __init__( supported_entity=supported_entity, supported_language=supported_language, name=name, - context_window=context_window, + score_thresholds=score_thresholds, ) -class UsPrescriptionNumberRecognizer(_ContextRequiredPatternRecognizer): +class UsPrescriptionNumberRecognizer(_HealthcareAdminPatternRecognizer): """Recognize US prescription numbers with pharmacy context.""" PATTERNS = [ @@ -169,23 +118,9 @@ class UsPrescriptionNumberRecognizer(_ContextRequiredPatternRecognizer): ] CONTEXT = [ - "prescription number", - "prescription id", - "rx number", - "rx no", - "pharmacy", "prescription", - "medication order", - "drug order", - ] - - NEGATIVE_CONTEXT = [ - "order number", - "tracking number", - "case number", - "claim number", - "claim id", - "invoice number", + "pharmacy", + "medication", ] def __init__( @@ -195,7 +130,7 @@ def __init__( supported_language: str = "en", supported_entity: str = "US_PRESCRIPTION_NUMBER", name: Optional[str] = None, - context_window: int = 45, + score_thresholds: Optional[Dict[str, float]] = None, ): super().__init__( patterns=patterns if patterns else self.PATTERNS, @@ -203,11 +138,11 @@ def __init__( supported_entity=supported_entity, supported_language=supported_language, name=name, - context_window=context_window, + score_thresholds=score_thresholds, ) -class UsReferralNumberRecognizer(_ContextRequiredPatternRecognizer): +class UsReferralNumberRecognizer(_HealthcareAdminPatternRecognizer): """Recognize US healthcare referral numbers with referral context.""" PATTERNS = [ @@ -219,23 +154,10 @@ class UsReferralNumberRecognizer(_ContextRequiredPatternRecognizer): ] CONTEXT = [ - "referral number", - "referral id", "referral", - "infusion referral", - "infusion therapy", - "specialty referral", - "specialty care", - "referring provider", - ] - - NEGATIVE_CONTEXT = [ - "order number", - "tracking number", - "case number", - "claim number", - "claim id", - "invoice number", + "infusion", + "specialty", + "referring", ] def __init__( @@ -245,7 +167,7 @@ def __init__( supported_language: str = "en", supported_entity: str = "US_REFERRAL_NUMBER", name: Optional[str] = None, - context_window: int = 45, + score_thresholds: Optional[Dict[str, float]] = None, ): super().__init__( patterns=patterns if patterns else self.PATTERNS, @@ -253,11 +175,11 @@ def __init__( supported_entity=supported_entity, supported_language=supported_language, name=name, - context_window=context_window, + score_thresholds=score_thresholds, ) -class UsProviderTaxIdRecognizer(_ContextRequiredPatternRecognizer): +class UsProviderTaxIdRecognizer(_HealthcareAdminPatternRecognizer): """Recognize US provider TIN/EIN values with healthcare provider context.""" PATTERNS = [ @@ -269,27 +191,7 @@ class UsProviderTaxIdRecognizer(_ContextRequiredPatternRecognizer): ] CONTEXT = [ - "provider tax id", - "provider tin", - "provider ein", - "tax id", - "tin", - "ein", - "healthcare organization", - "provider organization", - "billing provider", - "rendering provider", - ] - - NEGATIVE_CONTEXT = [ - "employee tax id", - "vendor tax id", - "company tax id", - "order number", - "tracking number", - "case number", - "claim number", - "invoice number", + "provider", ] def __init__( @@ -299,7 +201,7 @@ def __init__( supported_language: str = "en", supported_entity: str = "US_PROVIDER_TAX_ID", name: Optional[str] = None, - context_window: int = 45, + score_thresholds: Optional[Dict[str, float]] = None, ): super().__init__( patterns=patterns if patterns else self.PATTERNS, @@ -307,5 +209,5 @@ def __init__( supported_entity=supported_entity, supported_language=supported_language, name=name, - context_window=context_window, + score_thresholds=score_thresholds, ) diff --git a/presidio-analyzer/tests/mocks/__init__.py b/presidio-analyzer/tests/mocks/__init__.py index 3c5c37a7be..01f167159d 100644 --- a/presidio-analyzer/tests/mocks/__init__.py +++ b/presidio-analyzer/tests/mocks/__init__.py @@ -1,5 +1,10 @@ -from .nlp_engine_mock import NlpEngineMock from .app_tracer_mock import AppTracerMock +from .nlp_engine_mock import ContextAwareNlpEngineMock, NlpEngineMock from .recognizer_registry_mock import RecognizerRegistryMock -__all__ = ["NlpEngineMock", "AppTracerMock", "RecognizerRegistryMock"] +__all__ = [ + "NlpEngineMock", + "ContextAwareNlpEngineMock", + "AppTracerMock", + "RecognizerRegistryMock", +] diff --git a/presidio-analyzer/tests/mocks/nlp_engine_mock.py b/presidio-analyzer/tests/mocks/nlp_engine_mock.py index 5065ecb514..c9f18dd3b3 100644 --- a/presidio-analyzer/tests/mocks/nlp_engine_mock.py +++ b/presidio-analyzer/tests/mocks/nlp_engine_mock.py @@ -1,6 +1,7 @@ -from typing import Iterable, Iterator, Tuple, Dict, List +import re +from typing import Dict, Iterable, Iterator, List, Tuple -from presidio_analyzer.nlp_engine import NlpEngine, NlpArtifacts +from presidio_analyzer.nlp_engine import NlpArtifacts, NlpEngine class NlpEngineMock(NlpEngine): @@ -42,3 +43,14 @@ def get_supported_entities(self) -> List[str]: def get_supported_languages(self) -> List[str]: return ["en"] + + +class ContextAwareNlpEngineMock(NlpEngineMock): + """Create lightweight token and lemma artifacts for context unit tests.""" + + def process_text(self, text, language): + matches = list(re.finditer(r"\b[\w-]+\b", text)) + tokens = [match.group() for match in matches] + token_indices = [match.start() for match in matches] + lemmas = [token.lower() for token in tokens] + return NlpArtifacts([], tokens, token_indices, lemmas, self, language, []) diff --git a/presidio-analyzer/tests/test_us_health_insurance_member_id_recognizer.py b/presidio-analyzer/tests/test_us_health_insurance_member_id_recognizer.py index fa641bdaf5..48c5cecd9d 100644 --- a/presidio-analyzer/tests/test_us_health_insurance_member_id_recognizer.py +++ b/presidio-analyzer/tests/test_us_health_insurance_member_id_recognizer.py @@ -1,9 +1,11 @@ import pytest +from presidio_analyzer import AnalyzerEngine, RecognizerRegistry from presidio_analyzer.predefined_recognizers import ( UsHealthInsuranceMemberIdRecognizer, ) from tests import assert_result +from tests.mocks import ContextAwareNlpEngineMock @pytest.fixture(scope="module") @@ -13,73 +15,108 @@ def recognizer(): @pytest.fixture(scope="module") -def entities(): - """Return the US health insurance member ID entity list.""" - return ["US_HEALTH_INSURANCE_MEMBER_ID"] +def entity(): + """Return the US health insurance member ID entity name.""" + return "US_HEALTH_INSURANCE_MEMBER_ID" + + +def analyze_member_id(text, recognizer, entity, score_threshold=None): + """Analyze text with the member ID recognizer and its threshold.""" + registry = RecognizerRegistry() + registry.add_recognizer(recognizer) + analyzer = AnalyzerEngine(registry=registry, nlp_engine=ContextAwareNlpEngineMock()) + return analyzer.analyze( + text=text, + language="en", + entities=[entity], + score_threshold=score_threshold, + ) @pytest.mark.parametrize( - "text, expected_len, expected_positions", + "text, expected_positions", [ # fmt: off - ("Member ID ABC123456789", 1, ((10, 22),)), - ("member number ZX-987654321 appears on the card", 1, ((14, 26),)), - ("Subscriber ID HPN12345A9 is active", 1, ((14, 24),)), - ("Insurance ID BCBSM1234567 was verified", 1, ((13, 25),)), - ("Health plan ID UHC-12345AB covers the visit", 1, ((15, 26),)), - ("Plan member ID AET987654 for this policy", 1, ((15, 24),)), - ("Policy ID CIGNA123456 belongs to the patient", 1, ((10, 21),)), - ("The insurance card lists subscriber number K123456789", 1, ((43, 53),)), - # Plausible pattern alone should not be detected. - ("ABC123456789", 0, ()), - ("Please store HPN12345A9 in the table", 0, ()), - # Similar-looking IDs in non-healthcare contexts should not be detected. - ("Order number ABC123456789 shipped yesterday", 0, ()), - ("Tracking number ZX-987654321 is in transit", 0, ()), - ("Case number HPN12345A9 is pending review", 0, ()), - ("Claim number BCBSM1234567 was denied", 0, ()), - # Broad generic numeric IDs are intentionally not matched. - ("Member ID 1234567890", 0, ()), - # Too short to be a plausible member ID. - ("Subscriber ID A123", 0, ()), + ("Member ID ABC123456789", ((10, 22),)), + ("member number ZX-987654321 appears on the card", ((14, 26),)), + ("Subscriber ID HPN12345A9 is active", ((14, 24),)), + ("Insurance ID BCBSM1234567 was verified", ((13, 25),)), + ("Insurance plan ID UHC-12345AB covers the visit", ((18, 29),)), + ("Plan member ID AET987654 for this policy", ((15, 24),)), + ("Policy ID CIGNA123456 belongs to the patient", ((10, 21),)), + ("The insurance card lists subscriber number K123456789", ((43, 53),)), # fmt: on ], ) -def test_when_us_health_insurance_member_id_in_text_then_detected_only_with_context( - text, expected_len, expected_positions, recognizer, entities +def test_when_member_id_has_context_then_detected( + text, expected_positions, recognizer, entity ): - """Test that plausible member IDs are detected only with insurance context.""" - results = recognizer.analyze(text, entities) - results = sorted(results, key=lambda x: x.start) - assert len(results) == expected_len - for res, (st_pos, fn_pos) in zip(results, expected_positions): - assert_result(res, entities[0], st_pos, fn_pos, 0.3) + """Test context raises plausible member IDs above the threshold.""" + results = analyze_member_id(text, recognizer, entity) + results = sorted(results, key=lambda result: result.start) + assert len(results) == len(expected_positions) + for result, (start, end) in zip(results, expected_positions): + assert_result(result, entity, start, end, 0.6499999999999999) -def test_us_health_insurance_member_id_recognizer_supported_entity(recognizer): - """Test that recognizer supports the correct entity.""" - assert recognizer.supported_entities == ["US_HEALTH_INSURANCE_MEMBER_ID"] +@pytest.mark.parametrize( + "text", + [ + "ABC123456789", + "Please store HPN12345A9 in the table", + "Order number ABC123456789 shipped yesterday", + "Tracking number ZX-987654321 is in transit", + "Case number HPN12345A9 is pending review", + "Claim number BCBSM1234567 was denied", + ], +) +def test_when_member_id_lacks_insurance_context_then_below_threshold( + text, recognizer, entity +): + """Test pattern-only and unrelated-context values are suppressed.""" + assert analyze_member_id(text, recognizer, entity) == [] -def test_us_health_insurance_member_id_recognizer_supported_language(recognizer): - """Test that recognizer supports English by default.""" - assert recognizer.supported_language == "en" +@pytest.mark.parametrize( + "text", + [ + "Member ID 1234567890", + "Subscriber ID A123", + ], +) +def test_when_member_id_pattern_is_implausible_then_not_detected( + text, recognizer, entity +): + """Test numeric-only and short values do not match the base pattern.""" + assert ( + analyze_member_id( + text, + recognizer, + entity, + score_threshold=0, + ) + == [] + ) -def test_us_health_insurance_member_id_recognizer_context_words(recognizer): - """Test that recognizer has appropriate health insurance context words.""" - expected_context = [ - "member id", - "member number", - "subscriber id", - "subscriber number", - "insurance id", - "health plan id", - "plan member id", - "policy id", - "policy number", - "health insurance", - "insurance member", - "insurance card", - ] - assert recognizer.context == expected_context +def test_explicit_request_threshold_can_return_pattern_only_member_id( + recognizer, entity +): + """Test structured callers can opt into the raw pattern match.""" + text = "ABC123456789" + results = analyze_member_id( + text, + recognizer, + entity, + score_threshold=0, + ) + assert len(results) == 1 + assert_result(results[0], entity, 0, len(text), 0.3) + + +def test_us_health_insurance_member_id_recognizer_metadata(recognizer, entity): + """Test entity metadata, context, and recognizer threshold.""" + assert recognizer.supported_entities == [entity] + assert recognizer.supported_language == "en" + assert recognizer.context == ["member", "subscriber", "insurance", "policy"] + assert recognizer.score_thresholds == {entity: 0.6} diff --git a/presidio-analyzer/tests/test_us_healthcare_admin_recognizers.py b/presidio-analyzer/tests/test_us_healthcare_admin_recognizers.py index 1d89086824..b0bdc63157 100644 --- a/presidio-analyzer/tests/test_us_healthcare_admin_recognizers.py +++ b/presidio-analyzer/tests/test_us_healthcare_admin_recognizers.py @@ -1,4 +1,5 @@ import pytest +from presidio_analyzer import AnalyzerEngine, RecognizerRegistry from presidio_analyzer.predefined_recognizers import ( UsClaimNumberRecognizer, UsPrescriptionNumberRecognizer, @@ -8,6 +9,20 @@ ) from tests import assert_result +from tests.mocks import ContextAwareNlpEngineMock + + +def analyze_with_recognizer(text, entity, recognizer, score_threshold=None): + """Analyze text with one recognizer and its configured score threshold.""" + registry = RecognizerRegistry() + registry.add_recognizer(recognizer) + analyzer = AnalyzerEngine(registry=registry, nlp_engine=ContextAwareNlpEngineMock()) + return analyzer.analyze( + text=text, + language="en", + entities=[entity], + score_threshold=score_threshold, + ) @pytest.mark.parametrize( @@ -17,8 +32,8 @@ ( UsPriorAuthorizationNumberRecognizer(), "US_PRIOR_AUTHORIZATION_NUMBER", - "Prior Authorization Number PA-987654321 approved for treatment.", - ((27, 39),), + "Prior authorization PA-987654321 approved for treatment.", + ((20, 32),), ), ( UsClaimNumberRecognizer(), @@ -50,12 +65,12 @@ def test_when_us_healthcare_admin_id_has_context_then_detected( recognizer, entity, text, expected_positions ): - """Test that healthcare administrative identifiers are found with context.""" - results = recognizer.analyze(text, [entity]) - results = sorted(results, key=lambda x: x.start) + """Test context enhancement raises matches above the recognizer threshold.""" + results = analyze_with_recognizer(text, entity, recognizer) + results = sorted(results, key=lambda result: result.start) assert len(results) == len(expected_positions) for result, (start, end) in zip(results, expected_positions): - assert_result(result, entity, start, end, 0.35) + assert_result(result, entity, start, end, 0.7) @pytest.mark.parametrize( @@ -67,34 +82,18 @@ def test_when_us_healthcare_admin_id_has_context_then_detected( "US_PRIOR_AUTHORIZATION_NUMBER", "PA-987654321", ), - ( - UsClaimNumberRecognizer(), - "US_CLAIM_NUMBER", - "CLM456789123", - ), - ( - UsPrescriptionNumberRecognizer(), - "US_PRESCRIPTION_NUMBER", - "RX789456123", - ), - ( - UsReferralNumberRecognizer(), - "US_REFERRAL_NUMBER", - "INF2025001234", - ), - ( - UsProviderTaxIdRecognizer(), - "US_PROVIDER_TAX_ID", - "12-3456789", - ), + (UsClaimNumberRecognizer(), "US_CLAIM_NUMBER", "CLM456789123"), + (UsPrescriptionNumberRecognizer(), "US_PRESCRIPTION_NUMBER", "RX789456123"), + (UsReferralNumberRecognizer(), "US_REFERRAL_NUMBER", "INF2025001234"), + (UsProviderTaxIdRecognizer(), "US_PROVIDER_TAX_ID", "12-3456789"), # fmt: on ], ) -def test_when_us_healthcare_admin_id_lacks_context_then_not_detected( +def test_when_us_healthcare_admin_id_lacks_context_then_below_threshold( recognizer, entity, text ): - """Test that plausible patterns alone are not detected.""" - assert recognizer.analyze(text, [entity]) == [] + """Test normal analyzer calls suppress pattern-only matches.""" + assert analyze_with_recognizer(text, entity, recognizer) == [] @pytest.mark.parametrize( @@ -129,11 +128,42 @@ def test_when_us_healthcare_admin_id_lacks_context_then_not_detected( # fmt: on ], ) -def test_when_us_healthcare_admin_id_has_negative_context_then_not_detected( +def test_when_us_healthcare_admin_id_has_unrelated_context_then_not_detected( recognizer, entity, text ): - """Test that similar-looking non-healthcare workflow IDs are not detected.""" - assert recognizer.analyze(text, [entity]) == [] + """Test similar-looking workflow IDs stay below the threshold.""" + assert analyze_with_recognizer(text, entity, recognizer) == [] + + +@pytest.mark.parametrize( + "recognizer, entity, text, expected_score", + [ + # fmt: off + ( + UsPriorAuthorizationNumberRecognizer(), + "US_PRIOR_AUTHORIZATION_NUMBER", + "PA-987654321", + 0.35, + ), + (UsClaimNumberRecognizer(), "US_CLAIM_NUMBER", "CLM456789123", 0.35), + ( + UsPrescriptionNumberRecognizer(), + "US_PRESCRIPTION_NUMBER", + "RX789456123", + 0.35, + ), + (UsReferralNumberRecognizer(), "US_REFERRAL_NUMBER", "INF2025001234", 0.35), + (UsProviderTaxIdRecognizer(), "US_PROVIDER_TAX_ID", "12-3456789", 0.35), + # fmt: on + ], +) +def test_explicit_request_threshold_can_return_pattern_only_matches( + recognizer, entity, text, expected_score +): + """Test callers can opt into raw pattern matches for structured analysis.""" + results = analyze_with_recognizer(text, entity, recognizer, score_threshold=0) + assert len(results) == 1 + assert_result(results[0], entity, 0, len(text), expected_score) @pytest.mark.parametrize( @@ -142,83 +172,33 @@ def test_when_us_healthcare_admin_id_has_negative_context_then_not_detected( ( UsPriorAuthorizationNumberRecognizer(), "US_PRIOR_AUTHORIZATION_NUMBER", - [ - "prior authorization", - "prior auth", - "preauthorization", - "pre-auth", - "authorization number", - "auth number", - "approval request", - "treatment authorization", - "drug authorization", - ], + ["authorization", "auth", "preauthorization", "approval"], ), ( UsClaimNumberRecognizer(), "US_CLAIM_NUMBER", - [ - "claim number", - "claim id", - "claim", - "healthcare claim", - "medical claim", - "billing", - "billing claim", - "claims processing", - "processed claim", - ], + ["claim", "billing"], ), ( UsPrescriptionNumberRecognizer(), "US_PRESCRIPTION_NUMBER", - [ - "prescription number", - "prescription id", - "rx number", - "rx no", - "pharmacy", - "prescription", - "medication order", - "drug order", - ], + ["prescription", "pharmacy", "medication"], ), ( UsReferralNumberRecognizer(), "US_REFERRAL_NUMBER", - [ - "referral number", - "referral id", - "referral", - "infusion referral", - "infusion therapy", - "specialty referral", - "specialty care", - "referring provider", - ], + ["referral", "infusion", "specialty", "referring"], ), ( UsProviderTaxIdRecognizer(), "US_PROVIDER_TAX_ID", - [ - "provider tax id", - "provider tin", - "provider ein", - "tax id", - "tin", - "ein", - "healthcare organization", - "provider organization", - "billing provider", - "rendering provider", - ], + ["provider"], ), ], ) -def test_us_healthcare_admin_recognizer_metadata( - recognizer, entity, expected_context -): - """Test supported entities, language, and context words.""" +def test_us_healthcare_admin_recognizer_metadata(recognizer, entity, expected_context): + """Test entity metadata, context, and recognizer threshold.""" assert recognizer.supported_entities == [entity] assert recognizer.supported_language == "en" assert recognizer.context == expected_context + assert recognizer.score_thresholds == {entity: 0.6} From 589c04307f43ca25a656ff11a3943c0ddf7e2f8e Mon Sep 17 00:00:00 2001 From: Bhargavi Kalicheti <102758710+bhargavikalicheti@users.noreply.github.com> Date: Tue, 21 Jul 2026 22:02:38 -0500 Subject: [PATCH 03/15] docs(analyzer): cite healthcare identifier sources --- ...s_health_insurance_member_id_recognizer.py | 6 +++ .../us/us_healthcare_admin_recognizers.py | 48 +++++++++++++++++-- 2 files changed, 49 insertions(+), 5 deletions(-) diff --git a/presidio-analyzer/presidio_analyzer/predefined_recognizers/country_specific/us/us_health_insurance_member_id_recognizer.py b/presidio-analyzer/presidio_analyzer/predefined_recognizers/country_specific/us/us_health_insurance_member_id_recognizer.py index f7ea6c676f..92ff783274 100644 --- a/presidio-analyzer/presidio_analyzer/predefined_recognizers/country_specific/us/us_health_insurance_member_id_recognizer.py +++ b/presidio-analyzer/presidio_analyzer/predefined_recognizers/country_specific/us/us_health_insurance_member_id_recognizer.py @@ -14,6 +14,12 @@ class UsHealthInsuranceMemberIdRecognizer(PatternRecognizer): - a plausible alphanumeric member ID pattern, and - nearby healthcare/insurance context. + CMS consumer guidance illustrates that insurance cards carry payer-defined + member numbers. The default regex is therefore a conservative heuristic and + can be replaced through the ``patterns`` constructor argument. + + Reference: https://www.cms.gov/files/document/2020-c2c-how-use-health-coverage-slide-deck.pdf + :param patterns: List of patterns to be used by this recognizer :param context: List of context words which increase detection confidence :param supported_language: Language this recognizer supports diff --git a/presidio-analyzer/presidio_analyzer/predefined_recognizers/country_specific/us/us_healthcare_admin_recognizers.py b/presidio-analyzer/presidio_analyzer/predefined_recognizers/country_specific/us/us_healthcare_admin_recognizers.py index 9729880256..1ec8708686 100644 --- a/presidio-analyzer/presidio_analyzer/predefined_recognizers/country_specific/us/us_healthcare_admin_recognizers.py +++ b/presidio-analyzer/presidio_analyzer/predefined_recognizers/country_specific/us/us_healthcare_admin_recognizers.py @@ -35,7 +35,14 @@ def __init__( class UsPriorAuthorizationNumberRecognizer(_HealthcareAdminPatternRecognizer): - """Recognize US healthcare prior authorization numbers with context.""" + """Recognize US healthcare prior authorization numbers with context. + + CMS identifies prior authorization and referral numbers as payer-assigned + values. There is no universal US syntax; the default pattern is a + conservative heuristic for numeric identifiers carrying a ``PA`` prefix. + + Reference: https://www.cms.gov/outreach-and-education/mln/wbt/mln4462429-mln-wbt-1500/1500/lesson04/18/index.html + """ PATTERNS = [ Pattern( @@ -72,7 +79,15 @@ def __init__( class UsClaimNumberRecognizer(_HealthcareAdminPatternRecognizer): - """Recognize US healthcare claim numbers with billing/claims context.""" + """Recognize US healthcare claim numbers with billing/claims context. + + CMS describes a claim number as the reference number shown on an + explanation of benefits, but does not prescribe a universal syntax. The + default pattern is a conservative heuristic for numeric identifiers carrying + a ``CLM`` prefix. + + Reference: https://www.cms.gov/medical-bill-rights/help/guides/explanation-of-benefits + """ PATTERNS = [ Pattern( @@ -107,7 +122,15 @@ def __init__( class UsPrescriptionNumberRecognizer(_HealthcareAdminPatternRecognizer): - """Recognize US prescription numbers with pharmacy context.""" + """Recognize US prescription numbers with pharmacy context. + + CMS defines the prescription/service reference number as a pharmacy-assigned + alphanumeric value. Because pharmacies assign these values and there is no + universal syntax, the default pattern conservatively requires an ``RX`` + prefix. + + Reference: https://www.cms.gov/files/document/cms-medicare-part-d-340b-repository-companion-guide-v-1.pdf + """ PATTERNS = [ Pattern( @@ -143,7 +166,14 @@ def __init__( class UsReferralNumberRecognizer(_HealthcareAdminPatternRecognizer): - """Recognize US healthcare referral numbers with referral context.""" + """Recognize US healthcare referral numbers with referral context. + + CMS documents referral numbers as payer-assigned values reported in the same + CMS-1500 field as prior authorization numbers. There is no universal syntax; + the default pattern is a conservative ``REF`` or ``INF`` prefix heuristic. + + Reference: https://www.cms.gov/outreach-and-education/mln/wbt/mln4462429-mln-wbt-1500/1500/lesson04/18/index.html + """ PATTERNS = [ Pattern( @@ -180,7 +210,15 @@ def __init__( class UsProviderTaxIdRecognizer(_HealthcareAdminPatternRecognizer): - """Recognize US provider TIN/EIN values with healthcare provider context.""" + """Recognize US provider TIN/EIN values with healthcare provider context. + + CMS uses a provider's EIN or SSN as the billing provider tax ID. This + recognizer intentionally matches only the IRS-defined EIN format + ``XX-XXXXXXX`` to avoid treating SSNs as provider organization IDs. + + CMS reference: https://www.cms.gov/outreach-and-education/mln/wbt/mln4462429-mln-wbt-1500/1500/lesson04/12/index.html + IRS format reference: https://www.irs.gov/instructions/iss4 + """ PATTERNS = [ Pattern( From cef2f7780c7eadfbc51b41ee051140748a48a82e Mon Sep 17 00:00:00 2001 From: Omri Mendels Date: Tue, 4 Aug 2026 11:39:48 +0300 Subject: [PATCH 04/15] Update CHANGELOG with new recognizers and features Added various disabled-by-default recognizers for US and South African IDs, including health insurance member IDs, claim numbers, and UUID detection. Introduced NoOpNlpEngine for standalone recognizers. --- CHANGELOG.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fed51c23fa..dd9e50be4a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,8 +8,6 @@ All notable changes to this project will be documented in this file. #### Added - Added a disabled-by-default US health insurance member ID (`US_HEALTH_INSURANCE_MEMBER_ID`) recognizer requiring healthcare or insurance context. - Added disabled-by-default US healthcare administrative ID recognizers for claim numbers, prior authorization numbers, prescription numbers, provider tax IDs, and referral numbers. -- Added a 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. -- Added South African recognizers for `ZA_PASSPORT`, `ZA_INCOME_TAX_NUMBER`, `ZA_DRIVER_LICENSE`, `ZA_VAT_NUMBER`, `ZA_COMPANY_REGISTRATION`, `ZA_TRAFFIC_REGISTER_NUMBER`, `ZA_LICENSE_PLATE`, `ZA_MOBILE_NUMBER`, and `ZA_TELEPHONE_NUMBER`. All disabled by default. - Added `UuidRecognizer` (generic, entity type `UUID`) to detect UUIDs in the standard 8-4-4-4-12 hyphenated hexadecimal format, covering RFC 4122 versions 1-5 and RFC 9562 versions 6-8. Validates version and variant nibbles and filters the nil UUID to reduce false positives. - 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. - South African recognizers for `ZA_PASSPORT`, `ZA_INCOME_TAX_NUMBER`, `ZA_DRIVER_LICENSE`, `ZA_VAT_NUMBER`, `ZA_COMPANY_REGISTRATION`, `ZA_TRAFFIC_REGISTER_NUMBER`, `ZA_LICENSE_PLATE`, `ZA_MOBILE_NUMBER`, and `ZA_TELEPHONE_NUMBER`. All disabled by default. From d3ae87c55ff4fbd1e2d7faaa4d912e0a2f288ccf Mon Sep 17 00:00:00 2001 From: Bhargavi Kalicheti <102758710+bhargavikalicheti@users.noreply.github.com> Date: Sun, 9 Aug 2026 21:02:32 -0500 Subject: [PATCH 05/15] docs(analyzer): clarify member ID references --- .../us/us_health_insurance_member_id_recognizer.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/presidio-analyzer/presidio_analyzer/predefined_recognizers/country_specific/us/us_health_insurance_member_id_recognizer.py b/presidio-analyzer/presidio_analyzer/predefined_recognizers/country_specific/us/us_health_insurance_member_id_recognizer.py index 92ff783274..f9a50ee5de 100644 --- a/presidio-analyzer/presidio_analyzer/predefined_recognizers/country_specific/us/us_health_insurance_member_id_recognizer.py +++ b/presidio-analyzer/presidio_analyzer/predefined_recognizers/country_specific/us/us_health_insurance_member_id_recognizer.py @@ -14,11 +14,14 @@ class UsHealthInsuranceMemberIdRecognizer(PatternRecognizer): - a plausible alphanumeric member ID pattern, and - nearby healthcare/insurance context. - CMS consumer guidance illustrates that insurance cards carry payer-defined - member numbers. The default regex is therefore a conservative heuristic and - can be replaced through the ``patterns`` constructor argument. + CMS consumer guidance explicitly labels the payer-assigned member number on + a sample insurance card. Medicaid T-MSIS defines MEMBER-ID as the value shown + on the insurance carrier's card and permits up to 20 characters. These + sources establish the identifier and upper bound, not a universal syntax; + the default regex is therefore a conservative, replaceable heuristic. - Reference: https://www.cms.gov/files/document/2020-c2c-how-use-health-coverage-slide-deck.pdf + CMS card reference: https://www.cms.gov/files/document/11818-sample-insurance-card-english.pdf + Medicaid data reference: https://www.medicaid.gov/tmsis/dataguide/v4/data-elements/tpl003036/ :param patterns: List of patterns to be used by this recognizer :param context: List of context words which increase detection confidence From 522e3ec5e444e81dafddc5dc4ae30aba19ecb512 Mon Sep 17 00:00:00 2001 From: Bhargavi Kalicheti <102758710+bhargavikalicheti@users.noreply.github.com> Date: Mon, 10 Aug 2026 21:31:07 -0500 Subject: [PATCH 06/15] fix(analyzer): lower member ID base confidence --- ...us_health_insurance_member_id_recognizer.py | 8 +++++--- ...us_health_insurance_member_id_recognizer.py | 18 +++++++++++++++--- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/presidio-analyzer/presidio_analyzer/predefined_recognizers/country_specific/us/us_health_insurance_member_id_recognizer.py b/presidio-analyzer/presidio_analyzer/predefined_recognizers/country_specific/us/us_health_insurance_member_id_recognizer.py index f9a50ee5de..0a8e43993f 100644 --- a/presidio-analyzer/presidio_analyzer/predefined_recognizers/country_specific/us/us_health_insurance_member_id_recognizer.py +++ b/presidio-analyzer/presidio_analyzer/predefined_recognizers/country_specific/us/us_health_insurance_member_id_recognizer.py @@ -19,6 +19,8 @@ class UsHealthInsuranceMemberIdRecognizer(PatternRecognizer): on the insurance carrier's card and permits up to 20 characters. These sources establish the identifier and upper bound, not a universal syntax; the default regex is therefore a conservative, replaceable heuristic. + Presidio applies ``re.IGNORECASE`` through its default global regex flags, + so the uppercase character classes also match lowercase and mixed-case IDs. CMS card reference: https://www.cms.gov/files/document/11818-sample-insurance-card-english.pdf Medicaid data reference: https://www.medicaid.gov/tmsis/dataguide/v4/data-elements/tpl003036/ @@ -34,10 +36,10 @@ class UsHealthInsuranceMemberIdRecognizer(PatternRecognizer): PATTERNS = [ Pattern( - "Health insurance member ID (alphanumeric)", + "Health insurance member ID (weak)", r"\b(?=[A-Z0-9-]{6,20}\b)(?=[A-Z0-9-]*[A-Z])" r"(?=[A-Z0-9-]*\d)[A-Z]{1,5}-?[A-Z0-9]{5,14}\b", - 0.3, + 0.1, ), ] @@ -69,5 +71,5 @@ def __init__( self.score_thresholds = ( score_thresholds if score_thresholds is not None - else {supported_entity: 0.6} + else {supported_entity: 0.4} ) diff --git a/presidio-analyzer/tests/test_us_health_insurance_member_id_recognizer.py b/presidio-analyzer/tests/test_us_health_insurance_member_id_recognizer.py index 48c5cecd9d..56a5d6ba0e 100644 --- a/presidio-analyzer/tests/test_us_health_insurance_member_id_recognizer.py +++ b/presidio-analyzer/tests/test_us_health_insurance_member_id_recognizer.py @@ -56,7 +56,10 @@ def test_when_member_id_has_context_then_detected( results = sorted(results, key=lambda result: result.start) assert len(results) == len(expected_positions) for result, (start, end) in zip(results, expected_positions): - assert_result(result, entity, start, end, 0.6499999999999999) + assert result.entity_type == entity + assert result.start == start + assert result.end == end + assert result.score == pytest.approx(0.45) @pytest.mark.parametrize( @@ -68,6 +71,13 @@ def test_when_member_id_has_context_then_detected( "Tracking number ZX-987654321 is in transit", "Case number HPN12345A9 is pending review", "Claim number BCBSM1234567 was denied", + "covid19", + "sha256", + "iphone15pro", + "rfc2119", + "gpt4turbo", + "ICD10CM123", + "ABC-1234567", ], ) def test_when_member_id_lacks_insurance_context_then_below_threshold( @@ -111,7 +121,7 @@ def test_explicit_request_threshold_can_return_pattern_only_member_id( score_threshold=0, ) assert len(results) == 1 - assert_result(results[0], entity, 0, len(text), 0.3) + assert_result(results[0], entity, 0, len(text), 0.1) def test_us_health_insurance_member_id_recognizer_metadata(recognizer, entity): @@ -119,4 +129,6 @@ def test_us_health_insurance_member_id_recognizer_metadata(recognizer, entity): assert recognizer.supported_entities == [entity] assert recognizer.supported_language == "en" assert recognizer.context == ["member", "subscriber", "insurance", "policy"] - assert recognizer.score_thresholds == {entity: 0.6} + assert recognizer.patterns[0].name == "Health insurance member ID (weak)" + assert recognizer.patterns[0].score == 0.1 + assert recognizer.score_thresholds == {entity: 0.4} From 7ebf31dff15720dddec171ead5e0e4f7ae50081f Mon Sep 17 00:00:00 2001 From: Bhargavi Kalicheti <102758710+bhargavikalicheti@users.noreply.github.com> Date: Mon, 10 Aug 2026 21:31:07 -0500 Subject: [PATCH 07/15] fix(analyzer): lower member ID base confidence From 2aa4fb2ca690f41cdfff39c39284eb1c7061da6f Mon Sep 17 00:00:00 2001 From: Bhargavi Kalicheti <102758710+bhargavikalicheti@users.noreply.github.com> Date: Sun, 16 Aug 2026 10:25:35 -0500 Subject: [PATCH 08/15] fix(analyzer): anchor healthcare IDs on labels --- .../us/us_healthcare_admin_recognizers.py | 71 +++++++++++---- .../test_us_healthcare_admin_recognizers.py | 89 ++++++++++++++++++- 2 files changed, 139 insertions(+), 21 deletions(-) diff --git a/presidio-analyzer/presidio_analyzer/predefined_recognizers/country_specific/us/us_healthcare_admin_recognizers.py b/presidio-analyzer/presidio_analyzer/predefined_recognizers/country_specific/us/us_healthcare_admin_recognizers.py index 1ec8708686..6469bce446 100644 --- a/presidio-analyzer/presidio_analyzer/predefined_recognizers/country_specific/us/us_healthcare_admin_recognizers.py +++ b/presidio-analyzer/presidio_analyzer/predefined_recognizers/country_specific/us/us_healthcare_admin_recognizers.py @@ -38,18 +38,27 @@ class UsPriorAuthorizationNumberRecognizer(_HealthcareAdminPatternRecognizer): """Recognize US healthcare prior authorization numbers with context. CMS identifies prior authorization and referral numbers as payer-assigned - values. There is no universal US syntax; the default pattern is a - conservative heuristic for numeric identifiers carrying a ``PA`` prefix. + values. There is no universal US syntax. The primary pattern anchors a + numeric identifier on its label, while a weak prefixed pattern supports + structured data containing values such as ``PA-987654321``. Reference: https://www.cms.gov/outreach-and-education/mln/wbt/mln4462429-mln-wbt-1500/1500/lesson04/18/index.html """ PATTERNS = [ Pattern( - "Prior authorization number", - r"\bPA-?\d{6,12}\b", + "Prior authorization number (labelled)", + r"(?<=\b(?:prior\s+authorization|prior\s+auth|preauthorization|" + r"pre-auth|authorization)(?:\s*(?:#|no\.?|number|id)\s*:?\s*|" + r"\s*:\s*|\s+))" + r"(?:PA-?)?\d{6,12}\b", 0.35, ), + Pattern( + "Prior authorization number (weak prefixed)", + r"\bPA-?\d{6,12}\b", + 0.1, + ), ] CONTEXT = [ @@ -83,18 +92,25 @@ class UsClaimNumberRecognizer(_HealthcareAdminPatternRecognizer): CMS describes a claim number as the reference number shown on an explanation of benefits, but does not prescribe a universal syntax. The - default pattern is a conservative heuristic for numeric identifiers carrying - a ``CLM`` prefix. + primary pattern anchors a numeric identifier on its claim label, while a + weak prefixed pattern supports structured data containing ``CLM`` values. Reference: https://www.cms.gov/medical-bill-rights/help/guides/explanation-of-benefits """ PATTERNS = [ Pattern( - "Claim number", - r"\bCLM-?\d{6,12}\b", + "Claim number (labelled)", + r"(?<=\b(?:claim|medical\s+claim|healthcare\s+claim)" + r"(?:\s*(?:#|no\.?|number|id)\s*:?\s*|\s*:\s*|\s+))" + r"(?:CLM-?)?\d{6,15}\b", 0.35, ), + Pattern( + "Claim number (weak prefixed)", + r"\bCLM-?\d{6,15}\b", + 0.1, + ), ] CONTEXT = [ @@ -125,19 +141,32 @@ class UsPrescriptionNumberRecognizer(_HealthcareAdminPatternRecognizer): """Recognize US prescription numbers with pharmacy context. CMS defines the prescription/service reference number as a pharmacy-assigned - alphanumeric value. Because pharmacies assign these values and there is no - universal syntax, the default pattern conservatively requires an ``RX`` - prefix. + alphanumeric value. Because there is no universal syntax, the primary + pattern anchors a numeric identifier on an ``Rx`` or ``prescription`` label. + A weak prefixed pattern remains available for structured data. Reference: https://www.cms.gov/files/document/cms-medicare-part-d-340b-repository-companion-guide-v-1.pdf """ PATTERNS = [ Pattern( - "Prescription number", - r"\bRX-?\d{6,12}\b", + "Prescription number (Rx labelled)", + r"(?<=\brx(?:\s*(?:#|no\.?|number|id)\s*:?\s*|\s*:\s*|\s+))" + r"(?:RX-?)?\d{6,12}\b", + 0.6, + ), + Pattern( + "Prescription number (labelled)", + r"(?<=\bprescription" + r"(?:\s*(?:#|no\.?|number|id)\s*:?\s*|\s*:\s*|\s+))" + r"(?:RX-?)?\d{6,12}\b", 0.35, ), + Pattern( + "Prescription number (weak prefixed)", + r"\bRX-?\d{6,12}\b", + 0.1, + ), ] CONTEXT = [ @@ -169,18 +198,26 @@ class UsReferralNumberRecognizer(_HealthcareAdminPatternRecognizer): """Recognize US healthcare referral numbers with referral context. CMS documents referral numbers as payer-assigned values reported in the same - CMS-1500 field as prior authorization numbers. There is no universal syntax; - the default pattern is a conservative ``REF`` or ``INF`` prefix heuristic. + CMS-1500 field as prior authorization numbers. There is no universal syntax. + The primary pattern anchors a numeric identifier on its referral label, and + a weak prefixed pattern supports structured ``REF`` or ``INF`` values. Reference: https://www.cms.gov/outreach-and-education/mln/wbt/mln4462429-mln-wbt-1500/1500/lesson04/18/index.html """ PATTERNS = [ Pattern( - "Referral number", - r"\b(?:REF|INF)-?\d{6,12}\b", + "Referral number (labelled)", + r"(?<=\b(?:referral|infusion\s+referral)" + r"(?:\s*(?:#|no\.?|number|id)\s*:?\s*|\s*:\s*|\s+))" + r"(?:(?:REF|INF)-?)?\d{6,12}\b", 0.35, ), + Pattern( + "Referral number (weak prefixed)", + r"\b(?:REF|INF)-?\d{6,12}\b", + 0.1, + ), ] CONTEXT = [ diff --git a/presidio-analyzer/tests/test_us_healthcare_admin_recognizers.py b/presidio-analyzer/tests/test_us_healthcare_admin_recognizers.py index b0bdc63157..5d10660024 100644 --- a/presidio-analyzer/tests/test_us_healthcare_admin_recognizers.py +++ b/presidio-analyzer/tests/test_us_healthcare_admin_recognizers.py @@ -73,6 +73,87 @@ def test_when_us_healthcare_admin_id_has_context_then_detected( assert_result(result, entity, start, end, 0.7) +@pytest.mark.parametrize( + "recognizer, entity, text, expected_value, expected_score", + [ + # fmt: off + ( + UsPriorAuthorizationNumberRecognizer(), + "US_PRIOR_AUTHORIZATION_NUMBER", + "Prior authorization number: 987654321 approved.", + "987654321", + 0.7, + ), + ( + UsClaimNumberRecognizer(), + "US_CLAIM_NUMBER", + "Claim number: 1234567890123 was paid.", + "1234567890123", + 0.7, + ), + ( + UsClaimNumberRecognizer(), + "US_CLAIM_NUMBER", + "Claim ID 123456789012345 was paid.", + "123456789012345", + 0.7, + ), + ( + UsPrescriptionNumberRecognizer(), + "US_PRESCRIPTION_NUMBER", + "Rx #1234567", + "1234567", + 0.6, + ), + ( + UsPrescriptionNumberRecognizer(), + "US_PRESCRIPTION_NUMBER", + "Prescription number: 7654321", + "7654321", + 0.7, + ), + ( + UsPrescriptionNumberRecognizer(), + "US_PRESCRIPTION_NUMBER", + "prescription 4455667", + "4455667", + 0.7, + ), + ( + UsReferralNumberRecognizer(), + "US_REFERRAL_NUMBER", + "Infusion referral number: 2025001234", + "2025001234", + 0.7, + ), + # fmt: on + ], +) +def test_when_admin_id_follows_label_then_identifier_only_is_detected( + recognizer, entity, text, expected_value, expected_score +): + """Test labels enable bare numeric IDs without entering the result span.""" + results = analyze_with_recognizer(text, entity, recognizer) + start = text.index(expected_value) + assert len(results) == 1 + assert_result( + results[0], entity, start, start + len(expected_value), expected_score + ) + + +def test_when_number_has_different_workflow_label_then_prescription_not_detected(): + """Test a claim label does not support a prescription number match.""" + recognizer = UsPrescriptionNumberRecognizer() + assert ( + analyze_with_recognizer( + "The claim 1234567 was paid", + "US_PRESCRIPTION_NUMBER", + recognizer, + ) + == [] + ) + + @pytest.mark.parametrize( "recognizer, entity, text", [ @@ -143,16 +224,16 @@ def test_when_us_healthcare_admin_id_has_unrelated_context_then_not_detected( UsPriorAuthorizationNumberRecognizer(), "US_PRIOR_AUTHORIZATION_NUMBER", "PA-987654321", - 0.35, + 0.1, ), - (UsClaimNumberRecognizer(), "US_CLAIM_NUMBER", "CLM456789123", 0.35), + (UsClaimNumberRecognizer(), "US_CLAIM_NUMBER", "CLM456789123", 0.1), ( UsPrescriptionNumberRecognizer(), "US_PRESCRIPTION_NUMBER", "RX789456123", - 0.35, + 0.1, ), - (UsReferralNumberRecognizer(), "US_REFERRAL_NUMBER", "INF2025001234", 0.35), + (UsReferralNumberRecognizer(), "US_REFERRAL_NUMBER", "INF2025001234", 0.1), (UsProviderTaxIdRecognizer(), "US_PROVIDER_TAX_ID", "12-3456789", 0.35), # fmt: on ], From 9fa1d57d5c56e6553c909e5a182d095d03a783e0 Mon Sep 17 00:00:00 2001 From: Bhargavi Kalicheti <102758710+bhargavikalicheti@users.noreply.github.com> Date: Sun, 16 Aug 2026 17:32:52 -0500 Subject: [PATCH 09/15] fix(analyzer): validate provider EIN prefixes --- .../us/us_healthcare_admin_recognizers.py | 30 +++++-- .../test_us_healthcare_admin_recognizers.py | 87 ++++++++++++++++++- 2 files changed, 109 insertions(+), 8 deletions(-) diff --git a/presidio-analyzer/presidio_analyzer/predefined_recognizers/country_specific/us/us_healthcare_admin_recognizers.py b/presidio-analyzer/presidio_analyzer/predefined_recognizers/country_specific/us/us_healthcare_admin_recognizers.py index 6469bce446..da3f53633e 100644 --- a/presidio-analyzer/presidio_analyzer/predefined_recognizers/country_specific/us/us_healthcare_admin_recognizers.py +++ b/presidio-analyzer/presidio_analyzer/predefined_recognizers/country_specific/us/us_healthcare_admin_recognizers.py @@ -250,23 +250,41 @@ class UsProviderTaxIdRecognizer(_HealthcareAdminPatternRecognizer): """Recognize US provider TIN/EIN values with healthcare provider context. CMS uses a provider's EIN or SSN as the billing provider tax ID. This - recognizer intentionally matches only the IRS-defined EIN format - ``XX-XXXXXXX`` to avoid treating SSNs as provider organization IDs. + recognizer intentionally matches only the IRS-defined EIN format and valid + two-digit EIN prefixes to avoid treating SSNs as provider organization IDs. CMS reference: https://www.cms.gov/outreach-and-education/mln/wbt/mln4462429-mln-wbt-1500/1500/lesson04/12/index.html - IRS format reference: https://www.irs.gov/instructions/iss4 + IRS prefix reference: https://www.irs.gov/businesses/small-businesses-self-employed/valid-eins """ + # The IRS prefix list excludes 00, 07-09, 17-19, 28-29, 49, 69-70, + # 78-79, 89, and 96-97. + VALID_EIN_PREFIX = ( + r"(?:0[1-6]|1[0-6]|2[0-7]|3[0-9]|4[0-8]|5[0-9]|6[0-8]|" + r"7[1-7]|8[0-8]|9[0-5]|9[89])" + ) + PATTERNS = [ Pattern( - "Provider tax ID", - r"\b\d{2}-\d{7}\b", + "Provider tax ID (labelled)", + r"(?<=\b(?:(?:(?:billing|rendering|healthcare)\s+provider|" + r"provider\s+organization|provider)\s+(?:tax\s*(?:id|number|" + r"identification\s+number)|tin|ein)|billing\s+provider)" + r"(?:\s*:\s*|\s+))" + VALID_EIN_PREFIX + r"-\d{7}\b", 0.35, ), + Pattern( + "Provider tax ID (weak valid EIN)", + r"\b" + VALID_EIN_PREFIX + r"-\d{7}\b", + 0.1, + ), ] CONTEXT = [ - "provider", + "tax", + "tin", + "ein", + "billing", ] def __init__( diff --git a/presidio-analyzer/tests/test_us_healthcare_admin_recognizers.py b/presidio-analyzer/tests/test_us_healthcare_admin_recognizers.py index 5d10660024..1efd77665a 100644 --- a/presidio-analyzer/tests/test_us_healthcare_admin_recognizers.py +++ b/presidio-analyzer/tests/test_us_healthcare_admin_recognizers.py @@ -141,6 +141,33 @@ def test_when_admin_id_follows_label_then_identifier_only_is_detected( ) +@pytest.mark.parametrize( + "text, expected_value", + [ + ("Billing provider EIN: 12-3456789", "12-3456789"), + ("Rendering provider TIN 20-1234567", "20-1234567"), + ("Healthcare provider tax number: 67-1234567", "67-1234567"), + ("Billing provider: 99-1234567", "99-1234567"), + ], +) +def test_when_provider_ein_has_provider_tax_label_then_detected(text, expected_value): + """Test valid EINs immediately following provider tax labels are detected.""" + results = analyze_with_recognizer( + text, + "US_PROVIDER_TAX_ID", + UsProviderTaxIdRecognizer(), + ) + start = text.index(expected_value) + assert len(results) == 1 + assert_result( + results[0], + "US_PROVIDER_TAX_ID", + start, + start + len(expected_value), + 0.7, + ) + + def test_when_number_has_different_workflow_label_then_prescription_not_detected(): """Test a claim label does not support a prescription number match.""" recognizer = UsPrescriptionNumberRecognizer() @@ -216,6 +243,62 @@ def test_when_us_healthcare_admin_id_has_unrelated_context_then_not_detected( assert analyze_with_recognizer(text, entity, recognizer) == [] +@pytest.mark.parametrize( + "text", + [ + "Provider phone extension 12-3456789", + "provider 00-0000000 listed", + "Employee tax ID 12-3456789", + ], +) +def test_when_ein_lacks_provider_tax_label_then_not_detected(text): + """Test generic provider or tax wording cannot promote an EIN-shaped value.""" + assert ( + analyze_with_recognizer( + text, + "US_PROVIDER_TAX_ID", + UsProviderTaxIdRecognizer(), + ) + == [] + ) + + +@pytest.mark.parametrize( + "invalid_prefix", + [ + "00", + "07", + "08", + "09", + "17", + "18", + "19", + "28", + "29", + "49", + "69", + "70", + "78", + "79", + "89", + "96", + "97", + ], +) +def test_when_provider_ein_prefix_is_not_irs_valid_then_not_detected(invalid_prefix): + """Test values outside the IRS-assigned EIN prefix set do not match.""" + text = f"Provider Tax ID {invalid_prefix}-1234567" + assert ( + analyze_with_recognizer( + text, + "US_PROVIDER_TAX_ID", + UsProviderTaxIdRecognizer(), + score_threshold=0, + ) + == [] + ) + + @pytest.mark.parametrize( "recognizer, entity, text, expected_score", [ @@ -234,7 +317,7 @@ def test_when_us_healthcare_admin_id_has_unrelated_context_then_not_detected( 0.1, ), (UsReferralNumberRecognizer(), "US_REFERRAL_NUMBER", "INF2025001234", 0.1), - (UsProviderTaxIdRecognizer(), "US_PROVIDER_TAX_ID", "12-3456789", 0.35), + (UsProviderTaxIdRecognizer(), "US_PROVIDER_TAX_ID", "12-3456789", 0.1), # fmt: on ], ) @@ -273,7 +356,7 @@ def test_explicit_request_threshold_can_return_pattern_only_matches( ( UsProviderTaxIdRecognizer(), "US_PROVIDER_TAX_ID", - ["provider"], + ["tax", "tin", "ein", "billing"], ), ], ) From 2ed83f3357743609b364d404d7bc12a6fcb0fb42 Mon Sep 17 00:00:00 2001 From: Bhargavi Kalicheti <102758710+bhargavikalicheti@users.noreply.github.com> Date: Sun, 16 Aug 2026 17:36:53 -0500 Subject: [PATCH 10/15] test(analyzer): use approximate context score --- presidio-analyzer/tests/test_context_support.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/presidio-analyzer/tests/test_context_support.py b/presidio-analyzer/tests/test_context_support.py index f3cf3b9e2a..f3f94307e4 100644 --- a/presidio-analyzer/tests/test_context_support.py +++ b/presidio-analyzer/tests/test_context_support.py @@ -166,7 +166,7 @@ def test_when_text_with_only_additional_context_lemma_based_context_enhancer_the results_with_additional_context[0].analysis_explanation.supportive_context_word == "driver" ) - assert results_with_additional_context[0].score == 0.6499999999999999 + assert results_with_additional_context[0].score == pytest.approx(0.65) def test_when_text_with_context_then_improves_score( From 8ed30dec6b7754b3e9afc6c0618856be0f9cec70 Mon Sep 17 00:00:00 2001 From: Bhargavi Kalicheti <102758710+bhargavikalicheti@users.noreply.github.com> Date: Sun, 16 Aug 2026 17:42:42 -0500 Subject: [PATCH 11/15] test(analyzer): cover healthcare ID edge cases --- ...s_health_insurance_member_id_recognizer.py | 54 ++++ .../test_us_healthcare_admin_recognizers.py | 249 ++++++++++++++++++ 2 files changed, 303 insertions(+) diff --git a/presidio-analyzer/tests/test_us_health_insurance_member_id_recognizer.py b/presidio-analyzer/tests/test_us_health_insurance_member_id_recognizer.py index 56a5d6ba0e..cd9117428f 100644 --- a/presidio-analyzer/tests/test_us_health_insurance_member_id_recognizer.py +++ b/presidio-analyzer/tests/test_us_health_insurance_member_id_recognizer.py @@ -62,6 +62,59 @@ def test_when_member_id_has_context_then_detected( assert result.score == pytest.approx(0.45) +@pytest.mark.parametrize( + "text, expected_value", + [ + ("member id abc123456", "abc123456"), + ("MeMbEr Id AbC123456", "AbC123456"), + ("Subscriber ID zx-987654321.", "zx-987654321"), + ], +) +def test_member_id_matching_is_case_insensitive_and_ignores_trailing_punctuation( + text, expected_value, recognizer, entity +): + """Test casing and punctuation do not change a plausible member ID match.""" + results = analyze_member_id(text, recognizer, entity) + start = text.index(expected_value) + assert len(results) == 1 + assert results[0].entity_type == entity + assert results[0].start == start + assert results[0].end == start + len(expected_value) + assert results[0].score == pytest.approx(0.45) + + +def test_when_text_has_multiple_member_ids_then_all_are_detected(recognizer, entity): + """Test every contextual member ID in one input is returned.""" + text = "Member ID ABC123456 and subscriber ID ZX-987654321." + expected_values = ["ABC123456", "ZX-987654321"] + results = sorted( + analyze_member_id(text, recognizer, entity), + key=lambda result: result.start, + ) + assert [text[result.start : result.end] for result in results] == expected_values + assert all(result.score == pytest.approx(0.45) for result in results) + + +@pytest.mark.parametrize( + "text, expected_value", + [ + ("Member ID A12345", "A12345"), + ("Member ID ABCDE-12345678901234", "ABCDE-12345678901234"), + ], +) +def test_member_id_minimum_and_maximum_lengths_are_detected( + text, expected_value, recognizer, entity +): + """Test the documented 6-to-20-character member ID boundaries.""" + results = analyze_member_id(text, recognizer, entity) + start = text.index(expected_value) + assert len(results) == 1 + assert results[0].entity_type == entity + assert results[0].start == start + assert results[0].end == start + len(expected_value) + assert results[0].score == pytest.approx(0.45) + + @pytest.mark.parametrize( "text", [ @@ -92,6 +145,7 @@ def test_when_member_id_lacks_insurance_context_then_below_threshold( [ "Member ID 1234567890", "Subscriber ID A123", + "Member ID ABCDE-123456789012345", ], ) def test_when_member_id_pattern_is_implausible_then_not_detected( diff --git a/presidio-analyzer/tests/test_us_healthcare_admin_recognizers.py b/presidio-analyzer/tests/test_us_healthcare_admin_recognizers.py index 1efd77665a..82a8695e34 100644 --- a/presidio-analyzer/tests/test_us_healthcare_admin_recognizers.py +++ b/presidio-analyzer/tests/test_us_healthcare_admin_recognizers.py @@ -73,6 +73,255 @@ def test_when_us_healthcare_admin_id_has_context_then_detected( assert_result(result, entity, start, end, 0.7) +@pytest.mark.parametrize( + "recognizer, entity, text, expected_value", + [ + # fmt: off + ( + UsPriorAuthorizationNumberRecognizer(), + "US_PRIOR_AUTHORIZATION_NUMBER", + "pRiOr AuThOrIzAtIoN pa-123456", + "pa-123456", + ), + ( + UsClaimNumberRecognizer(), + "US_CLAIM_NUMBER", + "cLaIm clm123456", + "clm123456", + ), + ( + UsPrescriptionNumberRecognizer(), + "US_PRESCRIPTION_NUMBER", + "pReScRiPtIoN rX123456", + "rX123456", + ), + ( + UsReferralNumberRecognizer(), + "US_REFERRAL_NUMBER", + "rEfErRaL inf123456", + "inf123456", + ), + ( + UsProviderTaxIdRecognizer(), + "US_PROVIDER_TAX_ID", + "bIlLiNg PrOvIdEr eIn: 12-3456789", + "12-3456789", + ), + # fmt: on + ], +) +def test_admin_id_matching_is_case_insensitive( + recognizer, entity, text, expected_value +): + """Test mixed-case labels and prefixes are detected.""" + results = analyze_with_recognizer(text, entity, recognizer) + start = text.index(expected_value) + assert len(results) == 1 + assert_result(results[0], entity, start, start + len(expected_value), 0.7) + + +@pytest.mark.parametrize( + "recognizer, entity, text, expected_values", + [ + # fmt: off + ( + UsPriorAuthorizationNumberRecognizer(), + "US_PRIOR_AUTHORIZATION_NUMBER", + "Prior authorization PA-123456; prior authorization PA-654321.", + ["PA-123456", "PA-654321"], + ), + ( + UsClaimNumberRecognizer(), + "US_CLAIM_NUMBER", + "Claim CLM123456 and claim CLM654321.", + ["CLM123456", "CLM654321"], + ), + ( + UsPrescriptionNumberRecognizer(), + "US_PRESCRIPTION_NUMBER", + "Prescription RX123456 and prescription RX654321.", + ["RX123456", "RX654321"], + ), + ( + UsReferralNumberRecognizer(), + "US_REFERRAL_NUMBER", + "Referral REF123456 and referral INF654321.", + ["REF123456", "INF654321"], + ), + ( + UsProviderTaxIdRecognizer(), + "US_PROVIDER_TAX_ID", + "Provider EIN 12-3456789 and provider TIN 20-1234567.", + ["12-3456789", "20-1234567"], + ), + # fmt: on + ], +) +def test_when_text_has_multiple_admin_ids_then_all_are_detected( + recognizer, entity, text, expected_values +): + """Test every contextual administrative ID in one input is returned.""" + results = sorted( + analyze_with_recognizer(text, entity, recognizer), + key=lambda result: result.start, + ) + assert [text[result.start : result.end] for result in results] == expected_values + + +@pytest.mark.parametrize( + "recognizer, entity, text, expected_value", + [ + # fmt: off + ( + UsPriorAuthorizationNumberRecognizer(), + "US_PRIOR_AUTHORIZATION_NUMBER", + "Prior authorization PA-123456.", + "PA-123456", + ), + ( + UsClaimNumberRecognizer(), + "US_CLAIM_NUMBER", + "Claim CLM123456,", + "CLM123456", + ), + ( + UsPrescriptionNumberRecognizer(), + "US_PRESCRIPTION_NUMBER", + "Prescription RX123456;", + "RX123456", + ), + ( + UsReferralNumberRecognizer(), + "US_REFERRAL_NUMBER", + "Referral REF123456.", + "REF123456", + ), + ( + UsProviderTaxIdRecognizer(), + "US_PROVIDER_TAX_ID", + "Provider EIN 12-3456789.", + "12-3456789", + ), + # fmt: on + ], +) +def test_admin_id_matching_ignores_trailing_punctuation( + recognizer, entity, text, expected_value +): + """Test trailing sentence punctuation stays outside the result span.""" + results = analyze_with_recognizer(text, entity, recognizer) + start = text.index(expected_value) + assert len(results) == 1 + assert_result(results[0], entity, start, start + len(expected_value), 0.7) + + +@pytest.mark.parametrize( + "recognizer, entity, text, expected_value", + [ + # fmt: off + ( + UsPriorAuthorizationNumberRecognizer(), + "US_PRIOR_AUTHORIZATION_NUMBER", + "Prior authorization PA-123456", + "PA-123456", + ), + ( + UsPriorAuthorizationNumberRecognizer(), + "US_PRIOR_AUTHORIZATION_NUMBER", + "Prior authorization PA-123456789012", + "PA-123456789012", + ), + ( + UsClaimNumberRecognizer(), + "US_CLAIM_NUMBER", + "Claim CLM123456", + "CLM123456", + ), + ( + UsClaimNumberRecognizer(), + "US_CLAIM_NUMBER", + "Claim CLM123456789012345", + "CLM123456789012345", + ), + ( + UsPrescriptionNumberRecognizer(), + "US_PRESCRIPTION_NUMBER", + "Prescription RX123456", + "RX123456", + ), + ( + UsPrescriptionNumberRecognizer(), + "US_PRESCRIPTION_NUMBER", + "Prescription RX123456789012", + "RX123456789012", + ), + ( + UsReferralNumberRecognizer(), + "US_REFERRAL_NUMBER", + "Referral REF123456", + "REF123456", + ), + ( + UsReferralNumberRecognizer(), + "US_REFERRAL_NUMBER", + "Referral INF123456789012", + "INF123456789012", + ), + # fmt: on + ], +) +def test_admin_id_minimum_and_maximum_lengths_are_detected( + recognizer, entity, text, expected_value +): + """Test each variable-length administrative ID at its exact boundaries.""" + results = analyze_with_recognizer(text, entity, recognizer) + start = text.index(expected_value) + assert len(results) == 1 + assert_result(results[0], entity, start, start + len(expected_value), 0.7) + + +@pytest.mark.parametrize( + "recognizer, entity, text", + [ + # fmt: off + ( + UsPriorAuthorizationNumberRecognizer(), + "US_PRIOR_AUTHORIZATION_NUMBER", + "PA-12345", + ), + ( + UsPriorAuthorizationNumberRecognizer(), + "US_PRIOR_AUTHORIZATION_NUMBER", + "PA-1234567890123", + ), + (UsClaimNumberRecognizer(), "US_CLAIM_NUMBER", "CLM12345"), + ( + UsClaimNumberRecognizer(), + "US_CLAIM_NUMBER", + "CLM1234567890123456", + ), + (UsPrescriptionNumberRecognizer(), "US_PRESCRIPTION_NUMBER", "RX12345"), + ( + UsPrescriptionNumberRecognizer(), + "US_PRESCRIPTION_NUMBER", + "RX1234567890123", + ), + (UsReferralNumberRecognizer(), "US_REFERRAL_NUMBER", "REF12345"), + ( + UsReferralNumberRecognizer(), + "US_REFERRAL_NUMBER", + "INF1234567890123", + ), + (UsProviderTaxIdRecognizer(), "US_PROVIDER_TAX_ID", "12-123456"), + (UsProviderTaxIdRecognizer(), "US_PROVIDER_TAX_ID", "12-12345678"), + # fmt: on + ], +) +def test_too_short_and_too_long_admin_ids_do_not_match(recognizer, entity, text): + """Test values one digit outside each supported length do not match.""" + assert analyze_with_recognizer(text, entity, recognizer, score_threshold=0) == [] + + @pytest.mark.parametrize( "recognizer, entity, text, expected_value, expected_score", [ From d2fb76bfe7164b40cacd64d7a1209b0e46589909 Mon Sep 17 00:00:00 2001 From: Bhargavi Kalicheti <102758710+bhargavikalicheti@users.noreply.github.com> Date: Sun, 16 Aug 2026 17:53:52 -0500 Subject: [PATCH 12/15] test(analyzer): use spaCy for healthcare context --- presidio-analyzer/tests/mocks/__init__.py | 3 +- .../tests/mocks/nlp_engine_mock.py | 12 --- ...s_health_insurance_member_id_recognizer.py | 44 ++++++----- .../test_us_healthcare_admin_recognizers.py | 75 ++++++++++++------- 4 files changed, 73 insertions(+), 61 deletions(-) diff --git a/presidio-analyzer/tests/mocks/__init__.py b/presidio-analyzer/tests/mocks/__init__.py index 01f167159d..8420442476 100644 --- a/presidio-analyzer/tests/mocks/__init__.py +++ b/presidio-analyzer/tests/mocks/__init__.py @@ -1,10 +1,9 @@ from .app_tracer_mock import AppTracerMock -from .nlp_engine_mock import ContextAwareNlpEngineMock, NlpEngineMock +from .nlp_engine_mock import NlpEngineMock from .recognizer_registry_mock import RecognizerRegistryMock __all__ = [ "NlpEngineMock", - "ContextAwareNlpEngineMock", "AppTracerMock", "RecognizerRegistryMock", ] diff --git a/presidio-analyzer/tests/mocks/nlp_engine_mock.py b/presidio-analyzer/tests/mocks/nlp_engine_mock.py index c9f18dd3b3..bf11f0e0a5 100644 --- a/presidio-analyzer/tests/mocks/nlp_engine_mock.py +++ b/presidio-analyzer/tests/mocks/nlp_engine_mock.py @@ -1,4 +1,3 @@ -import re from typing import Dict, Iterable, Iterator, List, Tuple from presidio_analyzer.nlp_engine import NlpArtifacts, NlpEngine @@ -43,14 +42,3 @@ def get_supported_entities(self) -> List[str]: def get_supported_languages(self) -> List[str]: return ["en"] - - -class ContextAwareNlpEngineMock(NlpEngineMock): - """Create lightweight token and lemma artifacts for context unit tests.""" - - def process_text(self, text, language): - matches = list(re.finditer(r"\b[\w-]+\b", text)) - tokens = [match.group() for match in matches] - token_indices = [match.start() for match in matches] - lemmas = [token.lower() for token in tokens] - return NlpArtifacts([], tokens, token_indices, lemmas, self, language, []) diff --git a/presidio-analyzer/tests/test_us_health_insurance_member_id_recognizer.py b/presidio-analyzer/tests/test_us_health_insurance_member_id_recognizer.py index cd9117428f..983f6d430c 100644 --- a/presidio-analyzer/tests/test_us_health_insurance_member_id_recognizer.py +++ b/presidio-analyzer/tests/test_us_health_insurance_member_id_recognizer.py @@ -5,7 +5,6 @@ ) from tests import assert_result -from tests.mocks import ContextAwareNlpEngineMock @pytest.fixture(scope="module") @@ -20,17 +19,22 @@ def entity(): return "US_HEALTH_INSURANCE_MEMBER_ID" -def analyze_member_id(text, recognizer, entity, score_threshold=None): - """Analyze text with the member ID recognizer and its threshold.""" - registry = RecognizerRegistry() - registry.add_recognizer(recognizer) - analyzer = AnalyzerEngine(registry=registry, nlp_engine=ContextAwareNlpEngineMock()) - return analyzer.analyze( - text=text, - language="en", - entities=[entity], - score_threshold=score_threshold, - ) +@pytest.fixture(scope="module") +def analyze_member_id(spacy_nlp_engine): + """Return a member ID analyzer using production spaCy tokenization.""" + + def analyze(text, recognizer, entity, score_threshold=None): + registry = RecognizerRegistry() + registry.add_recognizer(recognizer) + analyzer = AnalyzerEngine(registry=registry, nlp_engine=spacy_nlp_engine) + return analyzer.analyze( + text=text, + language="en", + entities=[entity], + score_threshold=score_threshold, + ) + + return analyze @pytest.mark.parametrize( @@ -49,7 +53,7 @@ def analyze_member_id(text, recognizer, entity, score_threshold=None): ], ) def test_when_member_id_has_context_then_detected( - text, expected_positions, recognizer, entity + text, expected_positions, recognizer, entity, analyze_member_id ): """Test context raises plausible member IDs above the threshold.""" results = analyze_member_id(text, recognizer, entity) @@ -71,7 +75,7 @@ def test_when_member_id_has_context_then_detected( ], ) def test_member_id_matching_is_case_insensitive_and_ignores_trailing_punctuation( - text, expected_value, recognizer, entity + text, expected_value, recognizer, entity, analyze_member_id ): """Test casing and punctuation do not change a plausible member ID match.""" results = analyze_member_id(text, recognizer, entity) @@ -83,7 +87,9 @@ def test_member_id_matching_is_case_insensitive_and_ignores_trailing_punctuation assert results[0].score == pytest.approx(0.45) -def test_when_text_has_multiple_member_ids_then_all_are_detected(recognizer, entity): +def test_when_text_has_multiple_member_ids_then_all_are_detected( + recognizer, entity, analyze_member_id +): """Test every contextual member ID in one input is returned.""" text = "Member ID ABC123456 and subscriber ID ZX-987654321." expected_values = ["ABC123456", "ZX-987654321"] @@ -103,7 +109,7 @@ def test_when_text_has_multiple_member_ids_then_all_are_detected(recognizer, ent ], ) def test_member_id_minimum_and_maximum_lengths_are_detected( - text, expected_value, recognizer, entity + text, expected_value, recognizer, entity, analyze_member_id ): """Test the documented 6-to-20-character member ID boundaries.""" results = analyze_member_id(text, recognizer, entity) @@ -134,7 +140,7 @@ def test_member_id_minimum_and_maximum_lengths_are_detected( ], ) def test_when_member_id_lacks_insurance_context_then_below_threshold( - text, recognizer, entity + text, recognizer, entity, analyze_member_id ): """Test pattern-only and unrelated-context values are suppressed.""" assert analyze_member_id(text, recognizer, entity) == [] @@ -149,7 +155,7 @@ def test_when_member_id_lacks_insurance_context_then_below_threshold( ], ) def test_when_member_id_pattern_is_implausible_then_not_detected( - text, recognizer, entity + text, recognizer, entity, analyze_member_id ): """Test numeric-only and short values do not match the base pattern.""" assert ( @@ -164,7 +170,7 @@ def test_when_member_id_pattern_is_implausible_then_not_detected( def test_explicit_request_threshold_can_return_pattern_only_member_id( - recognizer, entity + recognizer, entity, analyze_member_id ): """Test structured callers can opt into the raw pattern match.""" text = "ABC123456789" diff --git a/presidio-analyzer/tests/test_us_healthcare_admin_recognizers.py b/presidio-analyzer/tests/test_us_healthcare_admin_recognizers.py index 82a8695e34..2239361617 100644 --- a/presidio-analyzer/tests/test_us_healthcare_admin_recognizers.py +++ b/presidio-analyzer/tests/test_us_healthcare_admin_recognizers.py @@ -9,20 +9,24 @@ ) from tests import assert_result -from tests.mocks import ContextAwareNlpEngineMock - - -def analyze_with_recognizer(text, entity, recognizer, score_threshold=None): - """Analyze text with one recognizer and its configured score threshold.""" - registry = RecognizerRegistry() - registry.add_recognizer(recognizer) - analyzer = AnalyzerEngine(registry=registry, nlp_engine=ContextAwareNlpEngineMock()) - return analyzer.analyze( - text=text, - language="en", - entities=[entity], - score_threshold=score_threshold, - ) + + +@pytest.fixture(scope="module") +def analyze_with_recognizer(spacy_nlp_engine): + """Return an administrative ID analyzer using production spaCy tokenization.""" + + def analyze(text, entity, recognizer, score_threshold=None): + registry = RecognizerRegistry() + registry.add_recognizer(recognizer) + analyzer = AnalyzerEngine(registry=registry, nlp_engine=spacy_nlp_engine) + return analyzer.analyze( + text=text, + language="en", + entities=[entity], + score_threshold=score_threshold, + ) + + return analyze @pytest.mark.parametrize( @@ -63,7 +67,7 @@ def analyze_with_recognizer(text, entity, recognizer, score_threshold=None): ], ) def test_when_us_healthcare_admin_id_has_context_then_detected( - recognizer, entity, text, expected_positions + recognizer, entity, text, expected_positions, analyze_with_recognizer ): """Test context enhancement raises matches above the recognizer threshold.""" results = analyze_with_recognizer(text, entity, recognizer) @@ -111,7 +115,7 @@ def test_when_us_healthcare_admin_id_has_context_then_detected( ], ) def test_admin_id_matching_is_case_insensitive( - recognizer, entity, text, expected_value + recognizer, entity, text, expected_value, analyze_with_recognizer ): """Test mixed-case labels and prefixes are detected.""" results = analyze_with_recognizer(text, entity, recognizer) @@ -158,7 +162,7 @@ def test_admin_id_matching_is_case_insensitive( ], ) def test_when_text_has_multiple_admin_ids_then_all_are_detected( - recognizer, entity, text, expected_values + recognizer, entity, text, expected_values, analyze_with_recognizer ): """Test every contextual administrative ID in one input is returned.""" results = sorted( @@ -206,7 +210,7 @@ def test_when_text_has_multiple_admin_ids_then_all_are_detected( ], ) def test_admin_id_matching_ignores_trailing_punctuation( - recognizer, entity, text, expected_value + recognizer, entity, text, expected_value, analyze_with_recognizer ): """Test trailing sentence punctuation stays outside the result span.""" results = analyze_with_recognizer(text, entity, recognizer) @@ -271,7 +275,7 @@ def test_admin_id_matching_ignores_trailing_punctuation( ], ) def test_admin_id_minimum_and_maximum_lengths_are_detected( - recognizer, entity, text, expected_value + recognizer, entity, text, expected_value, analyze_with_recognizer ): """Test each variable-length administrative ID at its exact boundaries.""" results = analyze_with_recognizer(text, entity, recognizer) @@ -317,7 +321,9 @@ def test_admin_id_minimum_and_maximum_lengths_are_detected( # fmt: on ], ) -def test_too_short_and_too_long_admin_ids_do_not_match(recognizer, entity, text): +def test_too_short_and_too_long_admin_ids_do_not_match( + recognizer, entity, text, analyze_with_recognizer +): """Test values one digit outside each supported length do not match.""" assert analyze_with_recognizer(text, entity, recognizer, score_threshold=0) == [] @@ -379,7 +385,12 @@ def test_too_short_and_too_long_admin_ids_do_not_match(recognizer, entity, text) ], ) def test_when_admin_id_follows_label_then_identifier_only_is_detected( - recognizer, entity, text, expected_value, expected_score + recognizer, + entity, + text, + expected_value, + expected_score, + analyze_with_recognizer, ): """Test labels enable bare numeric IDs without entering the result span.""" results = analyze_with_recognizer(text, entity, recognizer) @@ -399,7 +410,9 @@ def test_when_admin_id_follows_label_then_identifier_only_is_detected( ("Billing provider: 99-1234567", "99-1234567"), ], ) -def test_when_provider_ein_has_provider_tax_label_then_detected(text, expected_value): +def test_when_provider_ein_has_provider_tax_label_then_detected( + text, expected_value, analyze_with_recognizer +): """Test valid EINs immediately following provider tax labels are detected.""" results = analyze_with_recognizer( text, @@ -417,7 +430,9 @@ def test_when_provider_ein_has_provider_tax_label_then_detected(text, expected_v ) -def test_when_number_has_different_workflow_label_then_prescription_not_detected(): +def test_when_number_has_different_workflow_label_then_prescription_not_detected( + analyze_with_recognizer, +): """Test a claim label does not support a prescription number match.""" recognizer = UsPrescriptionNumberRecognizer() assert ( @@ -447,7 +462,7 @@ def test_when_number_has_different_workflow_label_then_prescription_not_detected ], ) def test_when_us_healthcare_admin_id_lacks_context_then_below_threshold( - recognizer, entity, text + recognizer, entity, text, analyze_with_recognizer ): """Test normal analyzer calls suppress pattern-only matches.""" assert analyze_with_recognizer(text, entity, recognizer) == [] @@ -486,7 +501,7 @@ def test_when_us_healthcare_admin_id_lacks_context_then_below_threshold( ], ) def test_when_us_healthcare_admin_id_has_unrelated_context_then_not_detected( - recognizer, entity, text + recognizer, entity, text, analyze_with_recognizer ): """Test similar-looking workflow IDs stay below the threshold.""" assert analyze_with_recognizer(text, entity, recognizer) == [] @@ -500,7 +515,9 @@ def test_when_us_healthcare_admin_id_has_unrelated_context_then_not_detected( "Employee tax ID 12-3456789", ], ) -def test_when_ein_lacks_provider_tax_label_then_not_detected(text): +def test_when_ein_lacks_provider_tax_label_then_not_detected( + text, analyze_with_recognizer +): """Test generic provider or tax wording cannot promote an EIN-shaped value.""" assert ( analyze_with_recognizer( @@ -534,7 +551,9 @@ def test_when_ein_lacks_provider_tax_label_then_not_detected(text): "97", ], ) -def test_when_provider_ein_prefix_is_not_irs_valid_then_not_detected(invalid_prefix): +def test_when_provider_ein_prefix_is_not_irs_valid_then_not_detected( + invalid_prefix, analyze_with_recognizer +): """Test values outside the IRS-assigned EIN prefix set do not match.""" text = f"Provider Tax ID {invalid_prefix}-1234567" assert ( @@ -571,7 +590,7 @@ def test_when_provider_ein_prefix_is_not_irs_valid_then_not_detected(invalid_pre ], ) def test_explicit_request_threshold_can_return_pattern_only_matches( - recognizer, entity, text, expected_score + recognizer, entity, text, expected_score, analyze_with_recognizer ): """Test callers can opt into raw pattern matches for structured analysis.""" results = analyze_with_recognizer(text, entity, recognizer, score_threshold=0) From a41f7e7bb84e91f9982aea30e4ecf7039652efce Mon Sep 17 00:00:00 2001 From: Bhargavi Kalicheti <102758710+bhargavikalicheti@users.noreply.github.com> Date: Sun, 16 Aug 2026 18:00:30 -0500 Subject: [PATCH 13/15] refactor(analyzer): flatten healthcare recognizers --- .../us/us_healthcare_admin_recognizers.py | 109 ++++++++++-------- .../test_us_healthcare_admin_recognizers.py | 9 +- 2 files changed, 68 insertions(+), 50 deletions(-) diff --git a/presidio-analyzer/presidio_analyzer/predefined_recognizers/country_specific/us/us_healthcare_admin_recognizers.py b/presidio-analyzer/presidio_analyzer/predefined_recognizers/country_specific/us/us_healthcare_admin_recognizers.py index da3f53633e..b66a4401ff 100644 --- a/presidio-analyzer/presidio_analyzer/predefined_recognizers/country_specific/us/us_healthcare_admin_recognizers.py +++ b/presidio-analyzer/presidio_analyzer/predefined_recognizers/country_specific/us/us_healthcare_admin_recognizers.py @@ -5,36 +5,7 @@ from presidio_analyzer import Pattern, PatternRecognizer -class _HealthcareAdminPatternRecognizer(PatternRecognizer): - """Pattern recognizer using context enhancement and score thresholds.""" - - COUNTRY_CODE = "us" - DEFAULT_SCORE_THRESHOLD = 0.6 - - def __init__( - self, - patterns: List[Pattern], - context: List[str], - supported_entity: str, - supported_language: str = "en", - name: Optional[str] = None, - score_thresholds: Optional[Dict[str, float]] = None, - ): - super().__init__( - supported_entity=supported_entity, - patterns=patterns, - context=context, - supported_language=supported_language, - name=name, - ) - self.score_thresholds = ( - score_thresholds - if score_thresholds is not None - else {supported_entity: self.DEFAULT_SCORE_THRESHOLD} - ) - - -class UsPriorAuthorizationNumberRecognizer(_HealthcareAdminPatternRecognizer): +class UsPriorAuthorizationNumberRecognizer(PatternRecognizer): """Recognize US healthcare prior authorization numbers with context. CMS identifies prior authorization and referral numbers as payer-assigned @@ -45,6 +16,8 @@ class UsPriorAuthorizationNumberRecognizer(_HealthcareAdminPatternRecognizer): Reference: https://www.cms.gov/outreach-and-education/mln/wbt/mln4462429-mln-wbt-1500/1500/lesson04/18/index.html """ + COUNTRY_CODE = "us" + PATTERNS = [ Pattern( "Prior authorization number (labelled)", @@ -77,17 +50,23 @@ def __init__( name: Optional[str] = None, score_thresholds: Optional[Dict[str, float]] = None, ): + patterns = patterns if patterns else self.PATTERNS + context = context if context else self.CONTEXT super().__init__( - patterns=patterns if patterns else self.PATTERNS, - context=context if context else self.CONTEXT, supported_entity=supported_entity, + patterns=patterns, + context=context, supported_language=supported_language, name=name, - score_thresholds=score_thresholds, + ) + self.score_thresholds = ( + score_thresholds + if score_thresholds is not None + else {supported_entity: 0.6} ) -class UsClaimNumberRecognizer(_HealthcareAdminPatternRecognizer): +class UsClaimNumberRecognizer(PatternRecognizer): """Recognize US healthcare claim numbers with billing/claims context. CMS describes a claim number as the reference number shown on an @@ -98,6 +77,8 @@ class UsClaimNumberRecognizer(_HealthcareAdminPatternRecognizer): Reference: https://www.cms.gov/medical-bill-rights/help/guides/explanation-of-benefits """ + COUNTRY_CODE = "us" + PATTERNS = [ Pattern( "Claim number (labelled)", @@ -127,17 +108,23 @@ def __init__( name: Optional[str] = None, score_thresholds: Optional[Dict[str, float]] = None, ): + patterns = patterns if patterns else self.PATTERNS + context = context if context else self.CONTEXT super().__init__( - patterns=patterns if patterns else self.PATTERNS, - context=context if context else self.CONTEXT, supported_entity=supported_entity, + patterns=patterns, + context=context, supported_language=supported_language, name=name, - score_thresholds=score_thresholds, + ) + self.score_thresholds = ( + score_thresholds + if score_thresholds is not None + else {supported_entity: 0.6} ) -class UsPrescriptionNumberRecognizer(_HealthcareAdminPatternRecognizer): +class UsPrescriptionNumberRecognizer(PatternRecognizer): """Recognize US prescription numbers with pharmacy context. CMS defines the prescription/service reference number as a pharmacy-assigned @@ -148,6 +135,8 @@ class UsPrescriptionNumberRecognizer(_HealthcareAdminPatternRecognizer): Reference: https://www.cms.gov/files/document/cms-medicare-part-d-340b-repository-companion-guide-v-1.pdf """ + COUNTRY_CODE = "us" + PATTERNS = [ Pattern( "Prescription number (Rx labelled)", @@ -184,17 +173,23 @@ def __init__( name: Optional[str] = None, score_thresholds: Optional[Dict[str, float]] = None, ): + patterns = patterns if patterns else self.PATTERNS + context = context if context else self.CONTEXT super().__init__( - patterns=patterns if patterns else self.PATTERNS, - context=context if context else self.CONTEXT, supported_entity=supported_entity, + patterns=patterns, + context=context, supported_language=supported_language, name=name, - score_thresholds=score_thresholds, + ) + self.score_thresholds = ( + score_thresholds + if score_thresholds is not None + else {supported_entity: 0.6} ) -class UsReferralNumberRecognizer(_HealthcareAdminPatternRecognizer): +class UsReferralNumberRecognizer(PatternRecognizer): """Recognize US healthcare referral numbers with referral context. CMS documents referral numbers as payer-assigned values reported in the same @@ -205,6 +200,8 @@ class UsReferralNumberRecognizer(_HealthcareAdminPatternRecognizer): Reference: https://www.cms.gov/outreach-and-education/mln/wbt/mln4462429-mln-wbt-1500/1500/lesson04/18/index.html """ + COUNTRY_CODE = "us" + PATTERNS = [ Pattern( "Referral number (labelled)", @@ -236,17 +233,23 @@ def __init__( name: Optional[str] = None, score_thresholds: Optional[Dict[str, float]] = None, ): + patterns = patterns if patterns else self.PATTERNS + context = context if context else self.CONTEXT super().__init__( - patterns=patterns if patterns else self.PATTERNS, - context=context if context else self.CONTEXT, supported_entity=supported_entity, + patterns=patterns, + context=context, supported_language=supported_language, name=name, - score_thresholds=score_thresholds, + ) + self.score_thresholds = ( + score_thresholds + if score_thresholds is not None + else {supported_entity: 0.6} ) -class UsProviderTaxIdRecognizer(_HealthcareAdminPatternRecognizer): +class UsProviderTaxIdRecognizer(PatternRecognizer): """Recognize US provider TIN/EIN values with healthcare provider context. CMS uses a provider's EIN or SSN as the billing provider tax ID. This @@ -257,6 +260,8 @@ class UsProviderTaxIdRecognizer(_HealthcareAdminPatternRecognizer): IRS prefix reference: https://www.irs.gov/businesses/small-businesses-self-employed/valid-eins """ + COUNTRY_CODE = "us" + # The IRS prefix list excludes 00, 07-09, 17-19, 28-29, 49, 69-70, # 78-79, 89, and 96-97. VALID_EIN_PREFIX = ( @@ -296,11 +301,17 @@ def __init__( name: Optional[str] = None, score_thresholds: Optional[Dict[str, float]] = None, ): + patterns = patterns if patterns else self.PATTERNS + context = context if context else self.CONTEXT super().__init__( - patterns=patterns if patterns else self.PATTERNS, - context=context if context else self.CONTEXT, supported_entity=supported_entity, + patterns=patterns, + context=context, supported_language=supported_language, name=name, - score_thresholds=score_thresholds, + ) + self.score_thresholds = ( + score_thresholds + if score_thresholds is not None + else {supported_entity: 0.6} ) diff --git a/presidio-analyzer/tests/test_us_healthcare_admin_recognizers.py b/presidio-analyzer/tests/test_us_healthcare_admin_recognizers.py index 2239361617..584a27f2e6 100644 --- a/presidio-analyzer/tests/test_us_healthcare_admin_recognizers.py +++ b/presidio-analyzer/tests/test_us_healthcare_admin_recognizers.py @@ -1,5 +1,5 @@ import pytest -from presidio_analyzer import AnalyzerEngine, RecognizerRegistry +from presidio_analyzer import AnalyzerEngine, PatternRecognizer, RecognizerRegistry from presidio_analyzer.predefined_recognizers import ( UsClaimNumberRecognizer, UsPrescriptionNumberRecognizer, @@ -630,7 +630,14 @@ def test_explicit_request_threshold_can_return_pattern_only_matches( ) def test_us_healthcare_admin_recognizer_metadata(recognizer, entity, expected_context): """Test entity metadata, context, and recognizer threshold.""" + custom_thresholds = {entity: 0.8} + customized_recognizer = type(recognizer)(score_thresholds=custom_thresholds) + + assert isinstance(recognizer, PatternRecognizer) + assert PatternRecognizer in type(recognizer).__bases__ + assert recognizer.COUNTRY_CODE == "us" assert recognizer.supported_entities == [entity] assert recognizer.supported_language == "en" assert recognizer.context == expected_context assert recognizer.score_thresholds == {entity: 0.6} + assert customized_recognizer.score_thresholds == custom_thresholds From f2d4dad235dee6f2e581c94341b5ca4bf2d87cc9 Mon Sep 17 00:00:00 2001 From: Bhargavi Kalicheti <102758710+bhargavikalicheti@users.noreply.github.com> Date: Sun, 16 Aug 2026 18:04:47 -0500 Subject: [PATCH 14/15] docs(analyzer): clarify healthcare detection --- docs/supported_entities.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/supported_entities.md b/docs/supported_entities.md index f61c6d2b4a..209fbc06ea 100644 --- a/docs/supported_entities.md +++ b/docs/supported_entities.md @@ -34,15 +34,15 @@ For more information, refer to the [adding new recognizers documentation](analyz |US_BANK_NUMBER|A US bank account number is between 8 to 17 digits.|Pattern match and context| |US_DRIVER_LICENSE|A US driver license according to |Pattern match and context| |US_ITIN | US Individual Taxpayer Identification Number (ITIN). Nine digits that start with a "9" and contain a "7" or "8" as the 4 digit.|Pattern match and context| -|US_CLAIM_NUMBER|A US healthcare claim identifier used in billing and claims processing.|Pattern match and required context| -|US_HEALTH_INSURANCE_MEMBER_ID|A US health insurance member or subscriber identifier printed on an insurance card. Detection requires healthcare or insurance context.|Pattern match and required context| +|US_CLAIM_NUMBER|A US healthcare claim identifier used in billing and claims processing.|Pattern match, context enhancement, and entity threshold| +|US_HEALTH_INSURANCE_MEMBER_ID|A US health insurance member or subscriber identifier printed on an insurance card. Healthcare or insurance context increases detection confidence.|Pattern match, context enhancement, and entity threshold| |US_MBI|A US Medicare Beneficiary Identifier (MBI) with 11 alphanumeric characters.|Pattern match and context| |US_NPI|A US National Provider Identifier (NPI) is a 10-digit number issued to healthcare providers by CMS under HIPAA.|Pattern match, context and checksum| |US_PASSPORT |A US passport number with 9 digits.|Pattern match and context| -|US_PRESCRIPTION_NUMBER|A US prescription or pharmacy order identifier.|Pattern match and required context| -|US_PRIOR_AUTHORIZATION_NUMBER|A US prior authorization identifier used for treatment or drug approval requests.|Pattern match and required context| -|US_PROVIDER_TAX_ID|A US provider organization tax identifier (TIN/EIN) used in healthcare billing workflows.|Pattern match and required context| -|US_REFERRAL_NUMBER|A US healthcare referral identifier, including specialty or infusion referral numbers.|Pattern match and required context| +|US_PRESCRIPTION_NUMBER|A US prescription or pharmacy order identifier.|Pattern match, context enhancement, and entity threshold| +|US_PRIOR_AUTHORIZATION_NUMBER|A US prior authorization identifier used for treatment or drug approval requests.|Pattern match, context enhancement, and entity threshold| +|US_PROVIDER_TAX_ID|A US provider organization tax identifier (TIN/EIN) used in healthcare billing workflows.|Pattern match, context enhancement, and entity threshold| +|US_REFERRAL_NUMBER|A US healthcare referral identifier, including specialty or infusion referral numbers.|Pattern match, context enhancement, and entity threshold| |US_SSN|A US Social Security Number (SSN) with 9 digits.|Pattern match and context| ### UK From b52181adfe9be04f4a8ac826e26f04e791ab6f4e Mon Sep 17 00:00:00 2001 From: Bhargavi Kalicheti <102758710+bhargavikalicheti@users.noreply.github.com> Date: Mon, 17 Aug 2026 16:00:08 -0500 Subject: [PATCH 15/15] Updating the yaml with changes that couldve lost during merge --- .../presidio_analyzer/conf/default_recognizers.yaml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/presidio-analyzer/presidio_analyzer/conf/default_recognizers.yaml b/presidio-analyzer/presidio_analyzer/conf/default_recognizers.yaml index f5c1500429..90fc678560 100644 --- a/presidio-analyzer/presidio_analyzer/conf/default_recognizers.yaml +++ b/presidio-analyzer/presidio_analyzer/conf/default_recognizers.yaml @@ -84,6 +84,13 @@ recognizers: enabled: false country_code: us + - name: AbaRoutingRecognizer + supported_languages: + - en + type: predefined + enabled: false + country_code: us + - name: UsHealthInsuranceMemberIdRecognizer supported_languages: - en