deidkit 0.1.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.
- deidkit/__init__.py +74 -0
- deidkit/__main__.py +6 -0
- deidkit/api.py +316 -0
- deidkit/cli.py +399 -0
- deidkit/config.py +285 -0
- deidkit/data/NOTICES.md +25 -0
- deidkit/data/context_triggers_en.txt +17 -0
- deidkit/data/context_triggers_es.txt +36 -0
- deidkit/data/en_given_names.txt +203 -0
- deidkit/data/en_surnames.txt +118 -0
- deidkit/data/es_given_names.txt +273 -0
- deidkit/data/es_surnames.txt +168 -0
- deidkit/data/honorifics_en.txt +22 -0
- deidkit/data/honorifics_es.txt +35 -0
- deidkit/data/medical_stoplist_en.txt +345 -0
- deidkit/data/medical_stoplist_es.txt +499 -0
- deidkit/data/medical_vocab.txt +21445 -0
- deidkit/dates.py +229 -0
- deidkit/detect/__init__.py +19 -0
- deidkit/detect/checksums.py +160 -0
- deidkit/detect/context.py +137 -0
- deidkit/detect/gazetteer.py +158 -0
- deidkit/detect/patterns.py +147 -0
- deidkit/detect/pipeline.py +234 -0
- deidkit/detect/spacy_ner.py +53 -0
- deidkit/detect/types.py +53 -0
- deidkit/engine.py +644 -0
- deidkit/generators/__init__.py +4 -0
- deidkit/generators/names.py +111 -0
- deidkit/generators/surrogates.py +76 -0
- deidkit/io.py +184 -0
- deidkit/learn.py +83 -0
- deidkit/mapping.py +108 -0
- deidkit/report.py +219 -0
- deidkit/resources.py +100 -0
- deidkit/schema.py +234 -0
- deidkit/secret.py +77 -0
- deidkit/textnorm.py +66 -0
- deidkit/version.py +3 -0
- deidkit-0.1.0.dist-info/METADATA +768 -0
- deidkit-0.1.0.dist-info/RECORD +45 -0
- deidkit-0.1.0.dist-info/WHEEL +5 -0
- deidkit-0.1.0.dist-info/entry_points.txt +2 -0
- deidkit-0.1.0.dist-info/licenses/LICENSE +21 -0
- deidkit-0.1.0.dist-info/top_level.txt +1 -0
deidkit/dates.py
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""Interval-preserving date shifting (HIPAA-style date jitter).
|
|
2
|
+
|
|
3
|
+
Every date belonging to one entity (default: one ``patient_id``) is shifted by
|
|
4
|
+
the *same* deterministic offset, drawn from ``[-max_days, +max_days]``. Because
|
|
5
|
+
the offset is identical for all of a patient's dates:
|
|
6
|
+
|
|
7
|
+
* intervals are preserved exactly — length of stay, time-to-result, gaps between
|
|
8
|
+
encounters, and age (encounter minus birth) are unchanged;
|
|
9
|
+
* the absolute calendar date is obscured, and offsets differ per patient so you
|
|
10
|
+
cannot line two patients up on the real timeline.
|
|
11
|
+
|
|
12
|
+
This is the "shift, don't reduce fidelity" alternative to truncating dates to
|
|
13
|
+
the year. Fidelity is fully retained; only the anchor moves.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import math
|
|
19
|
+
import re
|
|
20
|
+
from datetime import date, datetime, timedelta
|
|
21
|
+
from typing import Optional, Tuple
|
|
22
|
+
|
|
23
|
+
import pandas as pd
|
|
24
|
+
|
|
25
|
+
from .secret import digest_int
|
|
26
|
+
|
|
27
|
+
# Month names + abbreviations, Spanish AND English -> number.
|
|
28
|
+
_MONTHS = {
|
|
29
|
+
"ene": 1, "enero": 1, "jan": 1, "january": 1,
|
|
30
|
+
"feb": 2, "febrero": 2, "february": 2,
|
|
31
|
+
"mar": 3, "marzo": 3, "march": 3,
|
|
32
|
+
"abr": 4, "abril": 4, "apr": 4, "april": 4,
|
|
33
|
+
"may": 5, "mayo": 5,
|
|
34
|
+
"jun": 6, "junio": 6, "june": 6,
|
|
35
|
+
"jul": 7, "julio": 7, "july": 7,
|
|
36
|
+
"ago": 8, "agosto": 8, "aug": 8, "august": 8,
|
|
37
|
+
"sep": 9, "sept": 9, "set": 9, "septiembre": 9, "setiembre": 9, "september": 9,
|
|
38
|
+
"oct": 10, "octubre": 10, "october": 10,
|
|
39
|
+
"nov": 11, "noviembre": 11, "november": 11,
|
|
40
|
+
"dic": 12, "diciembre": 12, "dec": 12, "december": 12,
|
|
41
|
+
}
|
|
42
|
+
_MONTHS_ES_REV = {
|
|
43
|
+
1: "enero", 2: "febrero", 3: "marzo", 4: "abril", 5: "mayo", 6: "junio",
|
|
44
|
+
7: "julio", 8: "agosto", 9: "septiembre", 10: "octubre", 11: "noviembre",
|
|
45
|
+
12: "diciembre",
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
_NUM_DMY = re.compile(r"^(\d{1,2})([/\-.])(\d{1,2})([/\-.])(\d{2,4})$")
|
|
49
|
+
_NUM_YMD = re.compile(r"^(\d{4})([/\-.])(\d{1,2})([/\-.])(\d{1,2})$")
|
|
50
|
+
# ISO/other date followed by a time component -> shift the date, keep the time.
|
|
51
|
+
_DATETIME = re.compile(
|
|
52
|
+
r"^(\d{4}[/\-.]\d{1,2}[/\-.]\d{1,2}|\d{1,2}[/\-.]\d{1,2}[/\-.]\d{2,4})"
|
|
53
|
+
r"([ T])(\d{1,2}:\d{2}(?::\d{2})?)$"
|
|
54
|
+
)
|
|
55
|
+
_LONG_ES = re.compile(
|
|
56
|
+
r"^(\d{1,2})\s+de\s+([A-Za-zÁÉÍÓÚáéíóúñ]+)\s+(?:de\s+)?(\d{4})$", re.IGNORECASE
|
|
57
|
+
)
|
|
58
|
+
# 05-ABR-1988 / 5 March 1988 / 5th March 2021 (day [ordinal] monthname year)
|
|
59
|
+
_DMY_ALPHA = re.compile(
|
|
60
|
+
r"^(\d{1,2})(?:st|nd|rd|th)?[-/ ]([A-Za-zÁÉÍÓÚáéíóúñ]{3,})[-/ ](\d{2,4})$",
|
|
61
|
+
re.IGNORECASE,
|
|
62
|
+
)
|
|
63
|
+
# March 5, 2021 / Mar 5 2021 / March 5th, 2021 (monthname day [ordinal] year)
|
|
64
|
+
_MDY_ALPHA = re.compile(
|
|
65
|
+
r"^([A-Za-zÁÉÍÓÚáéíóúñ]{3,})\.?\s+(\d{1,2})(?:st|nd|rd|th)?,?\s+(\d{4})$",
|
|
66
|
+
re.IGNORECASE,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _pivot_year(y: int) -> int:
|
|
71
|
+
if y >= 100:
|
|
72
|
+
return y
|
|
73
|
+
return 2000 + y if y <= 69 else 1900 + y
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class DateShifter:
|
|
77
|
+
def __init__(self, secret: bytes, max_days: int = 365):
|
|
78
|
+
self.secret = secret
|
|
79
|
+
self.max_days = max(int(max_days), 1)
|
|
80
|
+
self._cache = {}
|
|
81
|
+
|
|
82
|
+
# ------------------------------------------------------------------ #
|
|
83
|
+
def offset_days(self, entity_id) -> int:
|
|
84
|
+
"""Deterministic offset in ``[-max_days, +max_days] \\ {0}`` for an entity."""
|
|
85
|
+
if entity_id is None or (isinstance(entity_id, float) and math.isnan(entity_id)):
|
|
86
|
+
key = "__GLOBAL__"
|
|
87
|
+
else:
|
|
88
|
+
key = str(entity_id)
|
|
89
|
+
cached = self._cache.get(key)
|
|
90
|
+
if cached is not None:
|
|
91
|
+
return cached
|
|
92
|
+
val = digest_int(self.secret, "dateshift|", key)
|
|
93
|
+
span = 2 * self.max_days + 1
|
|
94
|
+
off = val % span - self.max_days
|
|
95
|
+
if off == 0: # never leave a date unmoved
|
|
96
|
+
off = 1 if (val >> 3) % 2 else -1
|
|
97
|
+
self._cache[key] = off
|
|
98
|
+
return off
|
|
99
|
+
|
|
100
|
+
# ------------------------------------------------------------------ #
|
|
101
|
+
def shift_scalar(self, value, entity_id) -> Tuple[object, bool]:
|
|
102
|
+
"""Shift a single value; return ``(new_value, changed)``."""
|
|
103
|
+
if value is None or (isinstance(value, float) and math.isnan(value)):
|
|
104
|
+
return value, False
|
|
105
|
+
try:
|
|
106
|
+
if pd.isna(value):
|
|
107
|
+
return value, False
|
|
108
|
+
except (TypeError, ValueError):
|
|
109
|
+
pass
|
|
110
|
+
off = self.offset_days(entity_id)
|
|
111
|
+
delta = timedelta(days=off)
|
|
112
|
+
|
|
113
|
+
if isinstance(value, pd.Timestamp):
|
|
114
|
+
return value + delta, True
|
|
115
|
+
if isinstance(value, datetime):
|
|
116
|
+
return value + delta, True
|
|
117
|
+
if isinstance(value, date):
|
|
118
|
+
return value + delta, True
|
|
119
|
+
if isinstance(value, str):
|
|
120
|
+
shifted = self._shift_string(value.strip(), off)
|
|
121
|
+
if shifted is not None:
|
|
122
|
+
return shifted, True
|
|
123
|
+
return value, False
|
|
124
|
+
return value, False
|
|
125
|
+
|
|
126
|
+
# ------------------------------------------------------------------ #
|
|
127
|
+
def shift_series(self, series: pd.Series, entity_ids: pd.Series) -> pd.Series:
|
|
128
|
+
"""Shift a whole column. Returns a new Series (same index).
|
|
129
|
+
|
|
130
|
+
* real ``datetime64`` dtype -> vectorized add (fast path).
|
|
131
|
+
* string/object -> per-element, FORMAT-PRESERVING shift via
|
|
132
|
+
:meth:`_shift_string` (handles ISO, ``dd/mm/yyyy``, datetime-with-time,
|
|
133
|
+
Spanish/English month names & abbreviations). Element-wise avoids the
|
|
134
|
+
single-format inference that used to silently drop odd rows.
|
|
135
|
+
"""
|
|
136
|
+
if pd.api.types.is_datetime64_any_dtype(series):
|
|
137
|
+
offs = entity_ids.map(self.offset_days).astype("float64")
|
|
138
|
+
return series + pd.to_timedelta(offs, unit="D")
|
|
139
|
+
|
|
140
|
+
return pd.Series(
|
|
141
|
+
[self.shift_scalar(v, e)[0] for v, e in zip(series, entity_ids)],
|
|
142
|
+
index=series.index,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
@staticmethod
|
|
146
|
+
def _parse_datetime(series: pd.Series) -> pd.Series:
|
|
147
|
+
import warnings
|
|
148
|
+
|
|
149
|
+
# Decide day-first vs year-first from the data. ISO (YYYY-MM-DD) MUST be
|
|
150
|
+
# parsed with dayfirst=False — pandas mis-swaps month/day otherwise. Only
|
|
151
|
+
# dd/mm/yyyy strings need dayfirst=True.
|
|
152
|
+
sample = series.dropna().astype(str).head(50)
|
|
153
|
+
if len(sample):
|
|
154
|
+
year_first = sample.str.match(r"^\d{4}[-/.]").mean()
|
|
155
|
+
else:
|
|
156
|
+
year_first = 0.0
|
|
157
|
+
dayfirst = year_first < 0.5
|
|
158
|
+
with warnings.catch_warnings():
|
|
159
|
+
warnings.simplefilter("ignore")
|
|
160
|
+
return pd.to_datetime(series, errors="coerce", dayfirst=dayfirst)
|
|
161
|
+
|
|
162
|
+
# ------------------------------------------------------------------ #
|
|
163
|
+
def shift_fragment(self, fragment: str, entity_id) -> Optional[str]:
|
|
164
|
+
"""Shift a date substring found in free text; ``None`` if unparseable."""
|
|
165
|
+
return self._shift_string(fragment.strip(), self.offset_days(entity_id))
|
|
166
|
+
|
|
167
|
+
# ------------------------------------------------------------------ #
|
|
168
|
+
def _shift_string(self, s: str, off: int) -> Optional[str]:
|
|
169
|
+
# datetime with a time component: shift the date, keep the time.
|
|
170
|
+
m = _DATETIME.match(s)
|
|
171
|
+
if m:
|
|
172
|
+
date_part, sep, time_part = m.groups()
|
|
173
|
+
shifted = self._shift_string(date_part, off)
|
|
174
|
+
return f"{shifted}{sep}{time_part}" if shifted else None
|
|
175
|
+
m = _NUM_YMD.match(s)
|
|
176
|
+
if m:
|
|
177
|
+
y, s1, mo, s2, d = m.groups()
|
|
178
|
+
dt = self._safe_date(int(y), int(mo), int(d))
|
|
179
|
+
if dt:
|
|
180
|
+
dt += timedelta(days=off)
|
|
181
|
+
return f"{dt.year:04d}{s1}{dt.month:02d}{s2}{dt.day:02d}"
|
|
182
|
+
return None
|
|
183
|
+
m = _NUM_DMY.match(s)
|
|
184
|
+
if m:
|
|
185
|
+
d, s1, mo, s2, y = m.groups()
|
|
186
|
+
dt = self._safe_date(_pivot_year(int(y)), int(mo), int(d))
|
|
187
|
+
if dt:
|
|
188
|
+
dt += timedelta(days=off)
|
|
189
|
+
yout = f"{dt.year % 100:02d}" if len(y) == 2 else f"{dt.year:04d}"
|
|
190
|
+
return f"{dt.day:02d}{s1}{dt.month:02d}{s2}{yout}"
|
|
191
|
+
return None
|
|
192
|
+
m = _LONG_ES.match(s)
|
|
193
|
+
if m:
|
|
194
|
+
d, mon, y = m.groups()
|
|
195
|
+
mo = _MONTHS.get(mon.lower())
|
|
196
|
+
if mo:
|
|
197
|
+
dt = self._safe_date(int(y), mo, int(d))
|
|
198
|
+
if dt:
|
|
199
|
+
dt += timedelta(days=off)
|
|
200
|
+
return f"{dt.day} de {_MONTHS_ES_REV[dt.month]} de {dt.year}"
|
|
201
|
+
return None
|
|
202
|
+
m = _DMY_ALPHA.match(s) # 05-ABR-1988 / 5 March 1988
|
|
203
|
+
if m:
|
|
204
|
+
d, mon, y = m.groups()
|
|
205
|
+
mo = _MONTHS.get(mon.lower())
|
|
206
|
+
if mo:
|
|
207
|
+
dt = self._safe_date(_pivot_year(int(y)), mo, int(d))
|
|
208
|
+
if dt:
|
|
209
|
+
dt += timedelta(days=off)
|
|
210
|
+
return f"{dt.year:04d}-{dt.month:02d}-{dt.day:02d}"
|
|
211
|
+
return None
|
|
212
|
+
m = _MDY_ALPHA.match(s) # March 5, 2021
|
|
213
|
+
if m:
|
|
214
|
+
mon, d, y = m.groups()
|
|
215
|
+
mo = _MONTHS.get(mon.lower())
|
|
216
|
+
if mo:
|
|
217
|
+
dt = self._safe_date(int(y), mo, int(d))
|
|
218
|
+
if dt:
|
|
219
|
+
dt += timedelta(days=off)
|
|
220
|
+
return f"{dt.year:04d}-{dt.month:02d}-{dt.day:02d}"
|
|
221
|
+
return None
|
|
222
|
+
return None
|
|
223
|
+
|
|
224
|
+
@staticmethod
|
|
225
|
+
def _safe_date(y: int, m: int, d: int) -> Optional[datetime]:
|
|
226
|
+
try:
|
|
227
|
+
return datetime(y, m, d)
|
|
228
|
+
except ValueError:
|
|
229
|
+
return None
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Multi-stage PII detection for free text.
|
|
2
|
+
|
|
3
|
+
Stages (combined by :class:`~deidkit.detect.pipeline.Detector`):
|
|
4
|
+
|
|
5
|
+
1. ``patterns`` - regex for structured identifiers (email, phone, national ID,
|
|
6
|
+
medical-record number, URL, in-text dates). High precision, always accepted.
|
|
7
|
+
2. ``context`` - honorific / role-anchored person names ("Dr. X Y",
|
|
8
|
+
"paciente: X Y"). Captures names structurally, even out-of-gazetteer.
|
|
9
|
+
3. ``gazetteer`` - dictionary match against curated given-name + surname lists.
|
|
10
|
+
A lone hit is only *proposed*; it is accepted when corroborated.
|
|
11
|
+
4. ``spacy_ner`` - optional statistical PERSON model, used as an independent
|
|
12
|
+
detector and to corroborate gazetteer hits.
|
|
13
|
+
|
|
14
|
+
A voting gate then decides which detections are confident enough to replace and
|
|
15
|
+
which go to the human review queue.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from .types import Detection, entity # noqa: F401
|
|
19
|
+
from .pipeline import Detector # noqa: F401
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""National-ID checksum validators + opt-in discovery.
|
|
2
|
+
|
|
3
|
+
Most national IDs carry a check digit. Validating it means a *bare* ID (no cue
|
|
4
|
+
word) can be detected with near-zero false positives — a random number almost
|
|
5
|
+
never satisfies a mod-11 / mod-23 check. This both closes a recall gap (bare
|
|
6
|
+
cédula/CPF in a note) and keeps precision high.
|
|
7
|
+
|
|
8
|
+
Opt-in per region via ``Policy.id_checksums`` (e.g. ``["CPF","CNPJ","CNS"]`` for
|
|
9
|
+
Brazil, ``["DNI","NIE"]`` for Spain, ``["NHS"]`` for the UK). Off by default so a
|
|
10
|
+
10-digit phone is never mistaken for a UK NHS number in a Colombian dataset.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import re
|
|
16
|
+
from typing import Dict, List, Tuple
|
|
17
|
+
|
|
18
|
+
from .types import Detection, NATIONAL_ID
|
|
19
|
+
|
|
20
|
+
_DNI_LETTERS = "TRWAGMYFPDXBNJZSQVHLCKE"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _all_same(s: str) -> bool:
|
|
24
|
+
return len(set(s)) == 1
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# --- validators (return True if the checksum/format holds) ----------------- #
|
|
28
|
+
def valid_cpf(s: str) -> bool:
|
|
29
|
+
d = [int(c) for c in s if c.isdigit()]
|
|
30
|
+
if len(d) != 11 or _all_same("".join(map(str, d))):
|
|
31
|
+
return False
|
|
32
|
+
for n in (9, 10):
|
|
33
|
+
r = sum(d[i] * (n + 1 - i) for i in range(n)) % 11
|
|
34
|
+
dv = 0 if r < 2 else 11 - r
|
|
35
|
+
if d[n] != dv:
|
|
36
|
+
return False
|
|
37
|
+
return True
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def valid_cnpj(s: str) -> bool:
|
|
41
|
+
d = [int(c) for c in s if c.isdigit()]
|
|
42
|
+
if len(d) != 14 or _all_same("".join(map(str, d))):
|
|
43
|
+
return False
|
|
44
|
+
w1 = [5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2]
|
|
45
|
+
w2 = [6] + w1
|
|
46
|
+
for n, w in ((12, w1), (13, w2)):
|
|
47
|
+
r = sum(d[i] * w[i] for i in range(n)) % 11
|
|
48
|
+
dv = 0 if r < 2 else 11 - r
|
|
49
|
+
if d[n] != dv:
|
|
50
|
+
return False
|
|
51
|
+
return True
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def valid_cns(s: str) -> bool:
|
|
55
|
+
d = [int(c) for c in s if c.isdigit()]
|
|
56
|
+
if len(d) != 15:
|
|
57
|
+
return False
|
|
58
|
+
return sum(d[i] * (15 - i) for i in range(15)) % 11 == 0
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def valid_nhs(s: str) -> bool:
|
|
62
|
+
d = [int(c) for c in s if c.isdigit()]
|
|
63
|
+
if len(d) != 10:
|
|
64
|
+
return False
|
|
65
|
+
total = sum(d[i] * (10 - i) for i in range(9))
|
|
66
|
+
check = 11 - (total % 11)
|
|
67
|
+
check = 0 if check == 11 else check
|
|
68
|
+
return check != 10 and check == d[9]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def valid_dni_es(s: str) -> bool:
|
|
72
|
+
m = re.fullmatch(r"(\d{8})([A-Za-z])", s.strip().upper())
|
|
73
|
+
if not m:
|
|
74
|
+
return False
|
|
75
|
+
return _DNI_LETTERS[int(m.group(1)) % 23] == m.group(2)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def valid_nie_es(s: str) -> bool:
|
|
79
|
+
m = re.fullmatch(r"([XYZ])(\d{7})([A-Za-z])", s.strip().upper())
|
|
80
|
+
if not m:
|
|
81
|
+
return False
|
|
82
|
+
num = str("XYZ".index(m.group(1))) + m.group(2)
|
|
83
|
+
return _DNI_LETTERS[int(num) % 23] == m.group(3)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def valid_curp(s: str) -> bool:
|
|
87
|
+
s = s.strip().upper()
|
|
88
|
+
if not re.fullmatch(r"[A-Z]{4}\d{6}[HM][A-Z]{5}[0-9A-Z]\d", s):
|
|
89
|
+
return False
|
|
90
|
+
alphabet = "0123456789ABCDEFGHIJKLMNÑOPQRSTUVWXYZ"
|
|
91
|
+
total = 0
|
|
92
|
+
for i, ch in enumerate(s[:17]):
|
|
93
|
+
total += alphabet.index(ch) * (18 - i)
|
|
94
|
+
return (10 - total % 10) % 10 == int(s[17])
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def valid_rfc(s: str) -> bool: # format only (RFC's homoclave check is optional)
|
|
98
|
+
return bool(re.fullmatch(r"[A-ZÑ&]{3,4}\d{6}[A-Z0-9]{3}", s.strip().upper()))
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def valid_ssn_us(s: str) -> bool: # range/format only (SSN has no checksum)
|
|
102
|
+
m = re.fullmatch(r"(\d{3})[-. ]?(\d{2})[-. ]?(\d{4})", s.strip())
|
|
103
|
+
if not m:
|
|
104
|
+
return False
|
|
105
|
+
a, g, ser = m.group(1), m.group(2), m.group(3)
|
|
106
|
+
return a not in ("000", "666") and not a.startswith("9") and g != "00" and ser != "0000"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
VALIDATORS = {
|
|
110
|
+
"CPF": (valid_cpf, "num", 11),
|
|
111
|
+
"CNPJ": (valid_cnpj, "num", 14),
|
|
112
|
+
"CNS": (valid_cns, "num", 15),
|
|
113
|
+
"NHS": (valid_nhs, "num", 10),
|
|
114
|
+
"SSN": (valid_ssn_us, "num", 9),
|
|
115
|
+
"DNI": (valid_dni_es, "alnum", 9),
|
|
116
|
+
"NIE": (valid_nie_es, "alnum", 9),
|
|
117
|
+
"CURP": (valid_curp, "alnum", 18),
|
|
118
|
+
"RFC": (valid_rfc, "alnum", 13),
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
_NUM_CAND = re.compile(r"(?<![\w.])\d[\d.\-/ ]{6,17}\d(?![\w])")
|
|
122
|
+
_ALNUM_CAND = re.compile(r"(?<![\w])[A-Za-z0-9]{8,18}(?![\w])")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def find_valid_ids(text: str, enabled: List[str]) -> List[Detection]:
|
|
126
|
+
"""Return NATIONAL_ID detections for substrings that pass an enabled check."""
|
|
127
|
+
if not enabled:
|
|
128
|
+
return []
|
|
129
|
+
enabled = [e.upper() for e in enabled]
|
|
130
|
+
if "ALL" in enabled:
|
|
131
|
+
enabled = list(VALIDATORS)
|
|
132
|
+
num_types = [e for e in enabled if e in VALIDATORS and VALIDATORS[e][1] == "num"]
|
|
133
|
+
alnum_types = [e for e in enabled if e in VALIDATORS and VALIDATORS[e][1] == "alnum"]
|
|
134
|
+
out: List[Detection] = []
|
|
135
|
+
|
|
136
|
+
for m in _NUM_CAND.finditer(text):
|
|
137
|
+
digits = "".join(c for c in m.group(0) if c.isdigit())
|
|
138
|
+
for t in num_types:
|
|
139
|
+
fn, _, ln = VALIDATORS[t]
|
|
140
|
+
if len(digits) == ln and fn(digits):
|
|
141
|
+
out.append(Detection(m.start(), m.end(), m.group(0), NATIONAL_ID,
|
|
142
|
+
{"checksum"}, confidence=0.98, accepted=True,
|
|
143
|
+
meta={"id_type": t}))
|
|
144
|
+
break
|
|
145
|
+
for m in _ALNUM_CAND.finditer(text):
|
|
146
|
+
tok = m.group(0)
|
|
147
|
+
for t in alnum_types:
|
|
148
|
+
fn, _, _ = VALIDATORS[t]
|
|
149
|
+
if fn(tok):
|
|
150
|
+
out.append(Detection(m.start(), m.end(), tok, NATIONAL_ID,
|
|
151
|
+
{"checksum"}, confidence=0.98, accepted=True,
|
|
152
|
+
meta={"id_type": t}))
|
|
153
|
+
break
|
|
154
|
+
return out
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def validate(value: str, id_type: str) -> bool:
|
|
158
|
+
"""Public helper: does ``value`` satisfy ``id_type``'s check?"""
|
|
159
|
+
entry = VALIDATORS.get(id_type.upper())
|
|
160
|
+
return bool(entry and entry[0](value))
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Context stage: honorific / role-anchored person names.
|
|
2
|
+
|
|
3
|
+
Captures a name by the words *around* it, so it works even when the name is not
|
|
4
|
+
in the gazetteer. Anchors are graded by how reliably they precede a person, to
|
|
5
|
+
keep precision high:
|
|
6
|
+
|
|
7
|
+
* **strong** — honorifics (``Dr.``, ``Dra.``, ``Sr.``) and name/signature cues
|
|
8
|
+
(``nombre``, ``firma``, ``atentamente``). A following capitalized token is
|
|
9
|
+
taken directly.
|
|
10
|
+
* **naming** — relational/role words that almost always precede a person
|
|
11
|
+
(``madre``, ``acompañante``, ``paciente``, ``mother`` ...). Accepted when the
|
|
12
|
+
following run looks like a name (>=2 tokens or a known name token).
|
|
13
|
+
* **verb** — verbs that often precede findings, not names (``refiere``,
|
|
14
|
+
``solicita``, ``valorado`` ...). Only fire when a known name token follows, so
|
|
15
|
+
"valorado Ecografía Abdominal" is NOT mistaken for a person.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from typing import List
|
|
21
|
+
|
|
22
|
+
from ..textnorm import Token, is_titlecase
|
|
23
|
+
from .gazetteer import CONNECTORS, Gazetteer
|
|
24
|
+
from .types import Detection, PERSON
|
|
25
|
+
|
|
26
|
+
# Strong cues: a following capitalized token is a name with high confidence.
|
|
27
|
+
STRONG_EXTRA = {
|
|
28
|
+
"nombre", "nombres", "apellido", "apellidos", "firma", "firmado", "atentamente",
|
|
29
|
+
"cordialmente", "suscribe", "signed", "signature", "sincerely", "name", "surname",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
# Naming anchors: relational / role NOUNS that reliably precede a person name.
|
|
33
|
+
# Deliberately excludes participle verbs (valorado, atendido, remitido) — those
|
|
34
|
+
# are usually followed by "por <name>" and stay verb-class to avoid grabbing a
|
|
35
|
+
# capitalized procedure ("Valorado Ecografía Abdominal").
|
|
36
|
+
NAMING_TRIGGERS = {
|
|
37
|
+
"madre", "padre", "hijo", "hija", "esposo", "esposa", "acompanante", "responsable",
|
|
38
|
+
"familiar", "cuidador", "cuidadora", "tutor", "tutora", "informante", "senor",
|
|
39
|
+
"senora", "senorita", "paciente", "usuario", "asegurado", "afiliado", "beneficiario",
|
|
40
|
+
"cotizante", "mother", "father", "son", "daughter", "spouse", "guardian",
|
|
41
|
+
"companion", "caregiver", "relative", "patient",
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _is_name_like(tok: Token, gaz: Gazetteer) -> bool:
|
|
46
|
+
"""A token that can belong to an anchored name run.
|
|
47
|
+
|
|
48
|
+
Title-case is the usual signal, but after an explicit anchor (``Dr.``,
|
|
49
|
+
``paciente:`` ...) clinical staff often type the name in lowercase — so a
|
|
50
|
+
*known* gazetteer given/surname token counts even when it is not title-cased.
|
|
51
|
+
Generic (un-anchored) gazetteer detection stays title-case-only elsewhere, so
|
|
52
|
+
this relaxation cannot broaden false positives across the whole document.
|
|
53
|
+
"""
|
|
54
|
+
if gaz.is_boundary(tok.norm):
|
|
55
|
+
return False
|
|
56
|
+
return is_titlecase(tok.text) or gaz.is_name_token(tok.norm)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _grab_following_name(tokens: List[Token], start_idx: int, gaz: Gazetteer, max_tokens: int = 4):
|
|
60
|
+
"""Collect up to ``max_tokens`` name tokens after ``start_idx`` (title-case, or
|
|
61
|
+
a known lowercase name token straight after an anchor)."""
|
|
62
|
+
run: List[Token] = []
|
|
63
|
+
j = start_idx
|
|
64
|
+
n = len(tokens)
|
|
65
|
+
while j < n and len(run) < max_tokens:
|
|
66
|
+
tj = tokens[j]
|
|
67
|
+
if _is_name_like(tj, gaz):
|
|
68
|
+
run.append(tj)
|
|
69
|
+
j += 1
|
|
70
|
+
elif (
|
|
71
|
+
tj.norm in CONNECTORS
|
|
72
|
+
and run
|
|
73
|
+
and j + 1 < n
|
|
74
|
+
and _is_name_like(tokens[j + 1], gaz)
|
|
75
|
+
):
|
|
76
|
+
run.append(tj)
|
|
77
|
+
j += 1
|
|
78
|
+
else:
|
|
79
|
+
break
|
|
80
|
+
while run and run[-1].norm in CONNECTORS:
|
|
81
|
+
run = run[:-1]
|
|
82
|
+
return run
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _classify(nrm: str, gaz: Gazetteer) -> str:
|
|
86
|
+
if gaz.is_honorific(nrm) or nrm in STRONG_EXTRA:
|
|
87
|
+
return "strong"
|
|
88
|
+
if nrm in NAMING_TRIGGERS:
|
|
89
|
+
return "naming"
|
|
90
|
+
if gaz.is_trigger(nrm):
|
|
91
|
+
return "verb"
|
|
92
|
+
return ""
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def find(text: str, tokens: List[Token], gaz: Gazetteer) -> List[Detection]:
|
|
96
|
+
dets: List[Detection] = []
|
|
97
|
+
for idx, tok in enumerate(tokens):
|
|
98
|
+
strength = _classify(tok.norm, gaz)
|
|
99
|
+
if not strength:
|
|
100
|
+
continue
|
|
101
|
+
run = _grab_following_name(tokens, idx + 1, gaz)
|
|
102
|
+
if not run:
|
|
103
|
+
continue
|
|
104
|
+
known = sum(1 for r in run if gaz.is_name_token(r.norm))
|
|
105
|
+
name_len = sum(1 for r in run if r.norm not in CONNECTORS)
|
|
106
|
+
|
|
107
|
+
if strength == "strong":
|
|
108
|
+
accept = True
|
|
109
|
+
elif strength == "naming":
|
|
110
|
+
accept = name_len >= 2 or known >= 1
|
|
111
|
+
else: # verb: only when a known name token actually follows
|
|
112
|
+
accept = known >= 1
|
|
113
|
+
if not accept:
|
|
114
|
+
continue
|
|
115
|
+
|
|
116
|
+
gender = "u"
|
|
117
|
+
for r in run:
|
|
118
|
+
if gaz.is_given(r.norm):
|
|
119
|
+
gender = gaz.gender(r.norm)
|
|
120
|
+
break
|
|
121
|
+
dets.append(
|
|
122
|
+
Detection(
|
|
123
|
+
start=run[0].start,
|
|
124
|
+
end=run[-1].end,
|
|
125
|
+
text=text[run[0].start : run[-1].end],
|
|
126
|
+
entity_type=PERSON,
|
|
127
|
+
methods={"context"},
|
|
128
|
+
meta={
|
|
129
|
+
"anchor": tok.norm,
|
|
130
|
+
"strength": strength,
|
|
131
|
+
"known": known,
|
|
132
|
+
"run_len": name_len,
|
|
133
|
+
"gender": gender,
|
|
134
|
+
},
|
|
135
|
+
)
|
|
136
|
+
)
|
|
137
|
+
return dets
|