pii-eraser 0.1.0__tar.gz

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.
@@ -0,0 +1,6 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.egg-info/
4
+ dist/
5
+ .pytest_cache/
6
+ .ruff_cache/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 37lab OÜ
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,119 @@
1
+ Metadata-Version: 2.5
2
+ Name: pii-eraser
3
+ Version: 0.1.0
4
+ Summary: Find personal data and secrets in text and code, replace them with stable placeholders, locally.
5
+ Project-URL: Homepage, https://37lab.ai
6
+ Project-URL: Repository, https://github.com/tkenaz/pii-eraser
7
+ Author-email: 37lab OÜ <mv@37lab.ai>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: anonymization,gdpr,llm,pii,privacy,redaction,secrets
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: Security
15
+ Classifier: Topic :: Text Processing
16
+ Requires-Python: >=3.10
17
+ Provides-Extra: dev
18
+ Requires-Dist: pytest>=8; extra == 'dev'
19
+ Requires-Dist: ruff>=0.6; extra == 'dev'
20
+ Provides-Extra: gliner
21
+ Requires-Dist: gliner>=0.2.20; extra == 'gliner'
22
+ Description-Content-Type: text/markdown
23
+
24
+ # pii-eraser
25
+
26
+ Find personal data and secrets in text and code. Replace them with stable
27
+ placeholders. Do it locally, before the text goes to a model, a log or a vendor.
28
+
29
+ ```
30
+ Contact Jane Doe <jane.doe@example.org>, key AKIA… → Contact [NAME_1] <[EMAIL_1]>, key [SECRET_1]
31
+ ```
32
+
33
+ - **Local.** Regex rules and an optional NER model run on your machine. No network.
34
+ - **Stable.** A session keeps one placeholder table across many texts:
35
+ `Jane Doe` is `[NAME_1]` in the diff, in every file an agent reads afterwards,
36
+ and in the next file, even where no rule fires again.
37
+ - **Code-aware.** The code profile drops rules that misfire on source (dates in
38
+ changelogs, numbers that look like phones) and checks card numbers (Luhn) and
39
+ IBANs (mod-97). Line counts never change, so line numbers in a review still hold.
40
+ - **Reversible for the owner.** `session.restore(text)` puts the originals back
41
+ into a model's answer, on your side.
42
+
43
+ ## Install
44
+
45
+ ```bash
46
+ pip install pii-eraser # regex rules only, no dependencies
47
+ pip install 'pii-eraser[gliner]' # + GLiNER NER for names and organisations (~400 MB model)
48
+ ```
49
+
50
+ ## Use
51
+
52
+ ```python
53
+ from pii_eraser import Eraser
54
+
55
+ eraser = Eraser.for_prose() # documents, emails, MR descriptions
56
+ result = eraser.erase("Name: Jane Doe, jane.doe@example.org")
57
+ result.text # 'Name: [NAME_1], [EMAIL_1]'
58
+ result.mapping # {'[NAME_1]': 'Jane Doe', '[EMAIL_1]': 'jane.doe@example.org'}
59
+
60
+ session = Eraser.for_code().session() # many texts, one table
61
+ session.erase(diff_text)
62
+ session.erase(file_the_agent_read)
63
+ answer = session.restore(model_answer)
64
+ ```
65
+
66
+ With NER:
67
+
68
+ ```python
69
+ from pii_eraser.detectors.gliner import GlinerDetector
70
+
71
+ Eraser.for_prose(GlinerDetector()) # NER hits are replaced
72
+ Eraser.for_code(GlinerDetector()) # NER hits are only reported in result.signals
73
+ ```
74
+
75
+ On source code an NER model tags variable names and authors as people. In the
76
+ code profile its hits are signals for a human, not replacements.
77
+
78
+ ## CLI
79
+
80
+ ```bash
81
+ pii-eraser contract.txt > contract.clean.txt
82
+ git diff origin/main | pii-eraser --profile code --report --fail-on-secret > /dev/null
83
+ ```
84
+
85
+ The report on stderr lists kind, line and rule, never the original value, so it
86
+ is safe for CI logs. `--fail-on-secret` exits 1 when a secret is found.
87
+
88
+ ## What it finds
89
+
90
+ | Kind | Examples |
91
+ |---|---|
92
+ | `SECRET` | private key blocks, AWS / GitHub / GitLab / Slack / Anthropic / OpenAI / Google / Stripe keys, JWTs, passwords in URLs, `password = "…"` / `API_KEY=…` assignments, long random quoted strings |
93
+ | `EMAIL`, `PHONE` | emails; international, US, Swiss, German phone formats |
94
+ | `NAME` | labelled names, titles, salutations, signature blocks, contract parties; with NER: any person or organisation |
95
+ | `ADDRESS` | US street / city / state / ZIP, UK postcodes, German streets, labelled addresses |
96
+ | `FINANCIAL` | payment cards (Luhn), IBAN (mod-97), account, routing, tax ids |
97
+ | `SSN`, `NATIONAL_ID`, `DOB`, `MRN`, `HEALTH_PLAN_ID` | US SSN, Swiss AHV, Spanish NIE/DNI, UK NI, passports, licences, dates of birth, medical record and insurance numbers (EN/DE) |
98
+
99
+ References to secrets are left alone: `${DB_PASSWORD}`, `<your-token>`,
100
+ `os.environ['SECRET']`, `changeme`.
101
+
102
+ ## Limits
103
+
104
+ Detection is heuristic. Rules miss formats they don't know; NER misses rare
105
+ names and catches some non-names. Treat the output as reduced exposure, not as
106
+ anonymisation in the legal sense, and keep a secret scanner such as gitleaks in
107
+ your pipeline: a leaked key must be rotated, not just masked.
108
+
109
+ ## Origin
110
+
111
+ The personal-data rules and NER filters come from the scrubber of
112
+ 37lab's contract analyzer, tuned on real contracts, medical notes and business
113
+ email. The secret rules, sessions and the code profile were added for agentic
114
+ code review in [semantic-diff](https://github.com/tkenaz/semantic_diff).
115
+
116
+ ## License
117
+
118
+ MIT — [37lab](https://37lab.ai). The default NER model
119
+ `urchade/gliner_medium-v2.1` is Apache-2.0.
@@ -0,0 +1,96 @@
1
+ # pii-eraser
2
+
3
+ Find personal data and secrets in text and code. Replace them with stable
4
+ placeholders. Do it locally, before the text goes to a model, a log or a vendor.
5
+
6
+ ```
7
+ Contact Jane Doe <jane.doe@example.org>, key AKIA… → Contact [NAME_1] <[EMAIL_1]>, key [SECRET_1]
8
+ ```
9
+
10
+ - **Local.** Regex rules and an optional NER model run on your machine. No network.
11
+ - **Stable.** A session keeps one placeholder table across many texts:
12
+ `Jane Doe` is `[NAME_1]` in the diff, in every file an agent reads afterwards,
13
+ and in the next file, even where no rule fires again.
14
+ - **Code-aware.** The code profile drops rules that misfire on source (dates in
15
+ changelogs, numbers that look like phones) and checks card numbers (Luhn) and
16
+ IBANs (mod-97). Line counts never change, so line numbers in a review still hold.
17
+ - **Reversible for the owner.** `session.restore(text)` puts the originals back
18
+ into a model's answer, on your side.
19
+
20
+ ## Install
21
+
22
+ ```bash
23
+ pip install pii-eraser # regex rules only, no dependencies
24
+ pip install 'pii-eraser[gliner]' # + GLiNER NER for names and organisations (~400 MB model)
25
+ ```
26
+
27
+ ## Use
28
+
29
+ ```python
30
+ from pii_eraser import Eraser
31
+
32
+ eraser = Eraser.for_prose() # documents, emails, MR descriptions
33
+ result = eraser.erase("Name: Jane Doe, jane.doe@example.org")
34
+ result.text # 'Name: [NAME_1], [EMAIL_1]'
35
+ result.mapping # {'[NAME_1]': 'Jane Doe', '[EMAIL_1]': 'jane.doe@example.org'}
36
+
37
+ session = Eraser.for_code().session() # many texts, one table
38
+ session.erase(diff_text)
39
+ session.erase(file_the_agent_read)
40
+ answer = session.restore(model_answer)
41
+ ```
42
+
43
+ With NER:
44
+
45
+ ```python
46
+ from pii_eraser.detectors.gliner import GlinerDetector
47
+
48
+ Eraser.for_prose(GlinerDetector()) # NER hits are replaced
49
+ Eraser.for_code(GlinerDetector()) # NER hits are only reported in result.signals
50
+ ```
51
+
52
+ On source code an NER model tags variable names and authors as people. In the
53
+ code profile its hits are signals for a human, not replacements.
54
+
55
+ ## CLI
56
+
57
+ ```bash
58
+ pii-eraser contract.txt > contract.clean.txt
59
+ git diff origin/main | pii-eraser --profile code --report --fail-on-secret > /dev/null
60
+ ```
61
+
62
+ The report on stderr lists kind, line and rule, never the original value, so it
63
+ is safe for CI logs. `--fail-on-secret` exits 1 when a secret is found.
64
+
65
+ ## What it finds
66
+
67
+ | Kind | Examples |
68
+ |---|---|
69
+ | `SECRET` | private key blocks, AWS / GitHub / GitLab / Slack / Anthropic / OpenAI / Google / Stripe keys, JWTs, passwords in URLs, `password = "…"` / `API_KEY=…` assignments, long random quoted strings |
70
+ | `EMAIL`, `PHONE` | emails; international, US, Swiss, German phone formats |
71
+ | `NAME` | labelled names, titles, salutations, signature blocks, contract parties; with NER: any person or organisation |
72
+ | `ADDRESS` | US street / city / state / ZIP, UK postcodes, German streets, labelled addresses |
73
+ | `FINANCIAL` | payment cards (Luhn), IBAN (mod-97), account, routing, tax ids |
74
+ | `SSN`, `NATIONAL_ID`, `DOB`, `MRN`, `HEALTH_PLAN_ID` | US SSN, Swiss AHV, Spanish NIE/DNI, UK NI, passports, licences, dates of birth, medical record and insurance numbers (EN/DE) |
75
+
76
+ References to secrets are left alone: `${DB_PASSWORD}`, `<your-token>`,
77
+ `os.environ['SECRET']`, `changeme`.
78
+
79
+ ## Limits
80
+
81
+ Detection is heuristic. Rules miss formats they don't know; NER misses rare
82
+ names and catches some non-names. Treat the output as reduced exposure, not as
83
+ anonymisation in the legal sense, and keep a secret scanner such as gitleaks in
84
+ your pipeline: a leaked key must be rotated, not just masked.
85
+
86
+ ## Origin
87
+
88
+ The personal-data rules and NER filters come from the scrubber of
89
+ 37lab's contract analyzer, tuned on real contracts, medical notes and business
90
+ email. The secret rules, sessions and the code profile were added for agentic
91
+ code review in [semantic-diff](https://github.com/tkenaz/semantic_diff).
92
+
93
+ ## License
94
+
95
+ MIT — [37lab](https://37lab.ai). The default NER model
96
+ `urchade/gliner_medium-v2.1` is Apache-2.0.
@@ -0,0 +1,9 @@
1
+ """pii-eraser: find personal data and secrets in text and replace them with
2
+ stable placeholders, locally, before the text goes anywhere."""
3
+
4
+ from pii_eraser.eraser import Eraser, ErasingSession, Stage
5
+ from pii_eraser.types import EraseResult, Kind, Span
6
+
7
+ __version__ = "0.1.0"
8
+
9
+ __all__ = ["Eraser", "ErasingSession", "Stage", "EraseResult", "Kind", "Span", "__version__"]
@@ -0,0 +1,60 @@
1
+ """pii-eraser FILE|- → erased text on stdout, a report on stderr.
2
+
3
+ The report names kind, line and rule, never the original value: it is meant
4
+ for CI logs.
5
+ """
6
+
7
+ import argparse
8
+ import json
9
+ import sys
10
+
11
+ from pii_eraser.eraser import Eraser
12
+ from pii_eraser.types import EraseResult, Kind
13
+
14
+
15
+ def _line_of(text: str, offset: int) -> int:
16
+ return text.count("\n", 0, offset) + 1
17
+
18
+
19
+ def _report(original: str, result: EraseResult) -> dict:
20
+ def row(span):
21
+ return {"kind": span.kind.value, "line": _line_of(original, span.start), "rule": span.source}
22
+
23
+ return {
24
+ "found": [row(s) for s in result.found],
25
+ "signals": [row(s) for s in result.signals],
26
+ "secrets": sum(1 for s in result.found if s.kind is Kind.SECRET),
27
+ }
28
+
29
+
30
+ def main(argv=None) -> int:
31
+ parser = argparse.ArgumentParser(prog="pii-eraser", description=__doc__.split("\n")[0])
32
+ parser.add_argument("path", nargs="?", default="-", help="file to read, '-' for stdin")
33
+ parser.add_argument("--profile", choices=("prose", "code"), default="prose")
34
+ parser.add_argument("--ner", action="store_true", help="add GLiNER (pip install 'pii-eraser[gliner]')")
35
+ parser.add_argument("--report", action="store_true", help="JSON report on stderr")
36
+ parser.add_argument("--fail-on-secret", action="store_true", help="exit 1 when a secret is found")
37
+ args = parser.parse_args(argv)
38
+
39
+ text = sys.stdin.read() if args.path == "-" else open(args.path, encoding="utf-8").read()
40
+
41
+ ner = None
42
+ if args.ner:
43
+ from pii_eraser.detectors.gliner import GlinerDetector
44
+
45
+ ner = GlinerDetector()
46
+ eraser = Eraser.for_code(ner) if args.profile == "code" else Eraser.for_prose(ner)
47
+ result = eraser.erase(text)
48
+
49
+ sys.stdout.write(result.text)
50
+ report = _report(text, result)
51
+ if args.report:
52
+ print(json.dumps(report, indent=2), file=sys.stderr)
53
+ if args.fail_on_secret and report["secrets"]:
54
+ print(f"pii-eraser: {report['secrets']} secret(s) found", file=sys.stderr)
55
+ return 1
56
+ return 0
57
+
58
+
59
+ if __name__ == "__main__":
60
+ sys.exit(main())
@@ -0,0 +1,4 @@
1
+ from pii_eraser.detectors.base import Detector
2
+ from pii_eraser.detectors.regex import RegexDetector, Rule
3
+
4
+ __all__ = ["Detector", "RegexDetector", "Rule"]
@@ -0,0 +1,13 @@
1
+ """The one interface every detector implements."""
2
+
3
+ from typing import List, Protocol
4
+
5
+ from pii_eraser.types import Span
6
+
7
+
8
+ class Detector(Protocol):
9
+ name: str
10
+
11
+ def detect(self, text: str) -> List[Span]:
12
+ """Return candidate spans with absolute offsets into `text`."""
13
+ ...
@@ -0,0 +1,335 @@
1
+ """NER detection with GLiNER (zero-shot labels, runs locally, ~400 MB model).
2
+
3
+ Ported from the contract-analyzer scrubber. Install with `pip install
4
+ pii-eraser[gliner]`; the model downloads on first use.
5
+ """
6
+
7
+ import logging
8
+ from typing import Dict, List, Optional, Sequence
9
+
10
+ from pii_eraser.types import Kind, Span
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ DEFAULT_MODEL = "urchade/gliner_medium-v2.1" # Apache-2.0
15
+
16
+ LABEL_TO_KIND: Dict[str, Kind] = {
17
+ "person": Kind.NAME,
18
+ "organization": Kind.NAME,
19
+ "company": Kind.NAME,
20
+ "corporation": Kind.NAME,
21
+ "government": Kind.NAME,
22
+ "county": Kind.NAME,
23
+ "city": Kind.NAME,
24
+ "municipality": Kind.NAME,
25
+ "location": Kind.ADDRESS,
26
+ "address": Kind.ADDRESS,
27
+ "ssn": Kind.SSN,
28
+ "tax_id": Kind.FINANCIAL,
29
+ "passport_number": Kind.NATIONAL_ID,
30
+ "drivers_license": Kind.NATIONAL_ID,
31
+ "bank_account": Kind.FINANCIAL,
32
+ "routing_number": Kind.FINANCIAL,
33
+ "credit_card": Kind.FINANCIAL,
34
+ "phone_number": Kind.PHONE,
35
+ "email": Kind.EMAIL,
36
+ "date_of_birth": Kind.DOB,
37
+ }
38
+
39
+ DEFAULT_LABELS = list(LABEL_TO_KIND)
40
+
41
+ # Role nouns, pronouns and bare titles that NER tags as person/organization but
42
+ # that never identify anyone. Scrubbing them corrupts quotes ("[NAME_5] owning
43
+ # 95% of the Shares") and a downstream model then reports the corruption.
44
+ GENERIC_TERMS = frozenset(
45
+ {
46
+ "client",
47
+ "contractor",
48
+ "vendor",
49
+ "supplier",
50
+ "customer",
51
+ "employee",
52
+ "employees",
53
+ "employer",
54
+ "party",
55
+ "parties",
56
+ "company",
57
+ "corporation",
58
+ "llc",
59
+ "inc",
60
+ "ltd",
61
+ "agreement",
62
+ "contract",
63
+ "document",
64
+ "plaintiff",
65
+ "defendant",
66
+ "attorney",
67
+ "counsel",
68
+ "licensor",
69
+ "licensee",
70
+ "lessor",
71
+ "lessee",
72
+ "buyer",
73
+ "seller",
74
+ "purchaser",
75
+ "shareholder",
76
+ "shareholders",
77
+ "stockholder",
78
+ "stockholders",
79
+ "member",
80
+ "members",
81
+ "director",
82
+ "directors",
83
+ "officer",
84
+ "officers",
85
+ "authorized officer",
86
+ "founder",
87
+ "founders",
88
+ "partner",
89
+ "partners",
90
+ "trustee",
91
+ "beneficiary",
92
+ "executor",
93
+ "administrator",
94
+ "guarantor",
95
+ "borrower",
96
+ "lender",
97
+ "investor",
98
+ "investors",
99
+ "signatory",
100
+ "signatories",
101
+ "witness",
102
+ "board",
103
+ "board of directors",
104
+ "the board",
105
+ "person",
106
+ "persons",
107
+ "individual",
108
+ "individuals",
109
+ "entity",
110
+ "entities",
111
+ "the undersigned",
112
+ "undersigned",
113
+ "each party",
114
+ "either party",
115
+ "i",
116
+ "we",
117
+ "you",
118
+ "he",
119
+ "she",
120
+ "they",
121
+ "heirs",
122
+ "successors",
123
+ "assigns",
124
+ "spouse",
125
+ "affiliate",
126
+ "affiliates",
127
+ "cto",
128
+ "ceo",
129
+ "cfo",
130
+ "coo",
131
+ "cio",
132
+ "president",
133
+ "chairman",
134
+ "chairperson",
135
+ "secretary",
136
+ "treasurer",
137
+ "general counsel",
138
+ "managing director",
139
+ "email address",
140
+ "e-mail address",
141
+ "facsimile number",
142
+ "fax number",
143
+ "telephone number",
144
+ "phone number",
145
+ "mobile number",
146
+ "capital account",
147
+ "bank name",
148
+ "financial institution",
149
+ "account number",
150
+ "registered address",
151
+ "principal place of business",
152
+ "transferee",
153
+ "transferees",
154
+ "transferor",
155
+ "addressee",
156
+ "recipient",
157
+ "recipients",
158
+ "someone",
159
+ "anyone",
160
+ "competitor",
161
+ "competitors",
162
+ "representative",
163
+ "representatives",
164
+ "court",
165
+ "courts",
166
+ "natural person",
167
+ "human being",
168
+ "personal representative",
169
+ "personal representatives",
170
+ "prevailing party",
171
+ # software roles and words NER tags as people in code and commit messages
172
+ "user",
173
+ "users",
174
+ "admin",
175
+ "author",
176
+ "maintainer",
177
+ "owner",
178
+ "reviewer",
179
+ "developer",
180
+ "service",
181
+ "server",
182
+ "client id",
183
+ }
184
+ )
185
+
186
+ # Words that do not make a phrase a name: "New Shareholder", "Each party".
187
+ _STOP_MODIFIERS = frozenset(
188
+ {
189
+ "new",
190
+ "each",
191
+ "every",
192
+ "any",
193
+ "such",
194
+ "said",
195
+ "the",
196
+ "a",
197
+ "an",
198
+ "all",
199
+ "other",
200
+ "proposed",
201
+ "purported",
202
+ "prevailing",
203
+ "transferring",
204
+ "natural",
205
+ "personal",
206
+ "authorized",
207
+ "issuing",
208
+ "federal",
209
+ "state",
210
+ "or",
211
+ "and",
212
+ "of",
213
+ "to",
214
+ "this",
215
+ "that",
216
+ "no",
217
+ "non",
218
+ "former",
219
+ "future",
220
+ "existing",
221
+ }
222
+ )
223
+
224
+ _NOT_NAME_WORDS = GENERIC_TERMS | _STOP_MODIFIERS
225
+
226
+ _NAMEY_LABELS = frozenset(
227
+ {
228
+ "person",
229
+ "organization",
230
+ "company",
231
+ "corporation",
232
+ "government",
233
+ "county",
234
+ "city",
235
+ "municipality",
236
+ }
237
+ )
238
+
239
+ _LABEL_WORDS = {
240
+ "ssn": {"ssn", "social security", "social security number"},
241
+ "tax_id": {"tax id", "tax", "ein"},
242
+ "passport_number": {"passport", "passport number"},
243
+ "drivers_license": {"driver's license", "drivers license", "driver license", "dl"},
244
+ "bank_account": {"bank account", "account", "bank"},
245
+ "routing_number": {"routing", "routing number"},
246
+ "phone_number": {"phone", "telephone", "tel", "mobile", "cell"},
247
+ "email": {"email", "e-mail"},
248
+ "address": {"address"},
249
+ "date_of_birth": {"dob", "date of birth", "birthdate"},
250
+ }
251
+
252
+
253
+ def is_generic_name_phrase(text: str, label: str) -> bool:
254
+ """True when a person/org hit holds no actual name: only role nouns, stop
255
+ modifiers and lowercase words. 'transferring Shareholder' -> True,
256
+ 'Tunnel King Studios Inc.' -> False."""
257
+ if label not in _NAMEY_LABELS:
258
+ return False
259
+ for token in text.split():
260
+ bare = token.strip(".,;:()\"'’").rstrip(".")
261
+ if bare and bare[0].isupper() and bare.lower() not in _NOT_NAME_WORDS:
262
+ return False
263
+ return True
264
+
265
+
266
+ def is_noise(text: str, label: str) -> bool:
267
+ low = text.lower().strip().strip(".,;:")
268
+ if low in GENERIC_TERMS:
269
+ return True
270
+ for article in ("the ", "a ", "an ", "such ", "said ", "any ", "each ", "every "):
271
+ if low.startswith(article) and low[len(article) :] in GENERIC_TERMS:
272
+ return True
273
+ return is_generic_name_phrase(text, label) or low in _LABEL_WORDS.get(label, ())
274
+
275
+
276
+ class GlinerDetector:
277
+ name = "gliner"
278
+
279
+ def __init__(
280
+ self,
281
+ model_id: str = DEFAULT_MODEL,
282
+ labels: Optional[Sequence[str]] = None,
283
+ # 0.5: below it the model floods the table with generic phrases scored
284
+ # as names; rare surnames it under-scores are left to the regex rules.
285
+ threshold: float = 0.5,
286
+ chunk_size: int = 1500, # characters; the model truncates near 384 tokens
287
+ overlap: int = 200,
288
+ model=None, # injected for tests
289
+ ):
290
+ self.model_id = model_id
291
+ self.labels = list(labels or DEFAULT_LABELS)
292
+ self.threshold = threshold
293
+ self.chunk_size = chunk_size
294
+ self.overlap = overlap
295
+ self._model = model
296
+
297
+ def _load(self):
298
+ if self._model is None:
299
+ try:
300
+ from gliner import GLiNER
301
+ except ImportError as e:
302
+ raise ImportError("GLiNER is not installed: pip install 'pii-eraser[gliner]'") from e
303
+ logger.info("loading GLiNER model %s", self.model_id)
304
+ self._model = GLiNER.from_pretrained(self.model_id)
305
+ return self._model
306
+
307
+ def detect(self, text: str) -> List[Span]:
308
+ model = self._load()
309
+ spans: Dict[tuple, Span] = {}
310
+ start = 0
311
+ while start < len(text):
312
+ end = min(start + self.chunk_size, len(text))
313
+ chunk = text[start:end]
314
+ for entity in model.predict_entities(chunk, self.labels, threshold=self.threshold):
315
+ label, value = entity["label"], entity["text"]
316
+ if is_noise(value, label):
317
+ continue
318
+ local = entity.get("start")
319
+ if local is None or chunk[local : local + len(value)] != value:
320
+ local = chunk.find(value)
321
+ if local == -1:
322
+ continue
323
+ abs_start = start + local
324
+ spans[(abs_start, value)] = Span(
325
+ start=abs_start,
326
+ end=abs_start + len(value),
327
+ text=value,
328
+ kind=LABEL_TO_KIND.get(label, Kind.NAME),
329
+ confidence=float(entity["score"]),
330
+ source=f"gliner:{label}",
331
+ )
332
+ if end >= len(text):
333
+ break
334
+ start = end - self.overlap
335
+ return list(spans.values())