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