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.
Files changed (45) hide show
  1. deidkit/__init__.py +74 -0
  2. deidkit/__main__.py +6 -0
  3. deidkit/api.py +316 -0
  4. deidkit/cli.py +399 -0
  5. deidkit/config.py +285 -0
  6. deidkit/data/NOTICES.md +25 -0
  7. deidkit/data/context_triggers_en.txt +17 -0
  8. deidkit/data/context_triggers_es.txt +36 -0
  9. deidkit/data/en_given_names.txt +203 -0
  10. deidkit/data/en_surnames.txt +118 -0
  11. deidkit/data/es_given_names.txt +273 -0
  12. deidkit/data/es_surnames.txt +168 -0
  13. deidkit/data/honorifics_en.txt +22 -0
  14. deidkit/data/honorifics_es.txt +35 -0
  15. deidkit/data/medical_stoplist_en.txt +345 -0
  16. deidkit/data/medical_stoplist_es.txt +499 -0
  17. deidkit/data/medical_vocab.txt +21445 -0
  18. deidkit/dates.py +229 -0
  19. deidkit/detect/__init__.py +19 -0
  20. deidkit/detect/checksums.py +160 -0
  21. deidkit/detect/context.py +137 -0
  22. deidkit/detect/gazetteer.py +158 -0
  23. deidkit/detect/patterns.py +147 -0
  24. deidkit/detect/pipeline.py +234 -0
  25. deidkit/detect/spacy_ner.py +53 -0
  26. deidkit/detect/types.py +53 -0
  27. deidkit/engine.py +644 -0
  28. deidkit/generators/__init__.py +4 -0
  29. deidkit/generators/names.py +111 -0
  30. deidkit/generators/surrogates.py +76 -0
  31. deidkit/io.py +184 -0
  32. deidkit/learn.py +83 -0
  33. deidkit/mapping.py +108 -0
  34. deidkit/report.py +219 -0
  35. deidkit/resources.py +100 -0
  36. deidkit/schema.py +234 -0
  37. deidkit/secret.py +77 -0
  38. deidkit/textnorm.py +66 -0
  39. deidkit/version.py +3 -0
  40. deidkit-0.1.0.dist-info/METADATA +768 -0
  41. deidkit-0.1.0.dist-info/RECORD +45 -0
  42. deidkit-0.1.0.dist-info/WHEEL +5 -0
  43. deidkit-0.1.0.dist-info/entry_points.txt +2 -0
  44. deidkit-0.1.0.dist-info/licenses/LICENSE +21 -0
  45. 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