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
@@ -0,0 +1,158 @@
1
+ """Gazetteer stage: dictionary matching of person names.
2
+
3
+ Loads curated given-name (with gender) and surname lists, plus honorifics,
4
+ context triggers and a medical stoplist. Proposes PERSON spans from runs of
5
+ adjacent title-case tokens that contain at least one known name token. A run is
6
+ only a *proposal*; the pipeline's voting gate decides whether it is confident
7
+ enough to replace (e.g. a lone surname that is also a common word waits for
8
+ corroboration).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import Dict, List, Set
14
+
15
+ from ..resources import load_given_names, load_set, load_surnames
16
+ from ..textnorm import Token, is_titlecase, norm
17
+ from .types import Detection, PERSON
18
+
19
+ # Connectors allowed *inside* one compound name span. Deliberately excludes "y"
20
+ # and "san"/"santa": "Juan Pérez y María Gómez" is two people, not one, and
21
+ # "San ..." is usually a place. da/do/dos/das cover Portuguese/Brazilian names.
22
+ CONNECTORS: Set[str] = {"de", "del", "la", "las", "los", "da", "das", "do", "dos"}
23
+ MAX_RUN = 6
24
+
25
+
26
+ class Gazetteer:
27
+ def __init__(
28
+ self,
29
+ given: Dict[str, str],
30
+ surnames: Set[str],
31
+ honorifics: Set[str],
32
+ triggers: Set[str],
33
+ stoplist: Set[str],
34
+ ):
35
+ self.given = given
36
+ self.surnames = surnames
37
+ self.honorifics = honorifics
38
+ self.triggers = triggers
39
+ self.stoplist = stoplist
40
+
41
+ @classmethod
42
+ def load(cls, lang="es", extra_name_files=(), extra_stoplist_files=(),
43
+ use_medical_vocab=True):
44
+ from ..resources import load_lang_sets
45
+
46
+ given = load_given_names(lang, extra_name_files)
47
+ surnames = load_surnames(lang, extra_name_files)
48
+ honorifics = load_lang_sets("honorifics", lang)
49
+ triggers = load_lang_sets("context_triggers", lang)
50
+ stoplist = load_lang_sets("medical_stoplist", lang, extra_stoplist_files)
51
+ if not stoplist: # never run without a stoplist (precision guard)
52
+ stoplist = load_set("medical_stoplist_es.txt", extra_stoplist_files)
53
+ if use_medical_vocab: # bundled RxNorm-derived safe-vocabulary (Philter-style)
54
+ try:
55
+ stoplist |= load_set("medical_vocab.txt")
56
+ except (FileNotFoundError, OSError):
57
+ pass
58
+ return cls(given, surnames, honorifics, triggers, stoplist)
59
+
60
+ # --- token predicates -------------------------------------------------- #
61
+ def is_given(self, nrm: str) -> bool:
62
+ return nrm in self.given
63
+
64
+ def gender(self, nrm: str) -> str:
65
+ return self.given.get(nrm, "u")
66
+
67
+ def is_surname(self, nrm: str) -> bool:
68
+ return nrm in self.surnames
69
+
70
+ def is_name_token(self, nrm: str) -> bool:
71
+ return nrm in self.given or nrm in self.surnames
72
+
73
+ def is_stopword(self, nrm: str) -> bool:
74
+ return nrm in self.stoplist
75
+
76
+ def is_honorific(self, nrm: str) -> bool:
77
+ return nrm.rstrip(".") in self.honorifics
78
+
79
+ def is_trigger(self, nrm: str) -> bool:
80
+ return nrm in self.triggers
81
+
82
+ def is_boundary(self, nrm: str) -> bool:
83
+ """A token that must not be part of a name span (stopword / anchor word).
84
+
85
+ Honorifics and role triggers *precede* names; absorbing them into the
86
+ span ("Acompañante María Gómez") is wrong, so they end a run.
87
+ """
88
+ return self.is_stopword(nrm) or self.is_honorific(nrm) or self.is_trigger(nrm)
89
+
90
+
91
+ def _trim_connectors(run: List[Token]) -> List[Token]:
92
+ while run and run[0].norm in CONNECTORS:
93
+ run = run[1:]
94
+ while run and run[-1].norm in CONNECTORS:
95
+ run = run[:-1]
96
+ return run
97
+
98
+
99
+ def find(text: str, tokens: List[Token], gaz: Gazetteer) -> List[Detection]:
100
+ """Propose PERSON spans from gazetteer-anchored title-case runs."""
101
+ dets: List[Detection] = []
102
+ n = len(tokens)
103
+ i = 0
104
+ while i < n:
105
+ t = tokens[i]
106
+ if not is_titlecase(t.text) or gaz.is_boundary(t.norm):
107
+ i += 1
108
+ continue
109
+ # Grow a run of title-case, non-boundary tokens (connectors allowed
110
+ # between name tokens for Spanish compound surnames).
111
+ run: List[Token] = []
112
+ j = i
113
+ while j < n and len(run) < MAX_RUN:
114
+ tj = tokens[j]
115
+ if is_titlecase(tj.text) and not gaz.is_boundary(tj.norm):
116
+ run.append(tj)
117
+ j += 1
118
+ elif (
119
+ tj.norm in CONNECTORS
120
+ and run
121
+ and j + 1 < n
122
+ and is_titlecase(tokens[j + 1].text)
123
+ and not gaz.is_boundary(tokens[j + 1].norm)
124
+ ):
125
+ run.append(tj)
126
+ j += 1
127
+ else:
128
+ break
129
+
130
+ run = _trim_connectors(run)
131
+ if run:
132
+ known_given = sum(1 for r in run if gaz.is_given(r.norm))
133
+ known_surname = sum(1 for r in run if gaz.is_surname(r.norm))
134
+ name_tokens = [r for r in run if r.norm not in CONNECTORS]
135
+ if known_given + known_surname >= 1:
136
+ gender = "u"
137
+ for r in run:
138
+ if gaz.is_given(r.norm):
139
+ gender = gaz.gender(r.norm)
140
+ break
141
+ dets.append(
142
+ Detection(
143
+ start=run[0].start,
144
+ end=run[-1].end,
145
+ text=text[run[0].start : run[-1].end],
146
+ entity_type=PERSON,
147
+ methods={"gazetteer"},
148
+ meta={
149
+ "known_given": known_given,
150
+ "known_surname": known_surname,
151
+ "run_len": len(name_tokens),
152
+ "bigram": known_given >= 1 and known_surname >= 1,
153
+ "gender": gender,
154
+ },
155
+ )
156
+ )
157
+ i = max(j, i + 1)
158
+ return dets
@@ -0,0 +1,147 @@
1
+ """Regex stage: structured identifiers embedded in free text.
2
+
3
+ These patterns are deliberately precise. Bare numbers are *not* matched as IDs
4
+ (a 7-digit run could be a lab value or a code); a national ID is only taken when
5
+ a document cue (``CC``, ``cédula``, ``documento`` ...) or thousands-dotted shape
6
+ makes it unambiguous. Every hit here is high-precision and accepted directly by
7
+ the voting gate.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import re
13
+ from typing import List
14
+
15
+ from .types import (
16
+ Detection,
17
+ DATE,
18
+ EMAIL,
19
+ IPADDR,
20
+ MRN,
21
+ NATIONAL_ID,
22
+ PHONE,
23
+ URL,
24
+ )
25
+
26
+ # Unicode-aware: accented local parts (e.g. diego.gómez@...) must match fully.
27
+ EMAIL_RE = re.compile(
28
+ r"(?<![\w.%+\-])[\w.%+\-]+@[\w\-]+(?:\.[\w\-]+)*\.[A-Za-z]{2,}(?![\w])",
29
+ re.UNICODE,
30
+ )
31
+
32
+ # http(s):// URLs and bare www. hosts (which can carry names/IDs in the path).
33
+ URL_RE = re.compile(r"\b(?:https?://|www\.)[^\s<>\"')]+", re.IGNORECASE)
34
+ # Trailing characters that are sentence punctuation, not part of the URL.
35
+ _URL_TRAILING = ".,;:!?'\")]}>"
36
+
37
+ IP_RE = re.compile(
38
+ r"\b(?:(?:25[0-5]|2[0-4]\d|1?\d?\d)\.){3}(?:25[0-5]|2[0-4]\d|1?\d?\d)\b"
39
+ )
40
+
41
+ # Colombian phones: optional +57 / (57), mobile (3xx, 10 digits) or landline
42
+ # with separators. Requires separators or a country/area cue so we don't grab
43
+ # plain long integers.
44
+ PHONE_RE = re.compile(
45
+ r"""(?<!\d)(?:
46
+ (?:\+?57[\s\-.]?)? # optional country code
47
+ (?:\(?\d{1,3}\)?[\s\-.])? # optional area code in parens or with sep
48
+ 3\d{2}[\s\-.]?\d{3}[\s\-.]?\d{2,4} # mobile 3xx xxx xxxx
49
+ |
50
+ (?:\+?57[\s\-.]?)?\(?\d{1,3}\)?[\s\-.]\d{3}[\s\-.]?\d{4} # landline
51
+ )(?!\d)""",
52
+ re.VERBOSE,
53
+ )
54
+
55
+ # National ID (cédula / documento) with an explicit document cue.
56
+ CEDULA_CUED_RE = re.compile(
57
+ r"""(?ix)
58
+ \b(?:c\.?\s?c\.?|c[eé]dula(?:\s+de\s+ciudadan[ií]a)?|
59
+ documento|identificaci[oó]n|nuip|
60
+ t\.?\s?i\.?|r\.?\s?c\.?|c\.?\s?e\.?|no\.?\s?documento)
61
+ \s*[:#\-]?\s*
62
+ (\d{1,3}(?:[.\s]\d{3}){2,3}|\d{6,11})
63
+ """
64
+ )
65
+
66
+ # Thousands-dotted 8-11 digit number, which in ES text is almost always a cédula.
67
+ CEDULA_DOTTED_RE = re.compile(r"(?<!\d)\d{1,3}(?:\.\d{3}){2,3}(?!\d)")
68
+
69
+ # Medical-record / historia-clínica number with a cue.
70
+ MRN_RE = re.compile(
71
+ r"""(?ix)
72
+ \b(?:h\.?\s?c\.?|historia(?:\s+cl[ií]nica)?|
73
+ n[uú]mero\s+de\s+historia|expediente|folio|mrn)
74
+ \s*(?:n[o°º]\.?)?\s*[:#\-]?\s*
75
+ (\d{4,12})
76
+ """
77
+ )
78
+
79
+ # Dates: numeric (dd/mm/yyyy etc.) and Spanish long form.
80
+ DATE_NUMERIC_RE = re.compile(
81
+ r"(?<!\d)(?:\d{1,2}[/\-.]\d{1,2}[/\-.]\d{2,4}|\d{4}[/\-.]\d{1,2}[/\-.]\d{1,2})(?!\d)"
82
+ )
83
+ DATE_LONG_ES_RE = re.compile(
84
+ r"(?i)\b\d{1,2}\s+de\s+(?:enero|febrero|marzo|abril|mayo|junio|julio|agosto|"
85
+ r"septiembre|setiembre|octubre|noviembre|diciembre)\s+(?:de\s+)?\d{4}\b"
86
+ )
87
+ _EN_MONTH = (r"jan|feb|mar|apr|may|jun|jul|aug|sep|sept|oct|nov|dec|january|february|"
88
+ r"march|april|june|july|august|september|october|november|december")
89
+ DATE_LONG_EN_RE = re.compile(
90
+ rf"(?i)\b(?:{_EN_MONTH})\.?\s+\d{{1,2}}(?:st|nd|rd|th)?,?\s+\d{{4}}\b" # March 5, 2021
91
+ rf"|\b\d{{1,2}}(?:st|nd|rd|th)?\s+(?:{_EN_MONTH})\.?,?\s+\d{{4}}\b" # 5 March 2021
92
+ )
93
+ # 05-ABR-1988 / 5-MAR-88 (day-monthname-year, common in DB exports)
94
+ DATE_ALPHA_DMY_RE = re.compile(
95
+ r"(?i)\b\d{1,2}[-/](?:ene|feb|mar|abr|may|jun|jul|ago|sep|set|oct|nov|dic|"
96
+ rf"{_EN_MONTH})[-/]\d{{2,4}}\b"
97
+ )
98
+
99
+
100
+ def _add(dets: List[Detection], m, etype: str, group: int = 0, meta=None):
101
+ start, end = m.span(group)
102
+ dets.append(
103
+ Detection(
104
+ start=start,
105
+ end=end,
106
+ text=m.string[start:end],
107
+ entity_type=etype,
108
+ methods={"regex"},
109
+ confidence=0.99,
110
+ meta=meta or {},
111
+ )
112
+ )
113
+
114
+
115
+ def find(text: str) -> List[Detection]:
116
+ """Return all identifier detections in ``text``."""
117
+ dets: List[Detection] = []
118
+ for m in EMAIL_RE.finditer(text):
119
+ _add(dets, m, EMAIL)
120
+ for m in URL_RE.finditer(text):
121
+ start, end = m.start(), m.end()
122
+ while end > start and text[end - 1] in _URL_TRAILING: # keep sentence punctuation
123
+ end -= 1
124
+ dets.append(
125
+ Detection(start=start, end=end, text=text[start:end], entity_type=URL,
126
+ methods={"regex"}, confidence=0.99)
127
+ )
128
+ for m in IP_RE.finditer(text):
129
+ _add(dets, m, IPADDR)
130
+ for m in CEDULA_CUED_RE.finditer(text):
131
+ _add(dets, m, NATIONAL_ID, group=1)
132
+ for m in CEDULA_DOTTED_RE.finditer(text):
133
+ _add(dets, m, NATIONAL_ID)
134
+ for m in MRN_RE.finditer(text):
135
+ _add(dets, m, MRN, group=1)
136
+ for m in PHONE_RE.finditer(text):
137
+ # Guard: skip if it is entirely inside an already-found email/url span.
138
+ _add(dets, m, PHONE)
139
+ for m in DATE_NUMERIC_RE.finditer(text):
140
+ _add(dets, m, DATE)
141
+ for m in DATE_LONG_ES_RE.finditer(text):
142
+ _add(dets, m, DATE)
143
+ for m in DATE_LONG_EN_RE.finditer(text):
144
+ _add(dets, m, DATE)
145
+ for m in DATE_ALPHA_DMY_RE.finditer(text):
146
+ _add(dets, m, DATE)
147
+ return dets
@@ -0,0 +1,234 @@
1
+ """Detection orchestrator + voting gate.
2
+
3
+ Runs every stage over a text value, merges overlapping spans, then scores each
4
+ person cluster from the combination of signals that fired. Whether a detection
5
+ is *accepted* (replaced) or sent to the *review queue* depends on the score and
6
+ the policy ``mode``:
7
+
8
+ ============ ========= =============================================
9
+ mode threshold typical effect
10
+ ============ ========= =============================================
11
+ conservative 0.80 only clear names (bigram / honorific / 2-detector)
12
+ balanced 0.65 also accept single-model NER hits
13
+ aggressive 0.35 also accept lone gazetteer tokens
14
+ ============ ========= =============================================
15
+
16
+ This is how the layered design pursues high capture with low error: confident
17
+ signals are replaced automatically, and the ambiguous tail is surfaced for human
18
+ review in the audit workbook rather than silently changed or silently missed.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import re
24
+ import unicodedata
25
+ from typing import List, Optional
26
+
27
+ from ..textnorm import tokenize
28
+ from . import context, gazetteer, patterns
29
+ from .gazetteer import Gazetteer
30
+ from .spacy_ner import SpacyNER
31
+ from .types import Detection, PERSON
32
+
33
+ _IDENTIFIER_TYPES = {"EMAIL", "PHONE", "NATIONAL_ID", "MRN", "URL", "DATE", "IP"}
34
+
35
+ # Eponym context: a person-name token that is really a disease/sign/test name.
36
+ _EPONYM_BEFORE = re.compile(
37
+ r"(?i)(?:enfermedad|s[ií]ndrome|signo|prueba|maniobra|escala|fen[oó]meno|reflejo|"
38
+ r"cl[aá]sificaci[oó]n|[ií]ndice|test|disease|syndrome|sign|scale|reflex|maneuver|"
39
+ r"score|grade|stage|classification|lesi[oó]n|tumor)\s+de\s+$"
40
+ )
41
+ _EPONYM_AFTER = re.compile(
42
+ r"(?i)^['’]?s?\s*(?:disease|syndrome|sign|test|scale|reflex|maneuver|score|grade|"
43
+ r"stage|classification|cell|cells|body|bodies|node|nodes|method|stain|tumou?r)\b"
44
+ )
45
+
46
+ _THRESHOLDS = {"conservative": 0.80, "balanced": 0.65, "aggressive": 0.35}
47
+
48
+
49
+ class Detector:
50
+ """Full multi-stage detector for one language."""
51
+
52
+ def __init__(
53
+ self,
54
+ lang: str = "es",
55
+ mode: str = "conservative",
56
+ spacy_model: Optional[str] = None,
57
+ extra_name_files=(),
58
+ extra_stoplist_files=(),
59
+ id_checksums=(),
60
+ use_medical_vocab=True,
61
+ ):
62
+ self.lang = lang
63
+ self.mode = mode
64
+ self.id_checksums = list(id_checksums or [])
65
+ self.threshold = _THRESHOLDS.get(mode, 0.80)
66
+ self.gaz: Gazetteer = Gazetteer.load(
67
+ lang, extra_name_files, extra_stoplist_files, use_medical_vocab
68
+ )
69
+ self.ner: Optional[SpacyNER] = SpacyNER(spacy_model) if spacy_model else None
70
+
71
+ @property
72
+ def ner_status(self) -> str:
73
+ if self.ner is None:
74
+ return "disabled"
75
+ return "active" if self.ner.available else f"unavailable ({self.ner.error})"
76
+
77
+ # ------------------------------------------------------------------ #
78
+ def detect(self, text: str) -> List[Detection]:
79
+ """Return all detections with ``.accepted`` and ``.confidence`` set.
80
+
81
+ ``text`` is NFC-normalized first so combining-mark (NFD) input like a
82
+ decomposed ``Ramírez`` tokenizes correctly (otherwise the span ends early
83
+ and a fragment leaks). Callers that apply replacements MUST slice the same
84
+ NFC-normalized string (the engine does)."""
85
+ if not text or not str(text).strip():
86
+ return []
87
+ text = unicodedata.normalize("NFC", str(text))
88
+ tokens = tokenize(text)
89
+
90
+ raw: List[Detection] = []
91
+ raw += patterns.find(text)
92
+ if self.id_checksums:
93
+ from . import checksums
94
+ raw += checksums.find_valid_ids(text, self.id_checksums)
95
+ raw += context.find(text, tokens, self.gaz)
96
+ raw += gazetteer.find(text, tokens, self.gaz)
97
+ if self.ner is not None and self.ner.available:
98
+ raw += self.ner.find(text)
99
+
100
+ idents = [d for d in raw if d.entity_type in _IDENTIFIER_TYPES]
101
+ persons = [d for d in raw if d.entity_type == PERSON]
102
+
103
+ accepted_idents = self._resolve_identifiers(idents)
104
+ person_dets = self._resolve_persons(persons, accepted_idents)
105
+
106
+ final = accepted_idents + person_dets
107
+ final.sort(key=lambda d: (d.start, -(d.end - d.start)))
108
+ final = self._drop_accepted_overlaps(final)
109
+ self._suppress_eponyms(text, final)
110
+ return final
111
+
112
+ @staticmethod
113
+ def _suppress_eponyms(text: str, dets: List[Detection]) -> None:
114
+ """Demote eponymous diagnoses (``enfermedad de Wilson``, ``Down syndrome``,
115
+ ``Parkinson's disease``) from replace to review — they are disease names,
116
+ not the patient. Real names in other contexts are unaffected."""
117
+ for d in dets:
118
+ if not d.accepted or d.entity_type != PERSON:
119
+ continue
120
+ if _EPONYM_BEFORE.search(text[: d.start]) or _EPONYM_AFTER.match(text[d.end :]):
121
+ d.accepted = False
122
+ d.meta["suppressed"] = "eponym"
123
+
124
+ # ------------------------------------------------------------------ #
125
+ @staticmethod
126
+ def _resolve_identifiers(idents: List[Detection]) -> List[Detection]:
127
+ """Greedy longest-span-wins over overlapping identifier hits."""
128
+ idents.sort(key=lambda d: (d.start, -(d.end - d.start)))
129
+ kept: List[Detection] = []
130
+ occupied_end = -1
131
+ for d in idents:
132
+ if d.start >= occupied_end:
133
+ d.accepted = True
134
+ d.confidence = max(d.confidence, 0.95)
135
+ kept.append(d)
136
+ occupied_end = d.end
137
+ elif d.end > occupied_end:
138
+ # partial overlap with a shorter tail; skip to avoid double-edit
139
+ continue
140
+ return kept
141
+
142
+ def _resolve_persons(
143
+ self, persons: List[Detection], idents: List[Detection]
144
+ ) -> List[Detection]:
145
+ clusters = _merge_person_spans(persons)
146
+ out: List[Detection] = []
147
+ for c in clusters:
148
+ # suppress person spans overlapping an accepted identifier
149
+ if any(c.overlaps(i) for i in idents):
150
+ continue
151
+ c.confidence = _score_person(c)
152
+ c.accepted = c.confidence >= self.threshold
153
+ out.append(c)
154
+ return out
155
+
156
+ @staticmethod
157
+ def _drop_accepted_overlaps(final: List[Detection]) -> List[Detection]:
158
+ """Ensure accepted spans never overlap (safe replacement)."""
159
+ result: List[Detection] = []
160
+ occupied_end = -1
161
+ for d in sorted(final, key=lambda d: (d.start, -(d.end - d.start))):
162
+ if not d.accepted:
163
+ result.append(d)
164
+ continue
165
+ if d.start >= occupied_end:
166
+ result.append(d)
167
+ occupied_end = d.end
168
+ # else: overlapping accepted span -> drop (already covered)
169
+ return result
170
+
171
+
172
+ # --------------------------------------------------------------------------- #
173
+ def _merge_person_spans(persons: List[Detection]) -> List[Detection]:
174
+ """Merge overlapping person detections into clusters (union span + meta)."""
175
+ if not persons:
176
+ return []
177
+ persons = sorted(persons, key=lambda d: (d.start, d.end))
178
+ clusters: List[Detection] = []
179
+ cur = _clone(persons[0])
180
+ for d in persons[1:]:
181
+ if d.start < cur.end: # overlap
182
+ cur.end = max(cur.end, d.end)
183
+ cur.text += "" # placeholder; text re-set below
184
+ _merge_meta(cur, d)
185
+ else:
186
+ clusters.append(cur)
187
+ cur = _clone(d)
188
+ clusters.append(cur)
189
+ return clusters
190
+
191
+
192
+ def _clone(d: Detection) -> Detection:
193
+ return Detection(
194
+ start=d.start,
195
+ end=d.end,
196
+ text=d.text,
197
+ entity_type=d.entity_type,
198
+ methods=set(d.methods),
199
+ meta=dict(d.meta),
200
+ )
201
+
202
+
203
+ def _merge_meta(cur: Detection, other: Detection) -> None:
204
+ cur.methods |= other.methods
205
+ for key in ("known_given", "known_surname", "run_len"):
206
+ cur.meta[key] = max(cur.meta.get(key, 0), other.meta.get(key, 0))
207
+ cur.meta["bigram"] = cur.meta.get("bigram") or other.meta.get("bigram")
208
+ if cur.meta.get("gender", "u") == "u":
209
+ cur.meta["gender"] = other.meta.get("gender", "u")
210
+ # keep the strongest context strength seen (strong > naming > verb)
211
+ rank = {"strong": 3, "naming": 2, "weak": 1, "verb": 1}
212
+ a, b = cur.meta.get("strength"), other.meta.get("strength")
213
+ cur.meta["strength"] = a if rank.get(a, 0) >= rank.get(b, 0) else b
214
+
215
+
216
+ def _score_person(d: Detection) -> float:
217
+ methods = d.methods
218
+ meta = d.meta
219
+ score = 0.30
220
+ if "context" in methods:
221
+ strength = meta.get("strength")
222
+ score += {"strong": 0.55, "naming": 0.45}.get(strength, 0.30)
223
+ if "ner" in methods:
224
+ score += 0.35
225
+ if meta.get("bigram"):
226
+ score += 0.45
227
+ elif meta.get("run_len", 1) >= 2:
228
+ score += 0.15
229
+ known = meta.get("known_given", 0) + meta.get("known_surname", 0)
230
+ if known >= 1 and "gazetteer" in methods:
231
+ score += 0.10
232
+ if len(methods) >= 2:
233
+ score += 0.20
234
+ return min(score, 0.99)
@@ -0,0 +1,53 @@
1
+ """Optional statistical NER stage (spaCy).
2
+
3
+ Enabled only when ``Policy.spacy_model`` is set and spaCy + the model are
4
+ installed. It is used as an independent PERSON detector and to corroborate
5
+ gazetteer hits. Everything degrades gracefully: if the model is missing,
6
+ ``available`` is ``False`` and :meth:`find` returns nothing, so the tool still
7
+ runs on the deterministic stages alone.
8
+
9
+ pip install "deidkit[ner]"
10
+ python -m spacy download es_core_news_lg
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import List
16
+
17
+ from .types import Detection, PERSON
18
+
19
+ _PERSON_LABELS = {"PER", "PERSON"}
20
+
21
+
22
+ class SpacyNER:
23
+ def __init__(self, model: str):
24
+ self.model = model
25
+ self._nlp = None
26
+ self.available = False
27
+ self.error = None
28
+ try:
29
+ import spacy
30
+
31
+ self._nlp = spacy.load(model, disable=["lemmatizer", "textcat"])
32
+ self.available = True
33
+ except Exception as exc: # ImportError or OSError (model not found)
34
+ self.error = str(exc)
35
+
36
+ def find(self, text: str) -> List[Detection]:
37
+ if not self.available or not text:
38
+ return []
39
+ doc = self._nlp(text)
40
+ out: List[Detection] = []
41
+ for ent in doc.ents:
42
+ if ent.label_ in _PERSON_LABELS:
43
+ out.append(
44
+ Detection(
45
+ start=ent.start_char,
46
+ end=ent.end_char,
47
+ text=ent.text,
48
+ entity_type=PERSON,
49
+ methods={"ner"},
50
+ meta={"gender": "u"},
51
+ )
52
+ )
53
+ return out
@@ -0,0 +1,53 @@
1
+ """Detection data model."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Any, Dict, Set
7
+
8
+ # --- entity types ---------------------------------------------------------- #
9
+ PERSON = "PERSON"
10
+ EMAIL = "EMAIL"
11
+ PHONE = "PHONE"
12
+ NATIONAL_ID = "NATIONAL_ID" # e.g. Colombian cédula
13
+ MRN = "MRN" # medical-record / historia-clínica number
14
+ URL = "URL"
15
+ DATE = "DATE" # date embedded in free text
16
+ IPADDR = "IP"
17
+
18
+ # Namespace helper so callers can write ``entity.PERSON`` if they prefer.
19
+ class entity: # noqa: N801 - used as a namespace
20
+ PERSON = PERSON
21
+ EMAIL = EMAIL
22
+ PHONE = PHONE
23
+ NATIONAL_ID = NATIONAL_ID
24
+ MRN = MRN
25
+ URL = URL
26
+ DATE = DATE
27
+ IP = IPADDR
28
+
29
+
30
+ @dataclass
31
+ class Detection:
32
+ """A detected span of PII inside a text value.
33
+
34
+ Half-open span ``[start, end)`` over the *original* text. ``methods`` records
35
+ which detector stages fired (used by the voting gate). ``confidence`` is set
36
+ by the pipeline after voting. ``accepted`` marks whether it will be replaced
37
+ (vs. sent to the review queue).
38
+ """
39
+
40
+ start: int
41
+ end: int
42
+ text: str
43
+ entity_type: str
44
+ methods: Set[str] = field(default_factory=set)
45
+ confidence: float = 0.0
46
+ accepted: bool = False
47
+ meta: Dict[str, Any] = field(default_factory=dict)
48
+
49
+ def overlaps(self, other: "Detection") -> bool:
50
+ return self.start < other.end and other.start < self.end
51
+
52
+ def __len__(self) -> int:
53
+ return self.end - self.start