phi-gate 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- phi_gate/__init__.py +3 -0
- phi_gate/__main__.py +5 -0
- phi_gate/audit.py +330 -0
- phi_gate/cli.py +410 -0
- phi_gate/config.py +600 -0
- phi_gate/detectors.py +1086 -0
- phi_gate/globs.py +227 -0
- phi_gate/hooks.py +644 -0
- phi_gate/policy.py +97 -0
- phi_gate/py.typed +0 -0
- phi_gate/redact.py +236 -0
- phi_gate/scanner.py +776 -0
- phi_gate/shell.py +1582 -0
- phi_gate-0.2.0.dist-info/METADATA +344 -0
- phi_gate-0.2.0.dist-info/RECORD +19 -0
- phi_gate-0.2.0.dist-info/WHEEL +5 -0
- phi_gate-0.2.0.dist-info/entry_points.txt +2 -0
- phi_gate-0.2.0.dist-info/licenses/LICENSE +21 -0
- phi_gate-0.2.0.dist-info/top_level.txt +1 -0
phi_gate/detectors.py
ADDED
|
@@ -0,0 +1,1086 @@
|
|
|
1
|
+
"""Pattern-based PHI/PII detectors.
|
|
2
|
+
|
|
3
|
+
Design rule: a Finding never carries the matched value. Anything that needs
|
|
4
|
+
the value (redaction) slices it from the original text itself, so findings
|
|
5
|
+
can be printed, logged, or serialized without leaking what they found.
|
|
6
|
+
|
|
7
|
+
Each detector returns character spans. Labelled detectors (for example
|
|
8
|
+
"DOB: <date>") return the span of the value only, not the label, so that
|
|
9
|
+
redaction keeps the surrounding text readable.
|
|
10
|
+
|
|
11
|
+
Detection runs on a normalised view of the text (see `normalize`): Unicode
|
|
12
|
+
compatibility forms are folded, invisible format characters are removed, and
|
|
13
|
+
look-alike letters are mapped to ASCII, so that a zero-width space inside an
|
|
14
|
+
SSN or a Cyrillic letter inside a label cannot hide a value. Spans are mapped
|
|
15
|
+
back to the original text before they are returned.
|
|
16
|
+
|
|
17
|
+
Every pattern is written to run in linear time on hostile input: repetition
|
|
18
|
+
next to an optional separator uses possessive quantifiers or a fixed bound, so
|
|
19
|
+
a long run of spaces, digits or label words cannot make the engine backtrack.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import bisect
|
|
25
|
+
import ipaddress
|
|
26
|
+
import re
|
|
27
|
+
import unicodedata
|
|
28
|
+
from array import array
|
|
29
|
+
from collections.abc import Callable, Iterator
|
|
30
|
+
from dataclasses import dataclass
|
|
31
|
+
from typing import Final, Literal
|
|
32
|
+
|
|
33
|
+
Severity = Literal["high", "medium", "low"]
|
|
34
|
+
Group = Literal["direct", "person", "clinical", "contact", "provider", "network"]
|
|
35
|
+
|
|
36
|
+
HIGH: Final[Severity] = "high"
|
|
37
|
+
MEDIUM: Final[Severity] = "medium"
|
|
38
|
+
LOW: Final[Severity] = "low"
|
|
39
|
+
SEVERITY_RANK: Final[dict[Severity, int]] = {LOW: 0, MEDIUM: 1, HIGH: 2}
|
|
40
|
+
|
|
41
|
+
# Groups drive the record-likeness rule in policy.py.
|
|
42
|
+
DIRECT: Final[Group] = "direct" # identifies a person on its own (SSN, MRN, DOB...)
|
|
43
|
+
PERSON: Final[Group] = "person" # a person's name in a labelled field
|
|
44
|
+
CLINICAL: Final[Group] = "clinical" # diagnosis codes, dates of service
|
|
45
|
+
CONTACT: Final[Group] = "contact" # phone, email, street address
|
|
46
|
+
PROVIDER: Final[Group] = "provider" # provider identifiers (NPI, DEA): not PHI on their own
|
|
47
|
+
NETWORK: Final[Group] = "network" # IP addresses
|
|
48
|
+
|
|
49
|
+
Span = tuple[int, int]
|
|
50
|
+
Finder = Callable[[str], Iterator[Span]]
|
|
51
|
+
Validator = Callable[[re.Match[str]], bool]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True)
|
|
55
|
+
class Detector:
|
|
56
|
+
"""One kind of identifier: its category, severity, group and finder."""
|
|
57
|
+
|
|
58
|
+
name: str
|
|
59
|
+
severity: Severity
|
|
60
|
+
group: Group
|
|
61
|
+
description: str
|
|
62
|
+
find: Finder
|
|
63
|
+
# Lowercase substrings, at least one of which any match must contain. A
|
|
64
|
+
# substring test is far cheaper than a regex tried at every position, and
|
|
65
|
+
# most source files contain none of these words.
|
|
66
|
+
hints: tuple[str, ...] = ()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(frozen=True)
|
|
70
|
+
class Finding:
|
|
71
|
+
"""Where a detector matched. Never the matched value itself."""
|
|
72
|
+
|
|
73
|
+
category: str
|
|
74
|
+
severity: Severity
|
|
75
|
+
group: Group
|
|
76
|
+
start: int
|
|
77
|
+
end: int
|
|
78
|
+
line: int = 0
|
|
79
|
+
col: int = 0
|
|
80
|
+
|
|
81
|
+
def to_dict(self) -> dict[str, str | int]:
|
|
82
|
+
"""The finding as JSON-ready data, without offsets into the text."""
|
|
83
|
+
return {
|
|
84
|
+
"category": self.category,
|
|
85
|
+
"severity": self.severity,
|
|
86
|
+
"group": self.group,
|
|
87
|
+
"line": self.line,
|
|
88
|
+
"col": self.col,
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# --------------------------------------------------------------------------
|
|
93
|
+
# Normalisation
|
|
94
|
+
# --------------------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
# Letters from other scripts that render like ASCII letters. Folding them lets a
|
|
97
|
+
# label such as "SSN" written with a Cyrillic first letter still read as a label.
|
|
98
|
+
_CONFUSABLES: Final[dict[int, str]] = {
|
|
99
|
+
# Cyrillic capitals and small letters
|
|
100
|
+
0x0410: "A", 0x0412: "B", 0x0421: "C", 0x0415: "E", 0x041D: "H", 0x0406: "I",
|
|
101
|
+
0x0408: "J", 0x041A: "K", 0x041C: "M", 0x041E: "O", 0x0420: "P", 0x0405: "S",
|
|
102
|
+
0x0422: "T", 0x0425: "X", 0x0423: "Y", 0x04AE: "Y",
|
|
103
|
+
0x0430: "a", 0x0441: "c", 0x0501: "d", 0x0435: "e", 0x0456: "i", 0x0458: "j",
|
|
104
|
+
0x043E: "o", 0x0440: "p", 0x051B: "q", 0x0455: "s", 0x051D: "w", 0x0445: "x",
|
|
105
|
+
0x0443: "y",
|
|
106
|
+
# Greek capitals and small omicron
|
|
107
|
+
0x0391: "A", 0x0392: "B", 0x0395: "E", 0x0396: "Z", 0x0397: "H", 0x0399: "I",
|
|
108
|
+
0x039A: "K", 0x039C: "M", 0x039D: "N", 0x039F: "O", 0x03A1: "P", 0x03A4: "T",
|
|
109
|
+
0x03A5: "Y", 0x03A7: "X", 0x03BF: "o",
|
|
110
|
+
}
|
|
111
|
+
# Characters outside the dash punctuation category that are used as hyphens.
|
|
112
|
+
_EXTRA_DASHES: Final = frozenset("\u2212\u2043\ufe63\uff0d")
|
|
113
|
+
_NON_ASCII_RUN: Final = re.compile(r"[^\x00-\x7f]+")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _fold_char(ch: str) -> str:
|
|
117
|
+
"""The normalised form of one non-ASCII character (possibly empty)."""
|
|
118
|
+
out = []
|
|
119
|
+
for c in unicodedata.normalize("NFKC", ch):
|
|
120
|
+
if c < "\x80":
|
|
121
|
+
out.append(c)
|
|
122
|
+
continue
|
|
123
|
+
category = unicodedata.category(c)
|
|
124
|
+
if category == "Cf": # zero-width characters, soft hyphen, BOM
|
|
125
|
+
continue
|
|
126
|
+
if category == "Pd" or c in _EXTRA_DASHES:
|
|
127
|
+
out.append("-")
|
|
128
|
+
elif category == "Zs":
|
|
129
|
+
out.append(" ")
|
|
130
|
+
else:
|
|
131
|
+
out.append(_CONFUSABLES.get(ord(c), c))
|
|
132
|
+
return "".join(out)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@dataclass(frozen=True)
|
|
136
|
+
class NormalizedText:
|
|
137
|
+
"""A normalised view of a text, with a map from view offsets back to it.
|
|
138
|
+
|
|
139
|
+
`offsets[i]` is the index in the original text of the character that
|
|
140
|
+
produced view character `i`; `offsets` is None when the view is the text.
|
|
141
|
+
"""
|
|
142
|
+
|
|
143
|
+
text: str
|
|
144
|
+
offsets: array[int] | None = None
|
|
145
|
+
|
|
146
|
+
def to_original(self, start: int, end: int) -> Span:
|
|
147
|
+
"""The original span covering view span [start, end)."""
|
|
148
|
+
if self.offsets is None or start >= end:
|
|
149
|
+
return start, end
|
|
150
|
+
return self.offsets[start], self.offsets[end - 1] + 1
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def normalize(text: str) -> NormalizedText:
|
|
154
|
+
"""Fold the text so that visually equivalent spellings match the same way.
|
|
155
|
+
|
|
156
|
+
Applies NFKC per character (fullwidth letters and digits, ligatures, NBSP),
|
|
157
|
+
removes format characters (zero-width space and joiner, soft hyphen), maps
|
|
158
|
+
every Unicode dash to "-" and every space separator to " ", and folds
|
|
159
|
+
common Cyrillic and Greek look-alikes to ASCII. Line breaks are kept, so
|
|
160
|
+
line-based detectors see the same lines.
|
|
161
|
+
"""
|
|
162
|
+
if text.isascii():
|
|
163
|
+
return NormalizedText(text)
|
|
164
|
+
pieces: list[str] = []
|
|
165
|
+
offsets: array[int] = array("q")
|
|
166
|
+
cursor = 0
|
|
167
|
+
for m in _NON_ASCII_RUN.finditer(text):
|
|
168
|
+
pieces.append(text[cursor:m.start()])
|
|
169
|
+
offsets.extend(range(cursor, m.start()))
|
|
170
|
+
for i in range(m.start(), m.end()):
|
|
171
|
+
folded = _fold_char(text[i])
|
|
172
|
+
pieces.append(folded)
|
|
173
|
+
offsets.extend([i] * len(folded))
|
|
174
|
+
cursor = m.end()
|
|
175
|
+
pieces.append(text[cursor:])
|
|
176
|
+
offsets.extend(range(cursor, len(text)))
|
|
177
|
+
view = "".join(pieces)
|
|
178
|
+
if view == text:
|
|
179
|
+
return NormalizedText(text)
|
|
180
|
+
return NormalizedText(view, offsets)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
# --------------------------------------------------------------------------
|
|
184
|
+
# Validators
|
|
185
|
+
# --------------------------------------------------------------------------
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def luhn_ok(digits: str) -> bool:
|
|
189
|
+
"""Whether a digit string passes the Luhn (mod 10) check."""
|
|
190
|
+
if not digits.isdigit():
|
|
191
|
+
return False
|
|
192
|
+
total = 0
|
|
193
|
+
for i, ch in enumerate(reversed(digits)):
|
|
194
|
+
d = int(ch)
|
|
195
|
+
if i % 2 == 1:
|
|
196
|
+
d *= 2
|
|
197
|
+
if d > 9:
|
|
198
|
+
d -= 9
|
|
199
|
+
total += d
|
|
200
|
+
return total % 10 == 0
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def npi_ok(value: str) -> bool:
|
|
204
|
+
"""NPI check digit: Luhn over the number prefixed with 80840."""
|
|
205
|
+
return len(value) == 10 and value[0] in "12" and luhn_ok("80840" + value)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def dea_ok(value: str) -> bool:
|
|
209
|
+
"""Whether a DEA registration number has a valid check digit."""
|
|
210
|
+
digits = value[2:]
|
|
211
|
+
if len(digits) != 7 or not digits.isdigit():
|
|
212
|
+
return False
|
|
213
|
+
d = [int(c) for c in digits]
|
|
214
|
+
check = (d[0] + d[2] + d[4]) + 2 * (d[1] + d[3] + d[5])
|
|
215
|
+
return check % 10 == d[6]
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
# SSNs printed in documentation, advertising and training material for decades.
|
|
219
|
+
# They identify nobody and appear in ordinary code and docs, so they are treated
|
|
220
|
+
# as synthetic.
|
|
221
|
+
DUMMY_SSNS: Final = frozenset({
|
|
222
|
+
"078051120", # printed on a wallet insert sold in 1938, later voided
|
|
223
|
+
"219099999", # printed in a Social Security Administration pamphlet
|
|
224
|
+
"123456789", # the sequential example used in forms, documentation and tests
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def is_dummy_ssn(digits: str) -> bool:
|
|
229
|
+
"""Whether a nine-digit SSN is a well-known example rather than a real one:
|
|
230
|
+
a published example number, or three groups that each repeat one digit
|
|
231
|
+
(111-22-3333)."""
|
|
232
|
+
if digits in DUMMY_SSNS:
|
|
233
|
+
return True
|
|
234
|
+
return all(len(set(part)) == 1 for part in (digits[:3], digits[3:5], digits[5:]))
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def ssn_area_ok(area: str, group: str, serial: str) -> bool:
|
|
238
|
+
"""Whether an SSN could have been issued, and is not a well-known example."""
|
|
239
|
+
# 000, 666 and 900-999 are never issued. Treat them as synthetic so that
|
|
240
|
+
# test data (Synthea uses 999-xx-xxxx) passes without allowlisting.
|
|
241
|
+
if area in ("000", "666") or area[0] == "9":
|
|
242
|
+
return False
|
|
243
|
+
if group == "00" or serial == "0000":
|
|
244
|
+
return False
|
|
245
|
+
return not is_dummy_ssn(area + group + serial)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
# Publicly documented processor test card numbers: synthetic by definition.
|
|
249
|
+
TEST_CARDS: Final = frozenset({
|
|
250
|
+
"4242424242424242",
|
|
251
|
+
"4111111111111111",
|
|
252
|
+
"4012888888881881",
|
|
253
|
+
"4000056655665556",
|
|
254
|
+
"5555555555554444",
|
|
255
|
+
"5105105105105100",
|
|
256
|
+
"2223003122003222",
|
|
257
|
+
"378282246310005",
|
|
258
|
+
"371449635398431",
|
|
259
|
+
"6011111111111117",
|
|
260
|
+
"6011000990139424",
|
|
261
|
+
"3056930009020004",
|
|
262
|
+
"36227206271667",
|
|
263
|
+
"3566002020360505",
|
|
264
|
+
})
|
|
265
|
+
_CARD_PREFIX: Final = re.compile(r"^(4|5[1-5]|2[2-7]|3[47]|6011|65|3[068]|35)")
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def card_ok(raw: str) -> bool:
|
|
269
|
+
"""Whether a card-shaped number could be a real payment card: issuer
|
|
270
|
+
prefix, length, Luhn, and not a published test number or filler."""
|
|
271
|
+
digits = re.sub(r"[ -]", "", raw)
|
|
272
|
+
if not (13 <= len(digits) <= 19) or digits in TEST_CARDS:
|
|
273
|
+
return False
|
|
274
|
+
if not _CARD_PREFIX.match(digits):
|
|
275
|
+
return False
|
|
276
|
+
if len(set(digits)) <= 2: # 4444 4444 ... style filler
|
|
277
|
+
return False
|
|
278
|
+
return luhn_ok(digits)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
TOLL_FREE: Final = frozenset({"800", "833", "844", "855", "866", "877", "888"})
|
|
282
|
+
|
|
283
|
+
SAFE_EMAIL_DOMAINS: Final = re.compile(
|
|
284
|
+
r"(?:^|\.)(?:example\.(?:com|org|net)|example|test|invalid|localhost|"
|
|
285
|
+
r"users\.noreply\.github\.com|noreply\.github\.com)$",
|
|
286
|
+
re.I,
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
ORG_WORDS: Final = frozenset({
|
|
291
|
+
"inc", "llc", "ltd", "corp", "co", "company", "group", "partners", "labs",
|
|
292
|
+
"health", "healthcare", "care", "services", "solutions", "financial",
|
|
293
|
+
"medical", "clinic", "center", "centre", "hospital", "agency", "therapy",
|
|
294
|
+
"pharmacy", "supply", "systems", "technologies", "foundation", "network",
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def not_org_name(value: str) -> bool:
|
|
299
|
+
"""False when the last word marks the value as an organisation."""
|
|
300
|
+
last = re.split(r"[\s,]+", value.strip())[-1].lower().strip(".")
|
|
301
|
+
return last not in ORG_WORDS
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def has_digit(value: str) -> bool:
|
|
305
|
+
"""Whether the value contains at least one digit."""
|
|
306
|
+
return any(ch.isdigit() for ch in value)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
# --------------------------------------------------------------------------
|
|
310
|
+
# Finder helpers
|
|
311
|
+
# --------------------------------------------------------------------------
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def regex_finder(
|
|
315
|
+
pattern: str,
|
|
316
|
+
flags: int = 0,
|
|
317
|
+
group: int | str = 0,
|
|
318
|
+
validate: Validator | None = None,
|
|
319
|
+
) -> Finder:
|
|
320
|
+
"""A finder yielding the span of `group` for every match that `validate`
|
|
321
|
+
accepts."""
|
|
322
|
+
rx = re.compile(pattern, flags)
|
|
323
|
+
|
|
324
|
+
def find(text: str) -> Iterator[Span]:
|
|
325
|
+
for m in rx.finditer(text):
|
|
326
|
+
if validate is not None and not validate(m):
|
|
327
|
+
continue
|
|
328
|
+
s, e = m.span(group)
|
|
329
|
+
if s < e:
|
|
330
|
+
yield (s, e)
|
|
331
|
+
|
|
332
|
+
return find
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
# A labelled value may wrap onto the next line right after a separator, as in
|
|
336
|
+
# "SSN: 234-56-" followed by "7891" on the next line.
|
|
337
|
+
BR = r"(?:\r?\n[ \t]*+)?"
|
|
338
|
+
ORDINAL = r"(?:st|nd|rd|th)?"
|
|
339
|
+
MONTHS = r"(?:jan|feb|mar|apr|may|jun|jul|aug|sep|sept|oct|nov|dec)[a-z]*+\.?"
|
|
340
|
+
DATE = (
|
|
341
|
+
rf"(?:\d{{1,2}}[/.-]{BR}\d{{1,2}}[/.-]{BR}(?:\d{{4}}|\d{{2}})"
|
|
342
|
+
r"|\d{4}-\d{1,2}-\d{1,2}"
|
|
343
|
+
r"|(?:19|20)\d{6}"
|
|
344
|
+
r"|\d{1,2} \d{1,2} (?:19|20)\d{2}"
|
|
345
|
+
rf"|{MONTHS}\s++\d{{1,2}}{ORDINAL},?\s++\d{{4}}"
|
|
346
|
+
rf"|\d{{1,2}}{ORDINAL}(?:\s++|-){MONTHS}(?:,?\s++|-)\d{{4}})"
|
|
347
|
+
)
|
|
348
|
+
# Separator between a label and its value: handles `DOB: x`, `dob = "x"`,
|
|
349
|
+
# `"birthDate": "x"`, `'dob': 'x'`, `DOB#x`, `SSN#: x` and `DOB x`. The value
|
|
350
|
+
# may start on the next line only after a colon or equals sign, so the last
|
|
351
|
+
# column name of a table header is not read as a label for the first cell
|
|
352
|
+
# below it. Every quantifier is possessive, so a long run of spaces after a
|
|
353
|
+
# label is read once.
|
|
354
|
+
SEP = r"""[ \t]*+(?:["'][ \t]*+)?(?:#[ \t]*+)?(?:[:=]\s*+)?(?:["']\s*+)?"""
|
|
355
|
+
# A parenthesised note between a label and its value: `DOB (MM/DD/YYYY): x`.
|
|
356
|
+
NOTE = r"(?:\s*+\([^()\r\n]{0,40}+\))?"
|
|
357
|
+
# Where a label can start. `\b` would miss `patient_dob` (underscore) and
|
|
358
|
+
# `patientDob` (a lower-to-upper camelCase step).
|
|
359
|
+
LB = r"(?:(?<![A-Za-z0-9])|(?-i:(?<=[a-z])(?=[A-Z])))"
|
|
360
|
+
# Between the words of a label: `date of birth`, `date_of_birth`, `dateOfBirth`.
|
|
361
|
+
W = r"[\s_-]*+"
|
|
362
|
+
NUM = r"(?:\#|no\.?|num(?:ber)?)"
|
|
363
|
+
# An XML element used as a label: `<SSN>x`, `<ns:MemberId type="a">x`, and the
|
|
364
|
+
# FHIR attribute form `<birthDate value="x"/>`.
|
|
365
|
+
XML_OPEN = r"<(?:[A-Za-z][\w.-]{0,40}:)?"
|
|
366
|
+
XML_CLOSE = r"""(?:\s[^<>]{0,300}?\bvalue\s*+=\s*+["']|(?:\s[^<>]{0,300}+)?>\s*+)"""
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def labelled(label: str, value: str) -> str:
|
|
370
|
+
"""A pattern for `value` after `label`, as a key, a field or an XML
|
|
371
|
+
element. The value is the named group `v`."""
|
|
372
|
+
return rf"(?:{XML_OPEN}(?:{label}){XML_CLOSE}|{LB}(?:{label}){NOTE}{SEP})(?P<v>{value})"
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
_XML_OPEN_BEFORE: Final = re.compile(XML_OPEN + r"\Z")
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def labelled_finder(
|
|
379
|
+
label: str, value: str, starts: str, validate: Validator | None = None, ignore_case: bool = True
|
|
380
|
+
) -> Finder:
|
|
381
|
+
"""A finder for `labelled(label, value)` that yields the span of the value.
|
|
382
|
+
|
|
383
|
+
`starts` lists every letter a spelling of the label can begin with. The
|
|
384
|
+
full pattern is tried only where the label itself occurs (or at the `<`
|
|
385
|
+
opening an XML element around it): scanning for the label behind a
|
|
386
|
+
one-character lookahead is several times cheaper than trying the label
|
|
387
|
+
boundary and the XML form at every position.
|
|
388
|
+
"""
|
|
389
|
+
flags = "(?i)" if ignore_case else ""
|
|
390
|
+
rx = re.compile(flags + labelled(label, value))
|
|
391
|
+
trigger = re.compile(f"{flags}(?=[{starts.lower()}{starts.upper()}])(?:{label})")
|
|
392
|
+
|
|
393
|
+
def find(text: str) -> Iterator[Span]:
|
|
394
|
+
end = 0
|
|
395
|
+
for hit in trigger.finditer(text):
|
|
396
|
+
p = hit.start()
|
|
397
|
+
if p < end:
|
|
398
|
+
continue
|
|
399
|
+
m = None
|
|
400
|
+
if p and text[p - 1] in "<:":
|
|
401
|
+
xml = _XML_OPEN_BEFORE.search(text, max(end, p - 42), p)
|
|
402
|
+
if xml is not None:
|
|
403
|
+
m = rx.match(text, xml.start())
|
|
404
|
+
if m is None:
|
|
405
|
+
m = rx.match(text, p)
|
|
406
|
+
if m is None:
|
|
407
|
+
continue
|
|
408
|
+
end = m.end()
|
|
409
|
+
if validate is not None and not validate(m):
|
|
410
|
+
continue
|
|
411
|
+
s, e = m.span("v")
|
|
412
|
+
if s < e:
|
|
413
|
+
yield (s, e)
|
|
414
|
+
|
|
415
|
+
return find
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
NAME = r"[A-Z][a-z'\-]++(?:\s++[A-Z]\.?)?(?:\s++[A-Z][a-z'\-]++){1,2}|[A-Z][a-z'\-]++,\s*+[A-Z][a-z'\-]++"
|
|
419
|
+
# In a field that names a patient or member, also capitals (`JOHN SMITH`), the
|
|
420
|
+
# `SMITH, JOHN` order and names such as `McDonald`. Words stay on one line.
|
|
421
|
+
_NAME_WORD = r"[A-Z][A-Za-z'\-]++"
|
|
422
|
+
PATIENT_NAME = (
|
|
423
|
+
rf"{_NAME_WORD}(?:[ \t]++[A-Z]\.?)?(?:[ \t]++{_NAME_WORD}){{1,2}}|{_NAME_WORD},[ \t]*+{_NAME_WORD}"
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
US_STATES = (
|
|
427
|
+
"AL|AK|AZ|AR|CA|CO|CT|DE|DC|FL|GA|HI|ID|IL|IN|IA|KS|KY|LA|ME|MD|MA|MI|MN|MS|"
|
|
428
|
+
"MO|MT|NE|NV|NH|NJ|NM|NY|NC|ND|OH|OK|OR|PA|RI|SC|SD|TN|TX|UT|VT|VA|WA|WV|WI|WY|PR"
|
|
429
|
+
)
|
|
430
|
+
STREET_SUFFIX = (
|
|
431
|
+
"Street|St|Avenue|Ave|Road|Rd|Boulevard|Blvd|Lane|Ln|Drive|Dr|Court|Ct|Way|"
|
|
432
|
+
"Place|Pl|Terrace|Ter|Parkway|Pkwy|Circle|Cir|Highway|Hwy"
|
|
433
|
+
)
|
|
434
|
+
# A capitalised word, or one in capitals as on mailing labels.
|
|
435
|
+
_PLACE_WORD = r"(?:[A-Z][a-z]++|[A-Z]{2,}+)"
|
|
436
|
+
|
|
437
|
+
MBI_L = "AC-HJKMNP-RT-Y" # letters allowed in an MBI (no S, L, O, I, B, Z)
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _ssn_valid(m: re.Match[str]) -> bool:
|
|
441
|
+
return ssn_area_ok(m.group("a"), m.group("g"), m.group("s"))
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def _ssn_bare_valid(m: re.Match[str]) -> bool:
|
|
445
|
+
v = re.sub(r"\D", "", m.group("v"))
|
|
446
|
+
return ssn_area_ok(v[:3], v[3:5], v[5:])
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def _mbi_valid(m: re.Match[str]) -> bool:
|
|
450
|
+
"""One case throughout. A random mixed-case token is far more often an
|
|
451
|
+
opaque id than an MBI typed in lowercase."""
|
|
452
|
+
letters = [c for c in m.group(0) if c.isalpha()]
|
|
453
|
+
return all(c.isupper() for c in letters) or all(c.islower() for c in letters)
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def _phone_valid(m: re.Match[str]) -> bool:
|
|
457
|
+
area = m.group("area1") or m.group("area2")
|
|
458
|
+
exch, line = m.group("exch"), m.group("line")
|
|
459
|
+
if area in TOLL_FREE:
|
|
460
|
+
return False
|
|
461
|
+
if exch == "555" and 100 <= int(line) <= 199: # reserved fictional range
|
|
462
|
+
return False
|
|
463
|
+
return True
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def _email_valid(m: re.Match[str]) -> bool:
|
|
467
|
+
return not SAFE_EMAIL_DOMAINS.search(m.group("domain"))
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def _ip_valid(m: re.Match[str]) -> bool:
|
|
471
|
+
try:
|
|
472
|
+
ip = ipaddress.ip_address(m.group(0))
|
|
473
|
+
except ValueError:
|
|
474
|
+
return False
|
|
475
|
+
return ip.is_global
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def _card_valid(m: re.Match[str]) -> bool:
|
|
479
|
+
return card_ok(m.group(0))
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def _npi_valid(m: re.Match[str]) -> bool:
|
|
483
|
+
return npi_ok(m.group(0))
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def _dea_valid(m: re.Match[str]) -> bool:
|
|
487
|
+
return dea_ok(m.group(0))
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def _person_valid(m: re.Match[str]) -> bool:
|
|
491
|
+
return not_org_name(m.group("v"))
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def _x12_ref_valid(m: re.Match[str]) -> bool:
|
|
495
|
+
return has_digit(m.group("v"))
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
# Column types and encodings that follow an id in schemas and code, such as
|
|
499
|
+
# `patient_id INT8`. They contain digits but they are not identifiers.
|
|
500
|
+
_TYPE_NAME = re.compile(r"(?i)^(?:u?int|float|serial|char|varchar|sha|md|utf|base|uuid|bytea)-?\d*$")
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def _id_value(name: str, min_length: int = 4) -> Validator:
|
|
504
|
+
"""A validator for an identifier in group `name`: at least `min_length`
|
|
505
|
+
letters and digits, at least one digit, and not a type name."""
|
|
506
|
+
|
|
507
|
+
def valid(m: re.Match[str]) -> bool:
|
|
508
|
+
value = m.group(name)
|
|
509
|
+
compact = value.replace(" ", "")
|
|
510
|
+
return len(compact) >= min_length and has_digit(value) and not _TYPE_NAME.match(compact)
|
|
511
|
+
|
|
512
|
+
return valid
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
# --------------------------------------------------------------------------
|
|
516
|
+
# Delimited tables
|
|
517
|
+
# --------------------------------------------------------------------------
|
|
518
|
+
|
|
519
|
+
MAX_ROW_LINES: Final = 50 # how far a quoted cell may run across lines
|
|
520
|
+
|
|
521
|
+
_DIRECT_COLUMNS: Final = frozenset({
|
|
522
|
+
"social_security_number", "social_security", "ss_number",
|
|
523
|
+
"date_of_birth", "birth_date", "birthdate",
|
|
524
|
+
"medical_record_number", "medical_record_no", "chart_number",
|
|
525
|
+
"member_id", "member_number", "member_name", "subscriber_id", "subscriber_name",
|
|
526
|
+
"medicaid_id", "medicare_id", "medicaid_number", "medicare_number",
|
|
527
|
+
"insurance_id", "policy_number",
|
|
528
|
+
"patient_name", "patient_full_name", "client_name", "beneficiary_name",
|
|
529
|
+
"insured_name", "patient_id", "client_id_number",
|
|
530
|
+
})
|
|
531
|
+
# Any column with one of these words, such as `patient_dob` or `ssn_last4`.
|
|
532
|
+
_DIRECT_TOKENS: Final = frozenset({"ssn", "dob", "mrn", "mbi", "hicn"})
|
|
533
|
+
# A column naming the person a row is about, combined with an identifier or name
|
|
534
|
+
# word: `Mbr_ID`, `Subscriber No`, `Insured ID`, `Patient Acct #`.
|
|
535
|
+
_SUBJECT_TOKENS: Final = frozenset({
|
|
536
|
+
"member", "mbr", "subscriber", "insured", "patient", "pt", "medicaid", "medicare", "beneficiary",
|
|
537
|
+
})
|
|
538
|
+
_ID_TOKENS: Final = frozenset({"id", "no", "num", "number", "nbr", "nr", "identifier", "name"})
|
|
539
|
+
FIRST_NAME_COLUMNS: Final = frozenset({
|
|
540
|
+
"first_name", "firstname", "fname", "given_name", "patient_first_name", "first", "given",
|
|
541
|
+
})
|
|
542
|
+
LAST_NAME_COLUMNS: Final = frozenset({
|
|
543
|
+
"last_name", "lastname", "lname", "family_name", "surname", "patient_last_name", "last", "family",
|
|
544
|
+
})
|
|
545
|
+
QUASI_COLUMNS: Final = frozenset({
|
|
546
|
+
"diagnosis", "dx", "icd10", "icd_10", "icd10_code", "diagnosis_code",
|
|
547
|
+
"address", "street", "street_address", "address1", "address_line_1",
|
|
548
|
+
"phone", "phone_number", "mobile", "email", "zip", "zipcode", "zip_code",
|
|
549
|
+
"date_of_service", "dos", "admit_date", "admission_date", "discharge_date",
|
|
550
|
+
})
|
|
551
|
+
|
|
552
|
+
_CAMEL_STEP: Final = re.compile(r"(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])")
|
|
553
|
+
_PARENTHESISED: Final = re.compile(r"\([^()]*\)")
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def normalize_column(col: str) -> str:
|
|
557
|
+
"""A header cell as a snake_case column name.
|
|
558
|
+
|
|
559
|
+
`DOB (mm/dd/yyyy)` gives `dob`, `[Patient Name]` gives `patient_name`,
|
|
560
|
+
`MemberID` gives `member_id` and `Member #` gives `member_no`.
|
|
561
|
+
"""
|
|
562
|
+
col = col.strip().strip("\"'").strip()
|
|
563
|
+
if col.endswith(":"):
|
|
564
|
+
col = col[:-1].rstrip()
|
|
565
|
+
if col.startswith("[") and col.endswith("]"):
|
|
566
|
+
col = col[1:-1]
|
|
567
|
+
col = _PARENTHESISED.sub("", col).replace("#", " no ")
|
|
568
|
+
col = _CAMEL_STEP.sub("_", col).lower()
|
|
569
|
+
return re.sub(r"[^a-z0-9]+", "_", col).strip("_")
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def cell_spans(row: str, delim: str) -> list[Span]:
|
|
573
|
+
"""(start, end) of each cell. A delimiter inside double quotes is data."""
|
|
574
|
+
spans = []
|
|
575
|
+
start = 0
|
|
576
|
+
if '"' not in row:
|
|
577
|
+
for piece in row.split(delim):
|
|
578
|
+
spans.append((start, start + len(piece)))
|
|
579
|
+
start += len(piece) + len(delim)
|
|
580
|
+
return spans
|
|
581
|
+
quoted = False
|
|
582
|
+
for i, ch in enumerate(row):
|
|
583
|
+
if ch == '"':
|
|
584
|
+
quoted = not quoted
|
|
585
|
+
elif ch == delim and not quoted:
|
|
586
|
+
spans.append((start, i))
|
|
587
|
+
start = i + 1
|
|
588
|
+
spans.append((start, len(row)))
|
|
589
|
+
return spans
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def header_delimiter(row: str) -> str | None:
|
|
593
|
+
"""The first of comma, tab, pipe and semicolon that the row contains."""
|
|
594
|
+
return next((d for d in (",", "\t", "|", ";") if d in row), None)
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def logical_rows(lines: list[str], start: int, stop: int) -> Iterator[Span]:
|
|
598
|
+
"""(first, last) line indexes of each row. A quoted cell can span lines, so
|
|
599
|
+
a line with an unbalanced quote continues on the next one."""
|
|
600
|
+
i = start
|
|
601
|
+
while i < stop:
|
|
602
|
+
last = i
|
|
603
|
+
open_quote = lines[i].count('"') % 2 == 1
|
|
604
|
+
while open_quote and last + 1 < stop and last - i < MAX_ROW_LINES:
|
|
605
|
+
last += 1
|
|
606
|
+
open_quote ^= lines[last].count('"') % 2 == 1
|
|
607
|
+
yield i, last
|
|
608
|
+
i = last + 1
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
_RULE_ROW: Final = re.compile(r"^[\s|:+\-=]*$")
|
|
612
|
+
_COLUMN_NAME: Final = re.compile(r"^[a-z][a-z0-9_]{0,40}$")
|
|
613
|
+
_PLACEHOLDER: Final = re.compile(r"^\[[A-Z0-9_]+_\d+\]$")
|
|
614
|
+
_LIST_ITEM: Final = re.compile(r"^\s*(?:[-*+]|\d+[.)])\s")
|
|
615
|
+
_PERSON_NAME: Final = re.compile(rf"(?:{NAME})$")
|
|
616
|
+
# A tuple, or a line of a list of strings. Not any trailing comma: spreadsheets
|
|
617
|
+
# and many real exports end every row with a delimiter.
|
|
618
|
+
_CODE_LINE: Final = re.compile(r"""^\s*\(|^\s*["'][^"']*["'],\s.*,\s*$""")
|
|
619
|
+
# Quick rejections, so that ordinary lines of code never reach the cell logic.
|
|
620
|
+
_NOT_A_HEADER: Final = re.compile(r"[={}<>]")
|
|
621
|
+
# A colon other than at the end of a cell, or a subscript: code, not a header.
|
|
622
|
+
_CODE_PUNCTUATION: Final = re.compile(r"""\w\[|\]\w|:(?![ \t]*+(?:[,\t|;"']|$))""")
|
|
623
|
+
_HEADER_WORD: Final = re.compile(
|
|
624
|
+
r"(?i)(?=[bcdhimnps])(?:ssn|dob|mrn|mbi|hicn|name|patient|birth|member|mbr|subscriber|medic|insur|polic|chart"
|
|
625
|
+
r"|social|client|benefic)"
|
|
626
|
+
)
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def is_rule_row(row: str) -> bool:
|
|
630
|
+
"""Whether a row is a separator such as `|---|---|`, not data."""
|
|
631
|
+
return bool(_RULE_ROW.match(row))
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
def is_direct_column(col: str) -> bool:
|
|
635
|
+
"""Whether a normalised column name holds an identifier on its own."""
|
|
636
|
+
tokens = set(col.split("_"))
|
|
637
|
+
if col in _DIRECT_COLUMNS or tokens & _DIRECT_TOKENS:
|
|
638
|
+
return True
|
|
639
|
+
return bool(tokens & _SUBJECT_TOKENS) and bool(tokens & _ID_TOKENS)
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
def _header_cell_ok(cell: str) -> bool:
|
|
643
|
+
"""A cell that can be a column name: brackets only around the whole name,
|
|
644
|
+
a colon only at its end, and balanced parentheses."""
|
|
645
|
+
c = cell.strip().strip("\"'").strip()
|
|
646
|
+
if c.endswith(":"):
|
|
647
|
+
c = c[:-1].rstrip()
|
|
648
|
+
if _PLACEHOLDER.match(c): # a redacted value, not a column name
|
|
649
|
+
return False
|
|
650
|
+
if c.startswith("[") and c.endswith("]"):
|
|
651
|
+
c = c[1:-1]
|
|
652
|
+
if any(ch in c for ch in "[]:"):
|
|
653
|
+
return False
|
|
654
|
+
return not any(ch in _PARENTHESISED.sub("", c) for ch in "()")
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
def _is_header(raw: str, cells: list[str]) -> bool:
|
|
658
|
+
"""Whether a row reads as column names rather than prose with commas.
|
|
659
|
+
Two columns are weak evidence, so a table that narrow must not contain a
|
|
660
|
+
multi-word cell that is not a known column."""
|
|
661
|
+
if len(cells) < 2 or _LIST_ITEM.match(raw) or _CODE_LINE.search(raw):
|
|
662
|
+
return False
|
|
663
|
+
if not all(_header_cell_ok(c) for c in cells):
|
|
664
|
+
return False
|
|
665
|
+
names = [normalize_column(c) for c in cells]
|
|
666
|
+
if not all(_COLUMN_NAME.match(c) for c in names) or any(len(c.split()) > 4 for c in cells):
|
|
667
|
+
return False
|
|
668
|
+
return len(names) > 2 or all(is_direct_column(n) or len(c.split()) == 1 for n, c in zip(names, cells, strict=True))
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
@dataclass(frozen=True)
|
|
672
|
+
class TableHeader:
|
|
673
|
+
"""The layout a header line gives the rows below it."""
|
|
674
|
+
|
|
675
|
+
delimiter: str
|
|
676
|
+
columns: tuple[str, ...] # normalised names, one per cell, empty cells included
|
|
677
|
+
|
|
678
|
+
@property
|
|
679
|
+
def identifier_columns(self) -> list[int]:
|
|
680
|
+
"""Indexes of columns that identify the person a row is about."""
|
|
681
|
+
return [
|
|
682
|
+
k for k, c in enumerate(self.columns)
|
|
683
|
+
if is_direct_column(c) or c in FIRST_NAME_COLUMNS or c in LAST_NAME_COLUMNS
|
|
684
|
+
]
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
def parse_header(raw: str) -> TableHeader | None:
|
|
688
|
+
"""The header a line declares, when it names patient identifier columns."""
|
|
689
|
+
delim = header_delimiter(raw)
|
|
690
|
+
if delim is None or _NOT_A_HEADER.search(raw) or not _HEADER_WORD.search(raw) or _CODE_PUNCTUATION.search(raw):
|
|
691
|
+
return None
|
|
692
|
+
raw_cells = [raw[a:b] for a, b in cell_spans(raw, delim)]
|
|
693
|
+
if not _is_header(raw, [c for c in raw_cells if c.strip()]):
|
|
694
|
+
return None
|
|
695
|
+
columns = tuple(normalize_column(c) for c in raw_cells)
|
|
696
|
+
cols = set(columns)
|
|
697
|
+
direct = any(is_direct_column(c) for c in cols)
|
|
698
|
+
name_pair = bool(cols & FIRST_NAME_COLUMNS) and bool(cols & LAST_NAME_COLUMNS)
|
|
699
|
+
if not (direct or (name_pair and cols & QUASI_COLUMNS)):
|
|
700
|
+
return None
|
|
701
|
+
return TableHeader(delim, columns)
|
|
702
|
+
|
|
703
|
+
|
|
704
|
+
@dataclass(frozen=True)
|
|
705
|
+
class Table:
|
|
706
|
+
"""A delimited table: its header and the offsets of its rows.
|
|
707
|
+
|
|
708
|
+
`start` and `end` bound the body, from the line after the header. `rows` are the (start, end) offsets of
|
|
709
|
+
each logical row, without its line terminator; blank lines are not rows.
|
|
710
|
+
"""
|
|
711
|
+
|
|
712
|
+
header: TableHeader
|
|
713
|
+
start: int
|
|
714
|
+
end: int
|
|
715
|
+
rows: tuple[Span, ...]
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
def _live_row(row: str, delim: str, id_cols: list[int], name_cols: set[int]) -> bool:
|
|
719
|
+
"""A data row whose identifier cells hold real values, not placeholders."""
|
|
720
|
+
if _RULE_ROW.match(row):
|
|
721
|
+
return False
|
|
722
|
+
parts = [row[a:b].strip(" \t\"'") for a, b in cell_spans(row, delim)]
|
|
723
|
+
values = [(k, parts[k]) for k in id_cols if k < len(parts)]
|
|
724
|
+
if not any(v and not _PLACEHOLDER.match(v) for _, v in values):
|
|
725
|
+
return False
|
|
726
|
+
# A table of pure text can still be a patient list: its digit-free rows
|
|
727
|
+
# count when a name column holds what looks like a person's name. Digits
|
|
728
|
+
# inside placeholders are not data.
|
|
729
|
+
return any(has_digit(p) for p in parts if not _PLACEHOLDER.match(p)) or any(
|
|
730
|
+
k in name_cols and _PERSON_NAME.match(v) for k, v in values
|
|
731
|
+
)
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
def _continues(line: str, header: TableHeader) -> bool:
|
|
735
|
+
"""Whether a row after a blank line still belongs to the table above: it
|
|
736
|
+
has the header's width and does not start a table of its own."""
|
|
737
|
+
raw = line.rstrip("\r\n")
|
|
738
|
+
width = len(header.columns)
|
|
739
|
+
if len(cell_spans(raw, header.delimiter)) != width or parse_header(raw) is not None:
|
|
740
|
+
return False
|
|
741
|
+
return width > 2 or has_digit(raw)
|
|
742
|
+
|
|
743
|
+
|
|
744
|
+
def _blocks(lines: list[str], header_line: int, header: TableHeader) -> list[Span]:
|
|
745
|
+
"""(first, stop) line ranges of the table's row blocks. Rows run to a blank
|
|
746
|
+
line; after a single blank line, rows of the header's width continue the
|
|
747
|
+
table (an export with a blank line after its header, or between pages)."""
|
|
748
|
+
n = len(lines)
|
|
749
|
+
blocks: list[Span] = []
|
|
750
|
+
pos = header_line + 1
|
|
751
|
+
while pos < n:
|
|
752
|
+
k = pos
|
|
753
|
+
while k < n and not lines[k].strip() and k - pos < 2:
|
|
754
|
+
k += 1
|
|
755
|
+
gap = k - pos
|
|
756
|
+
if k >= n or gap > 1 or not lines[k].strip():
|
|
757
|
+
break
|
|
758
|
+
if gap == 1 and not _continues(lines[k], header):
|
|
759
|
+
break
|
|
760
|
+
if blocks and gap == 0:
|
|
761
|
+
break
|
|
762
|
+
stop = k
|
|
763
|
+
while stop < n and lines[stop].strip():
|
|
764
|
+
stop += 1
|
|
765
|
+
blocks.append((k, stop))
|
|
766
|
+
pos = stop
|
|
767
|
+
return blocks
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
def find_tables(text: str, live_only: bool = True) -> Iterator[Table]:
|
|
771
|
+
"""CSV/TSV/pipe tables whose header names patient identifiers. With
|
|
772
|
+
`live_only`, only tables with at least one data row holding a value.
|
|
773
|
+
Rows of the wrong width stay in the table, so redaction can deal with
|
|
774
|
+
them."""
|
|
775
|
+
lines = text.splitlines(keepends=True)
|
|
776
|
+
offsets = []
|
|
777
|
+
pos = 0
|
|
778
|
+
for ln in lines:
|
|
779
|
+
offsets.append(pos)
|
|
780
|
+
pos += len(ln)
|
|
781
|
+
i = 0
|
|
782
|
+
n = len(lines)
|
|
783
|
+
while i < n:
|
|
784
|
+
header = parse_header(lines[i].rstrip("\r\n"))
|
|
785
|
+
if header is None:
|
|
786
|
+
i += 1
|
|
787
|
+
continue
|
|
788
|
+
blocks = _blocks(lines, i, header)
|
|
789
|
+
if not blocks:
|
|
790
|
+
i += 1
|
|
791
|
+
continue
|
|
792
|
+
id_cols = header.identifier_columns
|
|
793
|
+
name_cols = {k for k in id_cols if "name" in header.columns[k].split("_")}
|
|
794
|
+
# A wide header is rare enough in code to trust on its own, so a
|
|
795
|
+
# malformed row in that table still counts. A narrow one is not: `dob,
|
|
796
|
+
# mrn` could be a line of a program, so its rows must line up.
|
|
797
|
+
width = len(header.columns)
|
|
798
|
+
rows: list[Span] = []
|
|
799
|
+
live = not live_only
|
|
800
|
+
for first, stop in blocks:
|
|
801
|
+
for a, b in logical_rows(lines, first, stop):
|
|
802
|
+
row = "".join(lines[a:b + 1]).rstrip("\r\n")
|
|
803
|
+
rows.append((offsets[a], offsets[a] + len(row)))
|
|
804
|
+
if not live:
|
|
805
|
+
lines_up = width > 3 or len(cell_spans(row, header.delimiter)) == width
|
|
806
|
+
live = lines_up and _live_row(row, header.delimiter, id_cols, name_cols)
|
|
807
|
+
if live:
|
|
808
|
+
# The body starts on the line after the header, a blank one included,
|
|
809
|
+
# so that removing that blank line changes the finding.
|
|
810
|
+
yield Table(header, offsets[i + 1], rows[-1][1], tuple(rows))
|
|
811
|
+
# The rows are consumed either way, so input of repeated header-like
|
|
812
|
+
# lines stays linear.
|
|
813
|
+
i = max(blocks[-1][1], i + 1)
|
|
814
|
+
|
|
815
|
+
|
|
816
|
+
def tabular_finder(text: str) -> Iterator[Span]:
|
|
817
|
+
"""The body of every live patient table (see `find_tables`)."""
|
|
818
|
+
for table in find_tables(text):
|
|
819
|
+
yield table.start, table.end
|
|
820
|
+
|
|
821
|
+
|
|
822
|
+
# --------------------------------------------------------------------------
|
|
823
|
+
# FHIR
|
|
824
|
+
# --------------------------------------------------------------------------
|
|
825
|
+
|
|
826
|
+
_FHIR_WINDOW: Final = 3000
|
|
827
|
+
_FHIR_PATIENT: Final = re.compile(r'"resourceType"\s*+:\s*+"Patient"')
|
|
828
|
+
_FHIR_EVIDENCE: Final = re.compile(
|
|
829
|
+
r'"birthDate"\s*+:\s*+"\d|"family"\s*+:\s*+"[A-Za-z]|"given"\s*+:\s*+\[\s*+"[A-Za-z]'
|
|
830
|
+
r'|"name"\s*+:\s*+\[\s*+\{[^\]]{0,500}?"text"\s*+:\s*+"[A-Za-z]'
|
|
831
|
+
r'|"system"\s*+:\s*+"[^"]{0,200}?us-ssn'
|
|
832
|
+
)
|
|
833
|
+
_FHIR_XML_PATIENT: Final = re.compile(r"<(?:[A-Za-z][\w.-]{0,40}:)?Patient(?:\s[^<>]{0,500}+)?>")
|
|
834
|
+
_FHIR_XML_EVIDENCE: Final = re.compile(
|
|
835
|
+
r"""<(?:[A-Za-z][\w.-]{0,40}:)?(?:birthDate\s++value\s*+=\s*+["']\d|(?:family|given|text)\s++value\s*+=\s*+["'][A-Za-z])"""
|
|
836
|
+
r"|us-ssn"
|
|
837
|
+
)
|
|
838
|
+
|
|
839
|
+
|
|
840
|
+
def _anchored_finder(anchor: re.Pattern[str], evidence: re.Pattern[str]) -> Finder:
|
|
841
|
+
"""Spans of `anchor` with a match of `evidence` starting within the window
|
|
842
|
+
around it. Evidence is located once, so many anchors stay linear."""
|
|
843
|
+
|
|
844
|
+
def find(text: str) -> Iterator[Span]:
|
|
845
|
+
marks: list[int] | None = None
|
|
846
|
+
for m in anchor.finditer(text):
|
|
847
|
+
if marks is None:
|
|
848
|
+
marks = [e.start() for e in evidence.finditer(text)]
|
|
849
|
+
k = bisect.bisect_left(marks, m.start() - _FHIR_WINDOW)
|
|
850
|
+
if k < len(marks) and marks[k] <= m.end() + _FHIR_WINDOW:
|
|
851
|
+
yield m.span()
|
|
852
|
+
|
|
853
|
+
return find
|
|
854
|
+
|
|
855
|
+
|
|
856
|
+
fhir_patient_finder = _anchored_finder(_FHIR_PATIENT, _FHIR_EVIDENCE)
|
|
857
|
+
fhir_xml_patient_finder = _anchored_finder(_FHIR_XML_PATIENT, _FHIR_XML_EVIDENCE)
|
|
858
|
+
|
|
859
|
+
|
|
860
|
+
# --------------------------------------------------------------------------
|
|
861
|
+
# Labels
|
|
862
|
+
# --------------------------------------------------------------------------
|
|
863
|
+
|
|
864
|
+
SSN_LABEL = rf"ssn|ss{W}{NUM}|social{W}security(?:{W}{NUM})?|tin"
|
|
865
|
+
SSN_VALUE = rf"\d{{3}}(?:[ .-]{BR})?\d{{2}}(?:[ .-]{BR})?\d{{4}}(?![\d-])"
|
|
866
|
+
MRN_LABEL = (
|
|
867
|
+
rf"mrn|mr{W}{NUM}|medical{W}record(?:{W}{NUM})?|p(?:atien)?t{W}(?:id(?:entifier)?|{NUM})"
|
|
868
|
+
rf"|chart{W}{NUM}|encounter{W}(?:id|{NUM})"
|
|
869
|
+
)
|
|
870
|
+
MRN_VALUE = r"[A-Z0-9][A-Z0-9-]{3,19}\b"
|
|
871
|
+
MEMBER_LABEL = (
|
|
872
|
+
rf"(?:member|mbr|subscriber|beneficiary|policy{W}holder|insurance|insured|medicaid|medicare)"
|
|
873
|
+
rf"{W}(?:id(?:entifier)?|{NUM})|policy{W}{NUM}|hicn|hic{W}{NUM}"
|
|
874
|
+
)
|
|
875
|
+
# An identifier, optionally printed in space-separated groups (`XYZ 1234 5678`).
|
|
876
|
+
# Groups after the first are capitals and digits with at least one digit, so a
|
|
877
|
+
# following word of prose does not join the value.
|
|
878
|
+
MEMBER_VALUE = r"[A-Z0-9][A-Z0-9-]{1,19}(?-i:(?:[ ](?=[A-Z0-9]{0,9}\d)[A-Z0-9]{2,10}\b){0,3})\b"
|
|
879
|
+
DOB_LABEL = rf"dob|d\.o\.b\.?|date{W}of{W}birth|birth{W}(?:date|day)|born(?:\s++on)?"
|
|
880
|
+
PATIENT_NAME_LABEL = (
|
|
881
|
+
r"(?i:patient|pt|client|member|beneficiary|resident|insured|subscriber|guarantor)(?:'s)?"
|
|
882
|
+
rf"{W}(?i:full{W})?(?i:name)"
|
|
883
|
+
)
|
|
884
|
+
ACCOUNT_LABEL = rf"(?:account|acct|bank{W}account|iban){W}{NUM}"
|
|
885
|
+
ACCOUNT_VALUE = r"\d[\d -]{5,24}\d"
|
|
886
|
+
CLINICAL_DATE_LABEL = (
|
|
887
|
+
rf"date{W}of{W}service|dos|admi(?:t|ssion){W}date|discharge{W}date|date{W}of{W}death|dod"
|
|
888
|
+
)
|
|
889
|
+
DIAGNOSIS_LABEL = rf"(?:dx|diagnosis|diag|icd{W}10(?:{W}cm)?)(?:{W}codes?)?"
|
|
890
|
+
ICD10 = r"(?-i:[A-TV-Z]\d[0-9AB](?:\.?[0-9A-TV-Z]{1,4})?)\b"
|
|
891
|
+
|
|
892
|
+
# HL7 v2: real feeds separate segments with CR; logs and JSON often carry the
|
|
893
|
+
# separator as a literal `\r` or `\n` escape, and samples are often indented.
|
|
894
|
+
_HL7_CHAR = r"(?:[^\r\n\\]|\\(?![rn]))"
|
|
895
|
+
_HL7_FIELD_CHAR = r"(?:[^\r\n\\^]|\\(?![rn]))"
|
|
896
|
+
|
|
897
|
+
|
|
898
|
+
# --------------------------------------------------------------------------
|
|
899
|
+
# Registry
|
|
900
|
+
# --------------------------------------------------------------------------
|
|
901
|
+
|
|
902
|
+
DETECTORS: list[Detector] = [
|
|
903
|
+
Detector(
|
|
904
|
+
"ssn", HIGH, DIRECT, "US Social Security number (issued ranges only)",
|
|
905
|
+
regex_finder(r"(?<![\d-])(?P<a>\d{3})-(?P<g>\d{2})-(?P<s>\d{4})(?![\d-])", validate=_ssn_valid),
|
|
906
|
+
),
|
|
907
|
+
Detector(
|
|
908
|
+
"ssn", HIGH, DIRECT, "Labelled SSN or TIN (bare, spaced or dotted digits)",
|
|
909
|
+
labelled_finder(SSN_LABEL, SSN_VALUE, "st", _ssn_bare_valid),
|
|
910
|
+
("ss", "social", "tin"),
|
|
911
|
+
),
|
|
912
|
+
Detector(
|
|
913
|
+
"medicare_mbi", HIGH, DIRECT, "Medicare Beneficiary Identifier",
|
|
914
|
+
regex_finder(
|
|
915
|
+
rf"(?i)\b[1-9][{MBI_L}][{MBI_L}0-9]\d[- ]?[{MBI_L}][{MBI_L}0-9]\d[- ]?[{MBI_L}]{{2}}\d{{2}}\b",
|
|
916
|
+
validate=_mbi_valid,
|
|
917
|
+
),
|
|
918
|
+
),
|
|
919
|
+
Detector(
|
|
920
|
+
"mrn", HIGH, DIRECT, "Labelled medical record, patient, chart or encounter number",
|
|
921
|
+
labelled_finder(MRN_LABEL, MRN_VALUE, "mpce", _id_value("v")),
|
|
922
|
+
("mr", "medical", "pt", "patient", "chart", "encounter"),
|
|
923
|
+
),
|
|
924
|
+
Detector(
|
|
925
|
+
"member_id", HIGH, DIRECT, "Labelled health plan member / subscriber / Medicaid / Medicare ID",
|
|
926
|
+
labelled_finder(MEMBER_LABEL, MEMBER_VALUE, "msbpih", _id_value("v", 5)),
|
|
927
|
+
("member", "mbr", "subscriber", "beneficiary", "policy", "insur", "medicaid", "medicare", "hic"),
|
|
928
|
+
),
|
|
929
|
+
Detector(
|
|
930
|
+
"dob", HIGH, DIRECT, "Labelled date of birth",
|
|
931
|
+
labelled_finder(DOB_LABEL, DATE, "db"),
|
|
932
|
+
("dob", "d.o.b", "birth", "born"),
|
|
933
|
+
),
|
|
934
|
+
Detector(
|
|
935
|
+
"account_number", HIGH, DIRECT, "Labelled financial account number",
|
|
936
|
+
labelled_finder(ACCOUNT_LABEL, ACCOUNT_VALUE, "abi"),
|
|
937
|
+
("account", "acct", "iban"),
|
|
938
|
+
),
|
|
939
|
+
Detector(
|
|
940
|
+
"payment_card", HIGH, DIRECT, "Payment card number (Luhn-valid, test cards excluded)",
|
|
941
|
+
# Unbroken, or grouped the way cards are printed (4-4-4-4, 4-6-5). The
|
|
942
|
+
# guards skip the fractional digits of a float.
|
|
943
|
+
regex_finder(
|
|
944
|
+
r"(?<![\w.-])(?:\d{13,19}|\d{4}([ -])\d{4}\1\d{4}\1\d{1,7}|\d{4}([ -])\d{6}\2\d{5})(?![\w-]|\.\d)",
|
|
945
|
+
validate=_card_valid,
|
|
946
|
+
),
|
|
947
|
+
),
|
|
948
|
+
Detector(
|
|
949
|
+
"x12_patient", HIGH, DIRECT, "X12 EDI patient, subscriber or dependent name segment (NM1*QC / IL / 03)",
|
|
950
|
+
regex_finder(r"(?i)NM1(?P<d>[*|^])(?:QC|IL|03)(?P=d)1(?P=d)[^~\r\n]++"),
|
|
951
|
+
("nm1",),
|
|
952
|
+
),
|
|
953
|
+
Detector(
|
|
954
|
+
"x12_patient", HIGH, DIRECT, "X12 EDI reference segment with a person's identifier (REF*SY / 0F / 1L / 1W / EA)",
|
|
955
|
+
regex_finder(
|
|
956
|
+
r"(?i)REF(?P<d>[*|^])(?:SY|0F|1L|1W|EA)(?P=d)(?P<v>[^~\r\n*|^]{2,50}+)",
|
|
957
|
+
validate=_x12_ref_valid,
|
|
958
|
+
),
|
|
959
|
+
("ref",),
|
|
960
|
+
),
|
|
961
|
+
Detector(
|
|
962
|
+
"x12_dob", HIGH, DIRECT, "X12 EDI demographic segment with birth date (DMG*D8)",
|
|
963
|
+
regex_finder(r"(?i)DMG(?P<d>[*|^])D8(?P=d)\d{8}"),
|
|
964
|
+
("dmg",),
|
|
965
|
+
),
|
|
966
|
+
Detector(
|
|
967
|
+
"hl7_pid", HIGH, DIRECT, "HL7 v2 PID segment with a patient name",
|
|
968
|
+
regex_finder(
|
|
969
|
+
rf"(?:(?<![^\r\n])[ \t]*+|(?<=\\[rn]))(?P<v>PID\|{_HL7_FIELD_CHAR}{{0,300}}+\^{_HL7_CHAR}*+)",
|
|
970
|
+
group="v",
|
|
971
|
+
),
|
|
972
|
+
("pid|",),
|
|
973
|
+
),
|
|
974
|
+
Detector(
|
|
975
|
+
"fhir_patient", HIGH, DIRECT, "FHIR Patient resource with name, birthDate or SSN", fhir_patient_finder,
|
|
976
|
+
('"resourcetype"',),
|
|
977
|
+
),
|
|
978
|
+
Detector(
|
|
979
|
+
"fhir_patient", HIGH, DIRECT, "FHIR Patient resource in XML with name, birthDate or SSN",
|
|
980
|
+
fhir_xml_patient_finder,
|
|
981
|
+
("patient",),
|
|
982
|
+
),
|
|
983
|
+
Detector("tabular_phi", HIGH, DIRECT, "Delimited table with patient identifier columns and data rows", tabular_finder),
|
|
984
|
+
Detector(
|
|
985
|
+
"person_name", HIGH, PERSON, "Patient/member/client name in a labelled field",
|
|
986
|
+
labelled_finder(PATIENT_NAME_LABEL, PATIENT_NAME, "pcmbrisg", _person_valid, ignore_case=False),
|
|
987
|
+
("name",),
|
|
988
|
+
),
|
|
989
|
+
Detector(
|
|
990
|
+
"person_name", MEDIUM, PERSON, "Generic labelled full name",
|
|
991
|
+
regex_finder(r"\b(?i:full\s*+_?name|name)" + SEP + rf"(?P<v>{NAME})", group="v", validate=_person_valid),
|
|
992
|
+
("name",),
|
|
993
|
+
),
|
|
994
|
+
Detector(
|
|
995
|
+
"diagnosis", MEDIUM, CLINICAL, "Labelled ICD-10 diagnosis code",
|
|
996
|
+
labelled_finder(DIAGNOSIS_LABEL, ICD10, "di"),
|
|
997
|
+
("dx", "diag", "icd"),
|
|
998
|
+
),
|
|
999
|
+
Detector(
|
|
1000
|
+
"clinical_date", MEDIUM, CLINICAL, "Labelled admission, discharge, service or death date",
|
|
1001
|
+
labelled_finder(CLINICAL_DATE_LABEL, DATE, "da"),
|
|
1002
|
+
("service", "dos", "admi", "discharge", "death", "dod"),
|
|
1003
|
+
),
|
|
1004
|
+
Detector(
|
|
1005
|
+
"phone", MEDIUM, CONTACT, "US phone number (toll-free and 555-01xx excluded)",
|
|
1006
|
+
regex_finder(
|
|
1007
|
+
r"(?<![\w.-])(?:\+?1[\s.-]?)?(?:\((?P<area1>[2-9]\d{2})\)\s?|(?P<area2>[2-9]\d{2})[.-])"
|
|
1008
|
+
r"(?P<exch>[2-9]\d{2})[.-](?P<line>\d{4})(?![\w-])",
|
|
1009
|
+
validate=_phone_valid,
|
|
1010
|
+
),
|
|
1011
|
+
),
|
|
1012
|
+
Detector(
|
|
1013
|
+
"email", MEDIUM, CONTACT, "Email address (example/test domains excluded)",
|
|
1014
|
+
regex_finder(
|
|
1015
|
+
r"(?<![A-Za-z0-9._%+-])[A-Za-z0-9._%+-]{1,64}@(?P<domain>[A-Za-z0-9-]++(?:\.[A-Za-z0-9-]++)*\.[A-Za-z]{2,}+)\b",
|
|
1016
|
+
validate=_email_valid,
|
|
1017
|
+
),
|
|
1018
|
+
),
|
|
1019
|
+
Detector(
|
|
1020
|
+
"street_address", MEDIUM, CONTACT, "Street address",
|
|
1021
|
+
regex_finder(
|
|
1022
|
+
rf"\b\d{{1,6}}\s++(?:[NSEW]\.?\s++)?(?:{_PLACE_WORD}\s++){{1,3}}(?:{STREET_SUFFIX}|{STREET_SUFFIX.upper()})\b\.?"
|
|
1023
|
+
r"(?:,?\s++(?:Apt|Unit|Suite|Ste|APT|UNIT|SUITE|STE|#)\.?\s*+[\w-]+)?"
|
|
1024
|
+
),
|
|
1025
|
+
),
|
|
1026
|
+
Detector(
|
|
1027
|
+
"city_state_zip", MEDIUM, CONTACT, "City, state and ZIP code",
|
|
1028
|
+
regex_finder(rf"\b{_PLACE_WORD}(?:\s{_PLACE_WORD}){{0,3}},\s*+(?:{US_STATES})\s++\d{{5}}(?:-\d{{4}})?\b"),
|
|
1029
|
+
),
|
|
1030
|
+
Detector(
|
|
1031
|
+
"provider_npi", LOW, PROVIDER, "National Provider Identifier (check digit valid)",
|
|
1032
|
+
regex_finder(r"(?<![\d-])[12]\d{9}(?![\d-])", validate=_npi_valid),
|
|
1033
|
+
),
|
|
1034
|
+
Detector(
|
|
1035
|
+
"provider_dea", LOW, PROVIDER, "DEA registration number (checksum valid)",
|
|
1036
|
+
regex_finder(r"\b[ABCDEFGHJKLMPRSTUX][A-Z9]\d{7}\b", validate=_dea_valid),
|
|
1037
|
+
),
|
|
1038
|
+
Detector(
|
|
1039
|
+
"ip_address", LOW, NETWORK, "Public IPv4 address",
|
|
1040
|
+
regex_finder(r"(?<![\d.])(?:\d{1,3}\.){3}\d{1,3}(?![\d.])", validate=_ip_valid),
|
|
1041
|
+
),
|
|
1042
|
+
]
|
|
1043
|
+
|
|
1044
|
+
CATEGORIES = sorted({d.name for d in DETECTORS})
|
|
1045
|
+
|
|
1046
|
+
|
|
1047
|
+
def _sort_key(f: Finding) -> tuple[int, int]:
|
|
1048
|
+
return f.start, -SEVERITY_RANK[f.severity]
|
|
1049
|
+
|
|
1050
|
+
|
|
1051
|
+
def detect(text: str, disabled: set[str] | frozenset[str] = frozenset()) -> list[Finding]:
|
|
1052
|
+
"""Run every enabled detector over `text` exactly as given, without
|
|
1053
|
+
normalising it. Spans are offsets into `text`."""
|
|
1054
|
+
out: list[Finding] = []
|
|
1055
|
+
seen: set[tuple[str, int, int]] = set()
|
|
1056
|
+
lowered = text.lower()
|
|
1057
|
+
for det in DETECTORS:
|
|
1058
|
+
if det.name in disabled or (det.hints and not any(hint in lowered for hint in det.hints)):
|
|
1059
|
+
continue
|
|
1060
|
+
for s, e in det.find(text):
|
|
1061
|
+
key = (det.name, s, e)
|
|
1062
|
+
if key in seen:
|
|
1063
|
+
continue
|
|
1064
|
+
seen.add(key)
|
|
1065
|
+
out.append(Finding(det.name, det.severity, det.group, s, e))
|
|
1066
|
+
out.sort(key=_sort_key)
|
|
1067
|
+
return out
|
|
1068
|
+
|
|
1069
|
+
|
|
1070
|
+
def run_detectors(text: str, disabled: set[str] | frozenset[str] = frozenset()) -> list[Finding]:
|
|
1071
|
+
"""Run every enabled detector over the normalised view of `text`. Spans are
|
|
1072
|
+
offsets into the original text. Line and column are filled in by the
|
|
1073
|
+
scanner."""
|
|
1074
|
+
view = normalize(text)
|
|
1075
|
+
findings = detect(view.text, disabled)
|
|
1076
|
+
if view.offsets is None:
|
|
1077
|
+
return findings
|
|
1078
|
+
out: list[Finding] = []
|
|
1079
|
+
seen: set[tuple[str, int, int]] = set()
|
|
1080
|
+
for f in findings:
|
|
1081
|
+
s, e = view.to_original(f.start, f.end)
|
|
1082
|
+
if (f.category, s, e) not in seen:
|
|
1083
|
+
seen.add((f.category, s, e))
|
|
1084
|
+
out.append(Finding(f.category, f.severity, f.group, s, e))
|
|
1085
|
+
out.sort(key=_sort_key)
|
|
1086
|
+
return out
|