Coverage for presidio_analyzer / predefined_recognizers / country_specific / us / us_npi_recognizer.py: 100%
31 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-03-29 09:03 +0000
« prev ^ index » next coverage.py v7.13.1, created at 2026-03-29 09:03 +0000
1"""Recognizer for US National Provider Identifier (NPI)."""
3from typing import List, Optional, Tuple
5from presidio_analyzer import EntityRecognizer, Pattern, PatternRecognizer
8class UsNpiRecognizer(PatternRecognizer):
9 """Recognize US National Provider Identifier (NPI) using regex + checksum.
11 The NPI is a unique 10-digit number assigned to healthcare providers in the
12 United States by CMS (Centers for Medicare & Medicaid Services). It is a
13 HIPAA-mandated identifier that appears on insurance claims, prescriptions,
14 and provider directories.
16 Format:
17 - Exactly 10 digits
18 - Starts with 1 (Type 1, individual) or 2 (Type 2, organization)
19 - Last digit is a Luhn check digit (using "80840" prefix per CMS spec)
21 Reference: https://www.cms.gov/Regulations-and-Guidance/Administrative-Simplification/NationalProvIdentStand
23 :param patterns: List of patterns to be used by this recognizer
24 :param context: List of context words to increase confidence in detection
25 :param supported_language: Language this recognizer supports
26 :param supported_entity: The entity this recognizer can detect
27 :param replacement_pairs: List of tuples with potential replacement values
28 for different strings to be used during pattern matching.
29 This can allow a greater variety in input, for example by removing dashes or
30 spaces.
31 """
33 PATTERNS = [
34 Pattern(
35 "NPI (weak)",
36 r"\b[12]\d{9}\b",
37 0.1,
38 ),
39 Pattern(
40 "NPI (medium)",
41 r"\b[12]\d{3}[ -]\d{3}[ -]\d{3}\b",
42 0.4,
43 ),
44 ]
46 CONTEXT = [
47 "npi",
48 "national provider",
49 "provider",
50 "npi number",
51 "provider id",
52 "provider identifier",
53 "taxonomy",
54 ]
56 def __init__(
57 self,
58 patterns: Optional[List[Pattern]] = None,
59 context: Optional[List[str]] = None,
60 supported_language: str = "en",
61 supported_entity: str = "US_NPI",
62 replacement_pairs: Optional[List[Tuple[str, str]]] = None,
63 name: Optional[str] = None,
64 ):
65 self.replacement_pairs = (
66 replacement_pairs if replacement_pairs else [("-", ""), (" ", "")]
67 )
68 patterns = patterns if patterns else self.PATTERNS
69 context = context if context else self.CONTEXT
70 super().__init__(
71 supported_entity=supported_entity,
72 patterns=patterns,
73 context=context,
74 supported_language=supported_language,
75 name=name,
76 )
78 def validate_result(self, pattern_text: str) -> bool: # noqa: D102
79 sanitized_value = EntityRecognizer.sanitize_value(
80 pattern_text, self.replacement_pairs
81 )
82 return self.__npi_luhn_checksum(sanitized_value)
84 def invalidate_result(self, pattern_text: str) -> bool: # noqa: D102
85 sanitized_value = EntityRecognizer.sanitize_value(
86 pattern_text, self.replacement_pairs
87 )
88 # Reject degenerate patterns where all body digits are identical
89 # (e.g., 1111111111 or 1111111112 where the last digit is a check digit).
90 if sanitized_value:
91 body = sanitized_value[:-1] if len(sanitized_value) > 1 else sanitized_value
92 if body and len(set(body)) == 1:
93 return True
94 return False
96 @staticmethod
97 def __npi_luhn_checksum(sanitized_value: str) -> bool:
98 """Validate NPI using Luhn algorithm with "80840" prefix per CMS spec.
100 Steps:
101 1. Take the 10-digit NPI
102 2. Prepend "80840" to get a 15-digit number
103 3. Apply standard Luhn algorithm: result mod 10 should equal 0
104 """
105 prefixed = "80840" + sanitized_value
106 digits = [int(d) for d in prefixed]
108 # Standard Luhn: from rightmost digit, double every second digit
109 checksum = 0
110 for i, digit in enumerate(reversed(digits)):
111 if i % 2 == 1:
112 doubled = digit * 2
113 checksum += doubled - 9 if doubled > 9 else doubled
114 else:
115 checksum += digit
117 return checksum % 10 == 0